From decc78b22535266edf72595bb17a554eb1d81950 Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 21 Aug 2026 12:45:40 +0200 Subject: [PATCH 01/87] options --- src/app/models/options.model.ts | 18 ++++++- src/app/services/options.service.spec.ts | 41 ++++++++++++---- src/app/services/options.service.ts | 60 ++++++++++++++++++++++-- 3 files changed, 102 insertions(+), 17 deletions(-) diff --git a/src/app/models/options.model.ts b/src/app/models/options.model.ts index 4408f091b..e8795be7b 100644 --- a/src/app/models/options.model.ts +++ b/src/app/models/options.model.ts @@ -21,12 +21,25 @@ export const OPTION_VALUES = { unitSearchViewMode: ['list', 'card', 'chassis', 'table'], forceOverviewViewMode: ['expanded', 'compact', 'table'], ASVehiclesCriticalHitTable: ['default', 'scouringSands'], + automationMode: ['yes', 'ask', 'no'], } as const; export type AvailabilitySource = typeof OPTION_VALUES.availabilitySource[number]; export type RecordSheetDoubleTapZoomResetMode = typeof OPTION_VALUES.recordSheetDoubleTapZoomReset[number]; export type ColorScheme = typeof OPTION_VALUES.colorScheme[number]; export type UnitSearchViewMode = typeof OPTION_VALUES.unitSearchViewMode[number]; +export type AutomationMode = typeof OPTION_VALUES.automationMode[number]; + +export interface CBTAutomationOptions { + heatAndDissipation: AutomationMode; + heatEffects: AutomationMode; + pilotHitsAndConsciousness: AutomationMode; + internalExplosions: AutomationMode; + criticalHitChance: AutomationMode; + breachAndFlood: AutomationMode; +} + +export type CBTAutomationKey = keyof CBTAutomationOptions; export interface SkillRangeOption { min: number; @@ -52,6 +65,7 @@ export interface ForceGeneratorOptions { maxDelta: number; }; failureSearchWindowMs: number; + ignoreRarityWeight: boolean; preventDuplicateChassis: boolean; useTaggedQuantities: boolean; useUnitTagsAsChassisTags: boolean; @@ -83,7 +97,7 @@ export interface Options { }, sidebarLipPosition?: string; trackPhaseAndTurn: boolean; - cbtAutomations: boolean; + cbtAutomationOptions: CBTAutomationOptions; CBTOptionalRules: CBTOptionalRules; CBTRules: typeof OPTION_VALUES.CBTRules[number]; ASUseHex: boolean; @@ -110,4 +124,4 @@ export interface Options { // Force Budget Optimizer forceBudgetOptimizerLastSkills: ForceBudgetOptimizerLastSkills; -} \ No newline at end of file +} diff --git a/src/app/services/options.service.spec.ts b/src/app/services/options.service.spec.ts index cf20e9d01..c3fb3a6b4 100644 --- a/src/app/services/options.service.spec.ts +++ b/src/app/services/options.service.spec.ts @@ -46,6 +46,37 @@ describe('OptionsService', () => { expect(service.options().enableForceSyncConflictDialog).toBeFalse(); }); + it('defaults heat effects to ask while preserving the established automation defaults', async () => { + savedOptions = null; + + const service = await createService(); + + expect(service.options().cbtAutomationOptions).toEqual({ + heatAndDissipation: 'no', + heatEffects: 'ask', + pilotHitsAndConsciousness: 'ask', + internalExplosions: 'ask', + criticalHitChance: 'ask', + breachAndFlood: 'ask', + }); + }); + + it('restores each heat automation policy independently', async () => { + savedOptions = { + cbtAutomationOptions: { + heatAndDissipation: 'yes', + heatEffects: 'no', + }, + }; + + const service = await createService(); + + expect(service.cbtAutomationMode('heatAndDissipation')).toBe('yes'); + expect(service.cbtAutomationMode('heatEffects')).toBe('no'); + expect(service.cbtAutomationMode('pilotHitsAndConsciousness')).toBe('ask'); + expect(service.cbtAutomationMode('criticalHitChance')).toBe('ask'); + }); + it('restores the force sync conflict dialog preference', async () => { savedOptions = { enableForceSyncConflictDialog: true }; @@ -84,7 +115,6 @@ describe('OptionsService', () => { megaMekAvailabilityFiltersUseAllScopedOptions: 1, recordSheetDoubleTapZoomReset: 'always', trackPhaseAndTurn: 'true', - cbtAutomations: 1, CBTRules: 'basic', ASUseHex: 'false', c3NetworkConnectionsAboveNodes: 0, @@ -116,7 +146,6 @@ describe('OptionsService', () => { megaMekAvailabilityFiltersUseAllScopedOptions: true, recordSheetDoubleTapZoomReset: 'contextual', trackPhaseAndTurn: true, - cbtAutomations: false, CBTRules: 'tw', ASUseHex: false, c3NetworkConnectionsAboveNodes: false, @@ -263,14 +292,6 @@ describe('OptionsService', () => { }); }); - it('restores a disabled CBT automations preference', async () => { - savedOptions = { cbtAutomations: false }; - - const service = await createService(); - - expect(service.options().cbtAutomations).toBeFalse(); - }); - it('uses CBT optional-rule defaults', async () => { savedOptions = null; diff --git a/src/app/services/options.service.ts b/src/app/services/options.service.ts index 3b6e9b1cf..4b9a99f6e 100644 --- a/src/app/services/options.service.ts +++ b/src/app/services/options.service.ts @@ -4,7 +4,7 @@ import { inject, Injectable, signal } from '@angular/core'; import { DbService } from './db.service'; -import { OPTION_VALUES, type CBTOptionalRules, type ColorScheme, type ForceBudgetOptimizerLastSkills, type ForceGeneratorOptions, type Options } from '../models/options.model'; +import { OPTION_VALUES, type AutomationMode, type CBTAutomationKey, type CBTAutomationOptions, type CBTOptionalRules, type ColorScheme, type ForceBudgetOptimizerLastSkills, type ForceGeneratorOptions, type Options } from '../models/options.model'; import { PRINT_OPTION_VALUES, type PrintAllOptions } from '../models/print-options.model'; import { GameSystem, normalizeUnitServerUrl } from '../models/common.model'; @@ -43,7 +43,14 @@ const DEFAULT_OPTIONS: Options = { recordSheetDoubleTapZoomReset: 'contextual', syncZoomBetweenSheets: true, trackPhaseAndTurn: true, - cbtAutomations: false, + cbtAutomationOptions: { + heatAndDissipation: 'no', + heatEffects: 'ask', + pilotHitsAndConsciousness: 'ask', + internalExplosions: 'ask', + criticalHitChance: 'ask', + breachAndFlood: 'ask', + }, CBTOptionalRules: { forcedWithdrawal: true, extremeRange: false, @@ -68,6 +75,7 @@ const DEFAULT_OPTIONS: Options = { maxDelta: 2, }, failureSearchWindowMs: 300, + ignoreRarityWeight: false, preventDuplicateChassis: false, useTaggedQuantities: false, useUnitTagsAsChassisTags: false, @@ -202,6 +210,7 @@ function resolveForceGeneratorOptions(saved: Options | null | undefined): ForceG maxDelta: resolveSavedValue(forceGenerator?.lastSkills?.maxDelta, defaults.lastSkills.maxDelta), }, failureSearchWindowMs: resolveSavedValue(forceGenerator?.failureSearchWindowMs, defaults.failureSearchWindowMs), + ignoreRarityWeight: resolveSavedValue(forceGenerator?.ignoreRarityWeight, defaults.ignoreRarityWeight), preventDuplicateChassis: resolveSavedValue(forceGenerator?.preventDuplicateChassis, defaults.preventDuplicateChassis), useTaggedQuantities: resolveSavedValue(forceGenerator?.useTaggedQuantities, defaults.useTaggedQuantities), useUnitTagsAsChassisTags: resolveSavedValue(forceGenerator?.useUnitTagsAsChassisTags, defaults.useUnitTagsAsChassisTags), @@ -216,6 +225,42 @@ function resolveCBTOptionalRules(saved: Options | null | undefined): CBTOptional }; } +function resolveCBTAutomationOptions(saved: Options | null | undefined): CBTAutomationOptions { + const defaults = DEFAULT_OPTIONS.cbtAutomationOptions; + return { + heatAndDissipation: resolveSavedValue( + saved?.cbtAutomationOptions?.heatAndDissipation, + defaults.heatAndDissipation, + OPTION_VALUES.automationMode, + ), + heatEffects: resolveSavedValue( + saved?.cbtAutomationOptions?.heatEffects, + defaults.heatEffects, + OPTION_VALUES.automationMode, + ), + pilotHitsAndConsciousness: resolveSavedValue( + saved?.cbtAutomationOptions?.pilotHitsAndConsciousness, + defaults.pilotHitsAndConsciousness, + OPTION_VALUES.automationMode, + ), + internalExplosions: resolveSavedValue( + saved?.cbtAutomationOptions?.internalExplosions, + defaults.internalExplosions, + OPTION_VALUES.automationMode, + ), + criticalHitChance: resolveSavedValue( + saved?.cbtAutomationOptions?.criticalHitChance, + defaults.criticalHitChance, + OPTION_VALUES.automationMode, + ), + breachAndFlood: resolveSavedValue( + saved?.cbtAutomationOptions?.breachAndFlood, + defaults.breachAndFlood, + OPTION_VALUES.automationMode, + ), + }; +} + function resolveLastCanvasState(saved: unknown): Options['lastCanvasState'] { if (!saved || typeof saved !== 'object') { return undefined; @@ -261,7 +306,7 @@ export class OptionsService { printAllOptions: { ...DEFAULT_OPTIONS.printAllOptions }, recordSheetDoubleTapZoomReset: DEFAULT_OPTIONS.recordSheetDoubleTapZoomReset, trackPhaseAndTurn: DEFAULT_OPTIONS.trackPhaseAndTurn, - cbtAutomations: DEFAULT_OPTIONS.cbtAutomations, + cbtAutomationOptions: { ...DEFAULT_OPTIONS.cbtAutomationOptions }, CBTOptionalRules: { ...DEFAULT_OPTIONS.CBTOptionalRules }, CBTRules: DEFAULT_OPTIONS.CBTRules, ASUseHex: DEFAULT_OPTIONS.ASUseHex, @@ -304,7 +349,7 @@ export class OptionsService { lastCanvasState: resolveLastCanvasState(saved?.lastCanvasState), sidebarLipPosition: typeof saved?.sidebarLipPosition === 'string' ? saved.sidebarLipPosition : undefined, trackPhaseAndTurn: resolveSavedValue(saved?.trackPhaseAndTurn, DEFAULT_OPTIONS.trackPhaseAndTurn), - cbtAutomations: resolveSavedValue(saved?.cbtAutomations, DEFAULT_OPTIONS.cbtAutomations), + cbtAutomationOptions: resolveCBTAutomationOptions(saved), CBTOptionalRules: resolveCBTOptionalRules(saved), CBTRules: resolveSavedValue(saved?.CBTRules, DEFAULT_OPTIONS.CBTRules, OPTION_VALUES.CBTRules), ASUseHex: resolveSavedValue(saved?.ASUseHex, DEFAULT_OPTIONS.ASUseHex), @@ -333,6 +378,11 @@ export class OptionsService { await this.dbService.saveOptions(updated); } + /** Returns the configured mode for one CBT automation. */ + cbtAutomationMode(key: CBTAutomationKey): AutomationMode { + return this.options().cbtAutomationOptions[key]; + } + async updateForceGeneratorOptions( updater: (options: ForceGeneratorOptions) => ForceGeneratorOptions, ) { @@ -343,4 +393,4 @@ export class OptionsService { this.options.set(updated); await this.dbService.saveOptions(updated); } -} \ No newline at end of file +} From 9f357c12ed7c6af1f16aed05d7aa6f5c36ecd588 Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 21 Aug 2026 14:24:37 +0200 Subject: [PATCH 02/87] merge from main --- .../ability-dropdown-panel.component.ts | 4 +- .../edit-as-pilot-dialog.component.ts | 38 ++++++++++-- .../floating-comp-info.component.spec.ts | 53 +++++++++++++++++ .../floating-comp-info.component.ts | 7 +-- .../force-builder-viewer.component.scss | 1 - .../force-builder-viewer.component.ts | 21 +++++-- .../force-overview-dialog.component.ts | 6 +- .../formation-info-dialog.component.ts | 59 +++++++++++++++++++ .../formation-info.component.ts | 26 +++++--- .../hex-slider/hex-slider.component.html | 7 ++- .../hex-slider/hex-slider.component.scss | 16 +++-- .../hex-slider/hex-slider.component.spec.ts | 17 ++++++ .../hex-slider/hex-slider.component.ts | 17 +++++- .../join-lobby-dialog.component.ts | 6 +- .../lobby-dialog/lobby-dialog.component.scss | 2 +- .../multi-select-dropdown.component.css | 6 +- .../multi-select-dropdown.component.html | 4 +- .../multi-select-dropdown.component.spec.ts | 24 +++++++- .../multi-select-dropdown.component.ts | 20 ++++++- src/app/unit-search.worker.spec.ts | 41 ++++++++++++- 20 files changed, 331 insertions(+), 44 deletions(-) create mode 100644 src/app/components/floating-comp-info/floating-comp-info.component.spec.ts diff --git a/src/app/components/edit-as-pilot-dialog/ability-dropdown-panel.component.ts b/src/app/components/edit-as-pilot-dialog/ability-dropdown-panel.component.ts index 2a93dcb47..892c89932 100644 --- a/src/app/components/edit-as-pilot-dialog/ability-dropdown-panel.component.ts +++ b/src/app/components/edit-as-pilot-dialog/ability-dropdown-panel.component.ts @@ -36,7 +36,7 @@ export interface AbilityDropdownOption { } @for (ability of sortedAbilities(); track ability.id) { @let abilityCost = ability.cost ?? 0; - @let isDisabled = disabledIds().includes(ability.id) || abilityCost > remainingCost(); + @let isDisabled = disabledIds().includes(ability.id) || abilityCost > remainingCost() || !!ability.unitTypeRestricted; @@ -84,6 +111,29 @@ export interface FormationInfoDialogData { min-width: 100px; } + .formation-target { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 14px; + padding: 12px; + border: 1px solid var(--border-color); + text-align: left; + } + + .formation-target label { + font-weight: 700; + } + + .formation-target-select { + width: 100%; + } + + .formation-target-warning { + color: var(--bt-orange, #f2a900); + font-size: 0.85em; + } + .formation-warning { display: flex; align-items: center; @@ -107,6 +157,15 @@ export interface FormationInfoDialogData { export class FormationInfoDialogComponent { public dialogRef = inject(DialogRef); readonly data: FormationInfoDialogData = inject(DIALOG_DATA) as FormationInfoDialogData; + selectedTargetGroupId: string | null = this.data.formationTargetGroupId ?? null; + + onTargetChange(event: Event): void { + this.selectedTargetGroupId = (event.target as HTMLSelectElement).value || null; + } + + apply(): void { + this.dialogRef.close({ formationTargetGroupId: this.selectedTargetGroupId } satisfies FormationInfoDialogResult); + } close(): void { this.dialogRef.close(); diff --git a/src/app/components/formation-info/formation-info.component.ts b/src/app/components/formation-info/formation-info.component.ts index cc35bf83e..0a8155035 100644 --- a/src/app/components/formation-info/formation-info.component.ts +++ b/src/app/components/formation-info/formation-info.component.ts @@ -3,7 +3,7 @@ // Author: Drake import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core'; -import { formationInheritsParentEffects, resolveFormationGameSystemText, type FormationTypeDefinition, type FormationEffectGroup, type FormationWideAbility } from '../../utils/formation-type.model'; +import { formationInheritsParentEffects, type FormationTypeDefinition, type FormationEffectGroup, type FormationWideAbility } from '../../utils/formation-type.model'; import { getFormationDefinition } from '../../utils/formation-blueprints'; import { type PilotAbility, PILOT_ABILITIES, getAbilityDetails, formatSummaryMovement } from '../../models/pilot-abilities.model'; import { type CommandAbility, COMMAND_ABILITIES } from '../../models/command-abilities.model'; @@ -34,6 +34,7 @@ export interface ResolvedEffectGroup { abilities: ResolvedAbility[]; selectionLabel: string; distributionLabel: string; + perTurn: boolean; } @Component({ @@ -122,7 +123,7 @@ export interface ResolvedEffectGroup { · } {{ eg.distributionLabel }} - @if (eg.group.perTurn) { + @if (eg.perTurn) { · Per turn } @@ -416,7 +417,7 @@ export class FormationInfoComponent { /** Resolved formation bonus text for the current formation & game system. */ effectDescriptionText = computed(() => { - const effectDescription = resolveFormationGameSystemText(this.formation()?.effectDescription, this.gameSystem()); + const effectDescription = this.formation()?.effectDescription; return effectDescription ? formatSummaryMovement(effectDescription, this.optionsService.options().ASUseHex) : null; }); @@ -424,7 +425,7 @@ export class FormationInfoComponent { requirementsText = computed(() => { const def = this.formation(); if (!def?.requirements) return null; - const requirements = def.requirements(this.gameSystem()); + const requirements = def.requirements; return requirements ? formatSummaryMovement(requirements, this.optionsService.options().ASUseHex) : null; }); @@ -432,14 +433,14 @@ export class FormationInfoComponent { private parentFormation = computed(() => { const def = this.formation(); if (!formationInheritsParentEffects(def) || !def?.parent) return null; - return getFormationDefinition(def.parent); + return getFormationDefinition(def.parent, this.gameSystem()); }); /** Resolved parent requirements text. */ parentRequirementsText = computed(() => { const parent = this.parentFormation(); if (!parent?.requirements) return null; - const requirements = parent.requirements(this.gameSystem()); + const requirements = parent.requirements; return requirements ? formatSummaryMovement(requirements, this.optionsService.options().ASUseHex) : null; }); @@ -490,14 +491,14 @@ export class FormationInfoComponent { resolvedEffectGroups = computed(() => { const def = this.formation(); - const effectGroups = getInheritedFormationEffectGroups(def); + const effectGroups = getInheritedFormationEffectGroups(def, this.gameSystem()); if (effectGroups.length === 0) return []; return effectGroups.map(group => { const abilities: ResolvedAbility[] = []; // Resolve pilot abilities - if (group.distribution !== 'formation-wide' && group.abilityIds) { + if (group.distribution !== 'formation-wide' && 'abilityIds' in group && group.abilityIds) { for (const id of group.abilityIds) { const pilot = PILOT_ABILITIES.find(a => a.id === id); if (pilot) { @@ -514,7 +515,7 @@ export class FormationInfoComponent { } // Resolve command abilities - if (group.distribution !== 'formation-wide' && group.commandAbilityIds) { + if (group.distribution !== 'formation-wide' && 'commandAbilityIds' in group && group.commandAbilityIds) { for (const id of group.commandAbilityIds) { const cmd = COMMAND_ABILITIES.find(a => a.id === id); if (cmd) { @@ -544,6 +545,7 @@ export class FormationInfoComponent { abilities, selectionLabel: this.getSelectionLabel(group), distributionLabel: this.getDistributionLabel(group), + perTurn: 'perTurn' in group && group.perTurn === true, }; }); }); @@ -557,6 +559,7 @@ export class FormationInfoComponent { case 'choose-one': return 'Choose one ability for all'; case 'choose-each': return 'Each recipient chooses'; case 'all': return 'All listed abilities'; + case 'copy': return 'Copy assigned SPAs from target'; default: return ''; } } @@ -565,6 +568,11 @@ export class FormationInfoComponent { const n = this.unitCount(); switch (group.distribution) { case 'formation-wide': return 'Formation-wide'; + case 'formation-target': { + return group.recipientLimit === 'half-self-round-down' + ? (n != null ? `Half this formation (${Math.floor(n / 2)} units)` : 'Half this formation (round down)') + : '1 unit per 2 target bonus recipients'; + } case 'all': return 'All units'; case 'half-round-down': { const count = n != null ? Math.floor(n / 2) : undefined; diff --git a/src/app/components/hex-slider/hex-slider.component.html b/src/app/components/hex-slider/hex-slider.component.html index f4daf72ed..2295ee208 100644 --- a/src/app/components/hex-slider/hex-slider.component.html +++ b/src/app/components/hex-slider/hex-slider.component.html @@ -5,7 +5,7 @@ tabindex="0" [attr.aria-label]="ariaLabel()" [attr.aria-valuemin]="effectiveMinValue()" - [attr.aria-valuemax]="maxValue()" + [attr.aria-valuemax]="effectiveMaxValue()" [attr.aria-valuenow]="clampedValue()" [attr.aria-valuetext]="valueLabel()" (keydown)="onKeyDown($event)" @@ -13,7 +13,10 @@
@for (tick of displayTicks(); track tick) { diff --git a/src/app/components/hex-slider/hex-slider.component.scss b/src/app/components/hex-slider/hex-slider.component.scss index 5f5be8acf..fe013e8f0 100644 --- a/src/app/components/hex-slider/hex-slider.component.scss +++ b/src/app/components/hex-slider/hex-slider.component.scss @@ -43,15 +43,23 @@ inset-inline-end: calc(var(--hex-slider-track-overhang) * -1); block-size: var(--hex-slider-track-height); transform: translateY(-50%); - background: rgba(70, 70, 70, 0.72); - border: 1px solid rgba(255, 255, 255, 0.06); + background: var(--background-input); box-sizing: border-box; overflow: hidden; } .blocked-track { - block-size: 100%; - background: #800; + position: absolute; + inset-block: 0; + background: #666; +} + +.blocked-min-track { + inset-inline-start: 0; +} + +.blocked-max-track { + inset-inline-end: 0; } .tick { diff --git a/src/app/components/hex-slider/hex-slider.component.spec.ts b/src/app/components/hex-slider/hex-slider.component.spec.ts index 9d6585977..042f949de 100644 --- a/src/app/components/hex-slider/hex-slider.component.spec.ts +++ b/src/app/components/hex-slider/hex-slider.component.spec.ts @@ -81,6 +81,23 @@ describe('HexSliderComponent', () => { expect(valueCommits).toEqual([1]); }); + it('shows and enforces a blocked upper range', () => { + fixture.componentRef.setInput('blockedMax', 6); + fixture.detectChanges(); + const slider = fixture.nativeElement.querySelector('.hex-slider') as HTMLDivElement; + + expect(component.effectiveMaxValue()).toBe(6); + expect(component.blockedMaxPercent()).toBe(40); + expect(slider.getAttribute('aria-valuemax')).toBe('6'); + expect(fixture.nativeElement.querySelector('.blocked-max-track')).not.toBeNull(); + + slider.dispatchEvent(pointerEvent('pointerdown', 1, 90)); + window.dispatchEvent(pointerEvent('pointerup', 1, 90)); + + expect(valueChanges).toEqual([6]); + expect(valueCommits).toEqual([6]); + }); + it('uses tick label overrides without replacing other generated tick labels', () => { fixture.componentRef.setInput('tickLabelOverrides', { 8: 'RUN', 10: 'MASC' }); fixture.detectChanges(); diff --git a/src/app/components/hex-slider/hex-slider.component.ts b/src/app/components/hex-slider/hex-slider.component.ts index a44981e64..e29b2ca79 100644 --- a/src/app/components/hex-slider/hex-slider.component.ts +++ b/src/app/components/hex-slider/hex-slider.component.ts @@ -35,6 +35,7 @@ export class HexSliderComponent { readonly min = input(0); readonly max = input(100); readonly blockedMin = input(null); + readonly blockedMax = input(null); readonly step = input(1); readonly value = input(0); readonly ticks = input(null); @@ -57,11 +58,23 @@ export class HexSliderComponent { if (blockedMin === null) return this.minValue(); return Math.max(this.minValue(), Math.min(this.maxValue(), this.normalizeNumber(blockedMin, this.minValue()))); }); + readonly effectiveMaxValue = computed(() => { + const blockedMax = this.blockedMax(); + if (blockedMax === null) return this.maxValue(); + return Math.max( + this.effectiveMinValue(), + Math.min(this.maxValue(), this.normalizeNumber(blockedMax, this.maxValue())), + ); + }); readonly stepValue = computed(() => Math.max(0.000001, Math.abs(this.normalizeNumber(this.step(), 1)))); readonly clampedValue = computed(() => this.alignToStep(this.value())); readonly valueLabel = computed(() => this.label() ?? `${this.clampedValue()}`); readonly valuePercent = computed(() => this.percentForValue(this.clampedValue())); readonly blockedMinPercent = computed(() => this.effectiveMinValue() > this.minValue() ? this.percentForValue(this.effectiveMinValue()) : 0); + readonly blockedMaxPercent = computed(() => this.effectiveMaxValue() < this.maxValue() + ? 100 - this.percentForValue(this.effectiveMaxValue()) + : 0 + ); readonly displayTicks = computed(() => { const explicitTicks = this.ticks(); if (explicitTicks !== null) { @@ -134,7 +147,7 @@ export class HexSliderComponent { if (event.key === 'PageUp') next = this.clampedValue() + step * 5; if (event.key === 'PageDown') next = this.clampedValue() - step * 5; if (event.key === 'Home') next = this.effectiveMinValue(); - if (event.key === 'End') next = this.maxValue(); + if (event.key === 'End') next = this.effectiveMaxValue(); if (next === null) return; event.preventDefault(); @@ -198,7 +211,7 @@ export class HexSliderComponent { private alignToStep(value: number): number { const min = this.minValue(); - const max = this.maxValue(); + const max = this.effectiveMaxValue(); const effectiveMin = this.effectiveMinValue(); const step = this.stepValue(); const stepped = min + Math.round((value - min) / step) * step; diff --git a/src/app/components/join-lobby-dialog/join-lobby-dialog.component.ts b/src/app/components/join-lobby-dialog/join-lobby-dialog.component.ts index baa5209f6..7e13a78f6 100644 --- a/src/app/components/join-lobby-dialog/join-lobby-dialog.component.ts +++ b/src/app/components/join-lobby-dialog/join-lobby-dialog.component.ts @@ -129,7 +129,7 @@ export interface JoinLobbyDialogData { font-family: monospace; font-size: 1.4rem; font-weight: 700; - text-transform: lowercase; + text-transform: uppercase; } .hint { @@ -163,7 +163,7 @@ export class JoinLobbyDialogComponent { readonly generatingName = signal(false); readonly joining = signal(false); readonly joinError = signal(''); - readonly isValid = computed(() => /^[a-z0-9]{4}$/.test(this.code()) && normalizeDisplayName(this.displayName()) !== null); + readonly isValid = computed(() => /^[a-z0-9]{4}$/i.test(this.code()) && normalizeDisplayName(this.displayName()) !== null); onCodeInput(event: Event): void { const input = event.target as HTMLInputElement; @@ -194,7 +194,7 @@ export class JoinLobbyDialogComponent { event?.stopPropagation(); if (this.joining()) return; const displayName = normalizeDisplayName(this.displayName()); - if (!displayName || !/^[a-z0-9]{4}$/.test(this.code())) return; + if (!displayName || !/^[a-z0-9]{4}$/i.test(this.code())) return; this.joining.set(true); this.joinError.set(''); diff --git a/src/app/components/lobby-dialog/lobby-dialog.component.scss b/src/app/components/lobby-dialog/lobby-dialog.component.scss index d768c7f17..0d4d26f04 100644 --- a/src/app/components/lobby-dialog/lobby-dialog.component.scss +++ b/src/app/components/lobby-dialog/lobby-dialog.component.scss @@ -43,7 +43,7 @@ font-weight: bold; line-height: 1; letter-spacing: 0; - text-transform: lowercase; + text-transform: uppercase; } .lobby-code-wrapper { diff --git a/src/app/components/multi-select-dropdown/multi-select-dropdown.component.css b/src/app/components/multi-select-dropdown/multi-select-dropdown.component.css index d0a8aec67..6794d7e5a 100644 --- a/src/app/components/multi-select-dropdown/multi-select-dropdown.component.css +++ b/src/app/components/multi-select-dropdown/multi-select-dropdown.component.css @@ -141,6 +141,10 @@ -webkit-overflow-scrolling: touch; } +.option-item.option-section-start { + border-top: 1px solid var(--border-color); +} + .options-viewport .option-item { align-items: center; height: var(--virtual-option-height); @@ -377,4 +381,4 @@ .semantic-item.semantic-not { color: #c00; -} \ No newline at end of file +} diff --git a/src/app/components/multi-select-dropdown/multi-select-dropdown.component.html b/src/app/components/multi-select-dropdown/multi-select-dropdown.component.html index 078da3aab..8a9ce23c3 100644 --- a/src/app/components/multi-select-dropdown/multi-select-dropdown.component.html +++ b/src/app/components/multi-select-dropdown/multi-select-dropdown.component.html @@ -154,6 +154,7 @@ [attr.data-option-name]="option.name" [attr.aria-selected]="isSelected(option.name) ? 'true' : 'false'" [class.keyboard-focused]="optionIndex === keyboardFocusedIndex()" + [class.option-section-start]="optionSectionBreakIndexes().has(optionIndex)" [class.unavailable]="option.available === false" [class.selected-single]="!multiselect() && isSelected(option.name)" (pointerenter)="onOptionPointerHover(option.name)" @@ -224,6 +225,7 @@ [attr.data-option-name]="option.name" [attr.aria-selected]="isSelected(option.name) ? 'true' : 'false'" [class.keyboard-focused]="i === keyboardFocusedIndex()" + [class.option-section-start]="optionSectionBreakIndexes().has(i)" [class.unavailable]="option.available === false" [class.selected-single]="!multiselect() && isSelected(option.name)" (pointerenter)="onOptionPointerHover(option.name)" @@ -290,4 +292,4 @@ }
- \ No newline at end of file + diff --git a/src/app/components/multi-select-dropdown/multi-select-dropdown.component.spec.ts b/src/app/components/multi-select-dropdown/multi-select-dropdown.component.spec.ts index d7fce628a..ce9086d1c 100644 --- a/src/app/components/multi-select-dropdown/multi-select-dropdown.component.spec.ts +++ b/src/app/components/multi-select-dropdown/multi-select-dropdown.component.spec.ts @@ -120,6 +120,28 @@ describe('MultiSelectDropdownComponent', () => { expect(overlayContainerElement.querySelector('.options-list')).not.toBeNull(); }); + it('renders a divider when the visible option section changes', () => { + const fixture = TestBed.createComponent(MultiSelectDropdownComponent); + + fixture.componentRef.setInput('options', [ + { name: 'BMM', available: true }, + { name: 'TW', available: true }, + { name: 'IO:AE', available: true }, + { name: 'TO:AUE', available: true }, + ]); + fixture.componentRef.setInput('optionSection', (option: DropdownOption) => + ['BMM', 'TW'].includes(option.name) ? 'base' : 'non-base'); + fixture.componentInstance.isOpen.set(true); + fixture.detectChanges(); + + const optionItems = Array.from(overlayContainerElement.querySelectorAll('.option-item')); + expect(optionItems.length).toBe(4); + expect(optionItems[0].classList).not.toContain('option-section-start'); + expect(optionItems[1].classList).not.toContain('option-section-start'); + expect(optionItems[2].classList).toContain('option-section-start'); + expect(optionItems[3].classList).not.toContain('option-section-start'); + }); + it('hides unavailable unselected options by default while keeping selected unavailable ones visible', () => { const fixture = TestBed.createComponent(MultiSelectDropdownComponent); const options: DropdownOption[] = [ @@ -810,4 +832,4 @@ describe('MultiSelectDropdownComponent', () => { 'Option 6': { name: 'Option 6', state: 'not', count: 1 }, }); }); -}); \ No newline at end of file +}); diff --git a/src/app/components/multi-select-dropdown/multi-select-dropdown.component.ts b/src/app/components/multi-select-dropdown/multi-select-dropdown.component.ts index 61fdfe8f2..20ad55b3e 100644 --- a/src/app/components/multi-select-dropdown/multi-select-dropdown.component.ts +++ b/src/app/components/multi-select-dropdown/multi-select-dropdown.component.ts @@ -109,6 +109,7 @@ export class MultiSelectDropdownComponent { displayText = input(); // Text to display instead of pills when in semantic-only mode (fallback) displayItems = input<{ text: string; state: 'or' | 'and' | 'not' }[] | undefined>(); // Structured display items with state options = input([]); + optionSection = input<((option: DropdownOption) => string | null | undefined) | null>(null); selected = input([]); selectionChange = output(); @@ -219,6 +220,23 @@ export class MultiSelectDropdownComponent { return nameFiltered; }); + optionSectionBreakIndexes = computed(() => { + const getSection = this.optionSection(); + const options = this.filteredOptions(); + const sectionBreakIndexes = new Set(); + if (!getSection || options.length < 2) return sectionBreakIndexes; + + let previousSection = getSection(options[0]); + for (let index = 1; index < options.length; index++) { + const currentSection = getSection(options[index]); + if (currentSection && previousSection && currentSection !== previousSection) { + sectionBreakIndexes.add(index); + } + previousSection = currentSection; + } + return sectionBreakIndexes; + }); + useVirtualScroll = computed(() => this.options().length >= this.virtualScrollThreshold); highlight(text: string): string { @@ -1211,4 +1229,4 @@ export class MultiSelectDropdownComponent { } return this.selectedOptions().some(o => o.name === optionName); } -} \ No newline at end of file +} diff --git a/src/app/unit-search.worker.spec.ts b/src/app/unit-search.worker.spec.ts index efc9002c5..15973c22f 100644 --- a/src/app/unit-search.worker.spec.ts +++ b/src/app/unit-search.worker.spec.ts @@ -214,4 +214,43 @@ describe('unit-search worker', () => { telemetryQuery: 'canon:no', }).entries).toEqual([{ unitName: 'Unpublished Non-Canon' }]); }); -}); \ No newline at end of file + + it('matches a complete rulebook bucket in the worker', () => { + const unitA = createUnit('Unit A'); + unitA.rulesRefs = [['Core'], ['TW', 'IO:AE']]; + const unitB = createUnit('Unit B'); + unitB.rulesRefs = [['TW', 'Shrap01', 'AAA'], ['TM', 'Shrap01']]; + + const runtime = __test__.hydrateCorpus({ + corpusVersion: '1:0', + units: [unitA, unitB], + indexes: { + rulesRefs: { + Core: ['Unit A'], + TW: ['Unit A', 'Unit B'], + TM: ['Unit B'], + 'IO:AE': ['Unit A'], + Shrap01: ['Unit B'], + AAA: ['Unit B'], + }, + }, + factionEraIndex: {}, + }); + const baseRequest = createRequest(); + + const getEntries = (executionQuery: string) => __test__.buildResultMessage(runtime, { + ...baseRequest, + executionQuery, + telemetryQuery: executionQuery, + }).entries; + + expect(getEntries('rulesRefs=Core')).toEqual([{ unitName: 'Unit A' }]); + expect(getEntries('rulesRefs=TW')).toEqual([]); + expect(getEntries('rulesRefs=TW,IO:AE')).toEqual([{ unitName: 'Unit A' }]); + expect(getEntries('rulesRefs=TW,Shrap01')).toEqual([]); + expect(getEntries('rulesRefs=TW,Shrap01,AAA')).toEqual([{ unitName: 'Unit B' }]); + expect(getEntries('rulesRefs=IO:AE')).toEqual([{ unitName: 'Unit A' }]); + expect(getEntries('rulesRefs=Shrap01')).toEqual([{ unitName: 'Unit B' }]); + expect(getEntries('rulesRefs=AAA')).toEqual([]); + }); +}); From 2f48f10033169ee7207021f8b5eb73b53051dd4c Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 21 Aug 2026 14:26:48 +0200 Subject: [PATCH 03/87] merge from main --- .../page-turn-summary-panel.component.html | 38 +- .../page-turn-summary-panel.component.scss | 37 + .../page-turn-summary-panel.component.spec.ts | 191 ++ .../page-turn-summary-panel.component.ts | 91 +- .../overlay/page-turn-summary.util.spec.ts | 53 +- .../overlay/page-turn-summary.util.ts | 26 +- .../rename-group-dialog.component.ts | 81 +- .../services/force-builder.service.spec.ts | 2 + src/app/services/force-builder.service.ts | 124 +- .../services/force-generator.service.spec.ts | 23 +- src/app/services/force-generator.service.ts | 10 +- src/app/services/lobby.service.ts | 2 +- src/app/services/unit-search-filters.model.ts | 4 +- .../unit-search-filters.service.spec.ts | 45 +- .../services/unit-search-filters.service.ts | 3 +- .../unit-search-index.service.spec.ts | 20 +- src/app/services/unit-search-index.service.ts | 4 +- src/app/testing/unit-test-helpers.ts | 6 +- src/app/utils/as-print-reference.util.ts | 18 +- src/app/utils/asprint.util.spec.ts | 71 +- src/app/utils/asprint.util.ts | 2 - .../formation-ability-assignment.util.spec.ts | 494 ++++- .../formation-ability-assignment.util.ts | 367 +++- src/app/utils/formation-blueprints.spec.ts | 265 +++ src/app/utils/formation-blueprints.ts | 1665 +++++++++-------- src/app/utils/formation-predicates.util.ts | 46 +- .../formation-requirement-engine.util.spec.ts | 143 +- .../formation-requirement-engine.util.ts | 32 +- src/app/utils/formation-requirement.model.ts | 10 +- src/app/utils/formation-target.util.ts | 75 + src/app/utils/formation-type.model.spec.ts | 21 - src/app/utils/formation-type.model.ts | 96 +- src/app/utils/formation-unit-facts.util.ts | 10 +- src/app/utils/lance-type-identifier.util.ts | 19 +- src/app/utils/rules-ref.util.ts | 9 + src/app/utils/semantic-filter-ast.util.ts | 10 +- src/app/utils/unit-filter-kernel.util.ts | 16 +- .../utils/unit-search-executor.util.spec.ts | 28 +- src/app/utils/unit-search-shared.util.ts | 56 +- .../unit-search-worker-request.util.spec.ts | 25 +- 40 files changed, 3195 insertions(+), 1043 deletions(-) create mode 100644 src/app/components/page-viewer/overlay/page-turn-summary-panel.component.spec.ts create mode 100644 src/app/utils/formation-blueprints.spec.ts create mode 100644 src/app/utils/formation-target.util.ts create mode 100644 src/app/utils/rules-ref.util.ts diff --git a/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.html b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.html index 76610a77c..f97afe820 100644 --- a/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.html +++ b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.html @@ -7,6 +7,10 @@
TURN TRACKER
+ @if (showImmobileStatus()) { +
Unit is immobile
+ } + @if (showMovementControls()) { @if (canSwitchAirborneMode()) {
-
- } - @if (canSwitchAirborneMode() === false || airborne() !== null) {
@for (moveMode of moveModes(); track moveMode.mode) { @@ -40,9 +38,11 @@ type="button" class="bt-button move-button" [class.stationary]="mode === 'stationary'" + [class.stationary-only]="mode === 'stationary' && onlyStationaryMoveMode()" [class.selected]="currentMoveMode() === mode" [class.danger]="moveMode.psr" [disabled]="isMoveModeDisabled(mode)" + [attr.aria-label]="mode === 'stationary' ? 'Stationary' : null" [attr.aria-pressed]="currentMoveMode() === mode" (click)="selectMove(mode)" > @@ -60,13 +60,35 @@ }
+ @if (prone() && canStandUp()) { +
+ +
+ } + + @if (standAttempts() > 0) { +
+ +
+ } + @if (currentMoveMode() !== 'stationary' && currentMoveMode() !== null) {
} } + }
+ @if (selectedFormationUsesTarget()) { +
+ + + @if (formationTargetOptions.length === 0) { +

No eligible formation is available in this force.

+ } @else if (!selectedFormationTargetGroupId()) { +

Select the formation whose assigned abilities this formation copies.

+ } +
+ } @if (selectedFormation(); as formation) { @if (!isNoFormation(formation)) { @if (!isSelectedFormationValid()) { @@ -184,6 +208,23 @@ export interface RenameGroupDialogResult { color: #888; } + .formation-target-field { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; + } + + .formation-target-select { + width: 100%; + } + + .formation-target-warning { + color: var(--bt-orange, #f2a900); + font-size: 0.85em; + margin: 0; + } + .random-button { flex-shrink: 0; height: 32px; @@ -332,6 +373,14 @@ export class RenameGroupDialogComponent implements OnDestroy { /** Currently selected formation */ selectedFormation = signal(this.data.group.formation()); + /** Eligible concrete formations and the currently restored target selection. */ + readonly formationTargetOptions = getFormationTargetCandidates(this.data.group).map((candidate) => ({ + id: candidate.id, + label: `${candidate.groupDisplayName()} — ${candidate.activeFormation()!.name}`, + })); + selectedFormationTargetGroupId = signal(resolveFormationTargetGroup(this.data.group)?.id ?? null); + selectedFormationUsesTarget = computed(() => formationHasTargetCopyEffect(this.selectedFormation())); + /** Whether the formation dropdown overlay is open. */ formationDropdownOpen = signal(false); @@ -345,7 +394,7 @@ export class RenameGroupDialogComponent implements OnDestroy { formationDisplayList: FormationDisplayItem[] = (() => { const validMatches = FormationNamerUtil.getAvailableFormationDefinitions(this.data.group); const validMap = new Map(validMatches.map(m => [m.definition.id, m])); - return getFormationDefinitions() + return getFormationDefinitions(this.data.group.force.gameSystem) .filter(def => FormationRequirementEngine.hasBlueprint(def.id)) .map(def => { const match = validMap.get(def.id); @@ -421,29 +470,38 @@ export class RenameGroupDialogComponent implements OnDestroy { } } + onFormationTargetChange(event: Event): void { + const targetGroupId = (event.target as HTMLSelectElement).value || null; + this.selectedFormationTargetGroupId.set( + targetGroupId && this.formationTargetOptions.some((option) => option.id === targetGroupId) + ? targetGroupId + : null, + ); + } + /** Expose isNoFormation to the template */ isNoFormation = isNoFormation; /** Get requirements text for a formation definition. */ getRequirementsText(formation: FormationTypeDefinition): string | null { if (!formation.requirements) return null; - const requirements = formation.requirements(this.data.group.force.gameSystem); + const requirements = formation.requirements; return requirements ? formatSummaryMovement(requirements, this.optionsService.options().ASUseHex) : null; } /** Get parent formation requirements text */ getParentRequirementsText(formation: FormationTypeDefinition): string | null { if (!formationInheritsParentEffects(formation) || !formation.parent) return null; - const parent = getFormationDefinition(formation.parent); + const parent = getFormationDefinition(formation.parent, this.data.group.force.gameSystem); if (!parent?.requirements) return null; - const requirements = parent.requirements(this.data.group.force.gameSystem); + const requirements = parent.requirements; return requirements ? formatSummaryMovement(requirements, this.optionsService.options().ASUseHex) : null; } /** Get parent formation name */ getParentFormationName(formation: FormationTypeDefinition): string { if (!formationInheritsParentEffects(formation) || !formation.parent) return ''; - return getFormationDefinition(formation.parent)?.name ?? ''; + return getFormationDefinition(formation.parent, this.data.group.force.gameSystem)?.name ?? ''; } /** Compose a display name for a formation definition */ @@ -453,11 +511,16 @@ export class RenameGroupDialogComponent implements OnDestroy { submit(): void { const name = this.inputRef().nativeElement.textContent?.trim() || ''; - this.dialogRef.close({ name, formation: this.selectedFormation(), action: 'confirm' }); + this.dialogRef.close({ + name, + formation: this.selectedFormation(), + formationTargetGroupId: this.selectedFormationUsesTarget() ? this.selectedFormationTargetGroupId() : null, + action: 'confirm', + }); } submitUnset(): void { - this.dialogRef.close({ name: '', formation: null, action: 'unset' }); + this.dialogRef.close({ name: '', formation: null, formationTargetGroupId: null, action: 'unset' }); } fillRandomFormation(): void { @@ -723,4 +786,4 @@ export class RenameGroupDialogComponent implements OnDestroy { close(value: RenameGroupDialogResult | null = null): void { this.dialogRef.close(value); } -} \ No newline at end of file +} diff --git a/src/app/services/force-builder.service.spec.ts b/src/app/services/force-builder.service.spec.ts index fbb949b77..3ce43e6de 100644 --- a/src/app/services/force-builder.service.spec.ts +++ b/src/app/services/force-builder.service.spec.ts @@ -70,6 +70,7 @@ function createHarness(formation: FormationTypeDefinition, factions: Faction[]) const forceUnits: ForceUnit[] = []; const group = { formation: signal(null), + formationTargetGroupId: signal(null), formationLock: false, formationHistory: new Set(['previous-automatic-match']), units: groupUnits, @@ -609,6 +610,7 @@ describe('ForceBuilderService OPFOR inventory target synchronization', () => { getActiveNarcWaterLayers: () => ({ aboveWater: false, underwater: false }), turnState: () => ({ moveMode: signal(null), + effectiveMoveMode: signal(null), moveDistance: signal(0), airborne: signal(false), cover: signal(cover) diff --git a/src/app/services/force-builder.service.ts b/src/app/services/force-builder.service.ts index 7759c7521..50a04fbea 100644 --- a/src/app/services/force-builder.service.ts +++ b/src/app/services/force-builder.service.ts @@ -30,7 +30,7 @@ import type { SerializedForce } from '../models/force-serialization'; import { EditPilotDialogComponent, type EditPilotDialogData, type EditPilotResult } from '../components/edit-pilot-dialog/edit-pilot-dialog.component'; import { EditASPilotDialogComponent, type EditASPilotDialogData, type EditASPilotResult } from '../components/edit-as-pilot-dialog/edit-as-pilot-dialog.component'; import { ShareForceDialogComponent } from '../components/share-force-dialog/share-force-dialog.component'; -import { FormationInfoDialogComponent, type FormationInfoDialogData } from '../components/formation-info-dialog/formation-info-dialog.component'; +import { FormationInfoDialogComponent, type FormationInfoDialogData, type FormationInfoDialogResult } from '../components/formation-info-dialog/formation-info-dialog.component'; import { GameSystem } from '../models/common.model'; import { CBTForce } from '../models/cbt-force.model'; import { ASForce } from '../models/as-force.model'; @@ -50,7 +50,8 @@ import type { ForceSlot, ForceAlignment } from '../models/force-slot.model'; import { MULFACTION_EXTINCT, MULFACTION_MERCENARY } from '../models/mulfactions.model'; import { LanceTypeIdentifierUtil } from '../utils/lance-type-identifier.util'; import { FormationAbilityAssignmentUtil } from '../utils/formation-ability-assignment.util'; -import type { FormationTypeDefinition } from '../utils/formation-type.model'; +import { formationHasTargetCopyEffect, type FormationTypeDefinition } from '../utils/formation-type.model'; +import { clearInvalidFormationTargetSelection, getFormationTargetCandidates, resolveFormationTargetGroup } from '../utils/formation-target.util'; import { UnitSearchFiltersService } from './unit-search-filters.service'; import type { MultiStateSelection } from '../components/multi-select-dropdown/multi-select-dropdown.component'; import { getPositiveDropdownNamesFromFilter } from '../utils/filter-name-resolution.util'; @@ -1194,8 +1195,12 @@ export class ForceBuilderService { } this.generateFactionAndForceNameIfNeeded(targetForce); - if (unitGroup) { + if (unitGroup && targetForce.groups().includes(unitGroup)) { this.assignFormationIfNeeded(unitGroup); + } else { + // removeUnit also removes an emptied group and clears target ids; + // reconcile the surviving force so copied abilities do not linger. + this.reconcileASFormationAssignmentsForForce(targetForce); } } @@ -1348,15 +1353,21 @@ export class ForceBuilderService { try { // First, clear any default groups newForce.groups.set([]); + const convertedGroupBySourceId = new Map(); // Recreate groups and units - process one group at a time for (const sourceGroup of force.groups()) { const newGroup = newForce.addGroup(); + convertedGroupBySourceId.set(sourceGroup.id, newGroup); newGroup.name.set(sourceGroup.name()); - newGroup.formation.set(sourceGroup.formation()); - newGroup.formationLock = sourceGroup.formationLock; - if (!newGroup.formationLock && sourceGroup.formation()) { - newGroup.formationHistory.add(sourceGroup.formation()!.id); + const sourceFormation = sourceGroup.formation(); + const convertedFormation = sourceFormation + ? LanceTypeIdentifierUtil.getDefinitionById(sourceFormation.id, newForce.gameSystem) + : null; + newGroup.formation.set(convertedFormation); + newGroup.formationLock = sourceGroup.formationLock && convertedFormation ? true : undefined; + if (!newGroup.formationLock && convertedFormation) { + newGroup.formationHistory.add(convertedFormation.id); } for (const sourceUnit of sourceGroup.units()) { @@ -1383,9 +1394,20 @@ export class ForceBuilderService { }); } - this.assignFormationIfNeeded(newGroup); // we re-evaluate all formations after conversion since unit changes may affect validity } + for (const sourceGroup of force.groups()) { + const convertedGroup = convertedGroupBySourceId.get(sourceGroup.id); + const sourceTargetId = sourceGroup.formationTargetGroupId(); + if (convertedGroup && sourceTargetId) { + convertedGroup.formationTargetGroupId.set(convertedGroupBySourceId.get(sourceTargetId)?.id ?? null); + } + } + for (const convertedGroup of convertedGroupBySourceId.values()) { + this.assignFormationIfNeeded(convertedGroup); + } + this.reconcileASFormationAssignmentsForForce(newForce); + // Set a new instance ID and save newForce.instanceId.set(uuidv7()); } finally { @@ -1625,10 +1647,14 @@ export class ForceBuilderService { if (group.units().length === 0) { group.formation.set(null); group.formationLock = false; // Unlock name so it can update with new formation name + group.formationTargetGroupId.set(null); + this.reconcileASFormationAssignments(group); + group.force.groups().forEach(clearInvalidFormationTargetSelection); return; } if (group.formationLock) { this.reconcileASFormationAssignments(group); + group.force.groups().forEach(clearInvalidFormationTargetSelection); return; } // Pick the best formation (deterministic, most specific wins), @@ -1641,6 +1667,7 @@ export class ForceBuilderService { } } this.reconcileASFormationAssignments(group); + group.force.groups().forEach(clearInvalidFormationTargetSelection); } private reconcileASFormationAssignments(group: UnitGroup | null | undefined): void { @@ -1648,7 +1675,13 @@ export class ForceBuilderService { return; } - FormationAbilityAssignmentUtil.reconcileGroupFormationAssignments(group as UnitGroup); + FormationAbilityAssignmentUtil.reconcileGroupAndDependents(group as UnitGroup); + } + + public reconcileASFormationAssignmentsForForce(force: Force): void { + if (force.gameSystem === GameSystem.ALPHA_STRIKE) { + FormationAbilityAssignmentUtil.reconcileForceFormationAssignments(force as ASForce); + } } public showFormationInfo(group: UnitGroup): void { @@ -1656,17 +1689,48 @@ export class ForceBuilderService { if (!targetForce) return; const formation = group.activeFormation(); if (!formation) return; - this.dialogsService.createDialog(FormationInfoDialogComponent, { + const usesFormationTarget = formationHasTargetCopyEffect(formation); + const targetOptions = usesFormationTarget + ? getFormationTargetCandidates(group).map((candidate) => ({ + id: candidate.id, + label: `${candidate.groupDisplayName()} — ${candidate.activeFormation()!.name}`, + })) + : undefined; + const currentTargetId = usesFormationTarget ? resolveFormationTargetGroup(group)?.id ?? null : undefined; + const ref = this.dialogsService.createDialog(FormationInfoDialogComponent, { data: { formation, gameSystem: targetForce.gameSystem, - formationDisplayName: group.formationDisplayName(), + formationDisplayName: group.formationDisplayName() ?? undefined, unitCount: group.units().length, isValid: group.hasValidFormation(), requirementsFiltered: group.isFormationRequirementsFiltered(), - requirementsFilterCompositionName: group.formationRequirementsFilterCompositionName(), - requirementsFilterNotice: group.formationRequirementsFilterNotice(), - } as FormationInfoDialogData + requirementsFilterCompositionName: group.formationRequirementsFilterCompositionName() ?? undefined, + requirementsFilterNotice: group.formationRequirementsFilterNotice() ?? undefined, + formationTargetOptions: targetOptions, + formationTargetGroupId: currentTargetId, + formationTargetEditable: !targetForce.readOnly(), + } + }); + ref.closed.pipe(take(1)).subscribe((result) => { + if (targetForce.readOnly() + || !result + || !targetForce.groups().includes(group) + || !formationHasTargetCopyEffect(group.activeFormation())) { + return; + } + const selectedTargetId = result.formationTargetGroupId; + const currentCandidateIds = new Set(getFormationTargetCandidates(group).map((candidate) => candidate.id)); + const validTargetId = selectedTargetId + && currentCandidateIds.has(selectedTargetId) + ? selectedTargetId + : null; + if (validTargetId === group.formationTargetGroupId()) { + return; + } + group.formationTargetGroupId.set(validTargetId); + this.reconcileASFormationAssignments(group); + targetForce.emitChanged(); }); } @@ -1711,6 +1775,7 @@ export class ForceBuilderService { this.selectedUnit.set(otherUnits[0] ?? null); } force.removeGroup(group); + this.reconcileASFormationAssignmentsForForce(force); } public shareForce(): void { @@ -2335,13 +2400,19 @@ export class ForceBuilderService { let insertedCount = 0; const newGroups: UnitGroup[] = []; + const insertedGroupBySourceId = new Map(); for (const sourceGroup of sourceGroups) { const newGroup = targetForce.addGroup(sourceGroup.name()); - newGroup.formation.set(sourceGroup.formation()); - newGroup.formationLock = sourceGroup.formationLock; - if (!newGroup.formationLock && sourceGroup.formation()) { - newGroup.formationHistory.add(sourceGroup.formation()!.id); + insertedGroupBySourceId.set(sourceGroup.id, newGroup); + const sourceFormation = sourceGroup.formation(); + const insertedFormation = sourceFormation + ? LanceTypeIdentifierUtil.getDefinitionById(sourceFormation.id, targetForce.gameSystem) + : null; + newGroup.formation.set(insertedFormation); + newGroup.formationLock = sourceGroup.formationLock && insertedFormation ? true : undefined; + if (!newGroup.formationLock && insertedFormation) { + newGroup.formationHistory.add(insertedFormation.id); } for (const sourceUnit of sourceGroup.units()) { @@ -2371,10 +2442,19 @@ export class ForceBuilderService { newGroups.push(newGroup); } + for (const sourceGroup of sourceGroups) { + const newGroup = insertedGroupBySourceId.get(sourceGroup.id); + const sourceTargetId = sourceGroup.formationTargetGroupId(); + if (newGroup && sourceTargetId) { + newGroup.formationTargetGroupId.set(insertedGroupBySourceId.get(sourceTargetId)?.id ?? null); + } + } + this.generateFactionAndForceNameIfNeeded(targetForce); for (const group of newGroups) { this.assignFormationIfNeeded(group); } + this.reconcileASFormationAssignmentsForForce(targetForce); const systemNote = needsConversion ? ' (units were converted)' : ''; this.toastService.showToast( `Inserted ${insertedCount} unit(s) from "${sourceForce.displayName()}" into "${targetForce.displayName()}"${systemNote}.`, @@ -3033,6 +3113,7 @@ export class ForceBuilderService { group.formationHistory.clear(); // We unset, we reset! group.formationLock = false; group.formation.set(null); + group.formationTargetGroupId.set(null); group.setName(undefined); this.assignFormationIfNeeded(group); } else @@ -3052,7 +3133,12 @@ export class ForceBuilderService { group.setName(result.name); } this.assignFormationIfNeeded(group); + group.formationTargetGroupId.set(result.formationTargetGroupId); + if (!resolveFormationTargetGroup(group)) { + group.formationTargetGroupId.set(null); + } } + this.reconcileASFormationAssignments(group); } } @@ -3270,7 +3356,7 @@ export class ForceBuilderService { } if (group) { - FormationAbilityAssignmentUtil.reconcileGroupFormationAssignments(group, { + FormationAbilityAssignmentUtil.reconcileGroupAndDependents(group, { abilityOverrides: result.formationAbilityOverrides ?? new Map([[unit.id, result.formationAbilities]]), commanderUnitId: result.commander ? unit.id diff --git a/src/app/services/force-generator.service.spec.ts b/src/app/services/force-generator.service.spec.ts index 8f0769cf8..d0bd75471 100644 --- a/src/app/services/force-generator.service.spec.ts +++ b/src/app/services/force-generator.service.spec.ts @@ -1224,7 +1224,7 @@ describe('ForceGeneratorService', () => { expect(preview.units.map((unit) => unit.unit.name)).toEqual(['Vedette', 'Vedette', 'Vedette']); }); - it('allows Vehicle Command matched-pair completion when duplicate chassis prevention is enabled', () => { + it('builds Vehicle Command from two distinct qualifying vehicles when duplicate chassis prevention is enabled', () => { const era = createEra(3150, 'ilClan'); const faction = createFaction(10, 'Mercenary'); registerEraAndFaction(era, faction); @@ -1237,7 +1237,16 @@ describe('ForceGeneratorService', () => { role: 'Sniper', as: { TP: 'CV', SZ: 2, PV: 60 }, }); - const supportVehicles = [2, 3, 4].map((id) => createUnit({ + const secondCommandVehicle = createUnit({ + id: 2, + name: 'Command Striker', + chassis: 'Command Striker', + type: 'Tank', + subtype: 'Combat Vehicle', + role: 'Missile Boat', + as: { TP: 'CV', SZ: 2, PV: 60 }, + }); + const supportVehicles = [3, 4, 5].map((id) => createUnit({ id, name: `Support Vehicle ${id}`, chassis: `Support Vehicle ${id}`, @@ -1246,14 +1255,14 @@ describe('ForceGeneratorService', () => { role: 'Scout', as: { TP: 'CV', SZ: 2, PV: 60 }, })); - for (const unit of [commandVehicle, ...supportVehicles]) { + for (const unit of [commandVehicle, secondCommandVehicle, ...supportVehicles]) { units.push(unit); addMegaMekAvailability(unit, faction, era); } spyOn(Math, 'random').and.returnValue(0); const preview = service.buildPreview({ - eligibleUnits: [commandVehicle, ...supportVehicles], + eligibleUnits: [commandVehicle, secondCommandVehicle, ...supportVehicles], context: createContext(faction, era), gameSystem: GameSystem.ALPHA_STRIKE, budgetRange: { min: 300, max: 300 }, @@ -1270,10 +1279,10 @@ describe('ForceGeneratorService', () => { expect(preview.totalCost).toBe(300); expect(preview.units.map((unit) => unit.unit.name)).toEqual([ 'Command Vedette', - 'Command Vedette', - 'Support Vehicle 2', + 'Command Striker', 'Support Vehicle 3', 'Support Vehicle 4', + 'Support Vehicle 5', ]); expect(preview.explanationLines).toContain('Prevent Duplicate Chassis: on.'); }); @@ -5886,4 +5895,4 @@ describe('ForceGeneratorService', () => { expect(preview.units.map((unit) => unit.unit.name)).toEqual(['Fighter 1', 'Fighter 2', 'Fighter 3', 'Fighter 4', 'Fighter 5', 'Fighter 6']); expect(preview.explanationLines.some((line) => line.includes('Resolved org shape: Squadron.'))).toBeTrue(); }); -}); \ No newline at end of file +}); diff --git a/src/app/services/force-generator.service.ts b/src/app/services/force-generator.service.ts index 7190f58c5..4968223be 100644 --- a/src/app/services/force-generator.service.ts +++ b/src/app/services/force-generator.service.ts @@ -6568,7 +6568,7 @@ export class ForceGeneratorService implements OnDestroy { selectionSteps.push(this.createSelectionStep(candidate, rulesetProfile, stepOverrides, preparedSelection)); }; - const hasMatchedPairConstraints = this.getMatchedPairConstraintIds(definition.id).size > 0; + const hasMatchedPairConstraints = this.getMatchedPairConstraintIds(definition.id, options.gameSystem).size > 0; const allowUnlimitedDuplicateUnits = this.canReuseCandidateCopies(preventDuplicateChassis, targetCandidates); let candidatePoolStarved = false; @@ -6762,7 +6762,7 @@ export class ForceGeneratorService implements OnDestroy { return null; } - const matchedPairConstraintIds = this.getMatchedPairConstraintIds(definition.id); + const matchedPairConstraintIds = this.getMatchedPairConstraintIds(definition.id, options.gameSystem); if (matchedPairConstraintIds.size === 0) { return null; } @@ -6859,9 +6859,9 @@ export class ForceGeneratorService implements OnDestroy { return capacity; } - private getMatchedPairConstraintIds(formationId: string): Set { + private getMatchedPairConstraintIds(formationId: string, gameSystem: GameSystem): Set { const result = new Set(); - this.collectMatchedPairConstraintIds(getFormationBlueprint(formationId)?.constraints ?? [], result); + this.collectMatchedPairConstraintIds(getFormationBlueprint(formationId, gameSystem)?.constraints ?? [], result); return result; } @@ -7509,7 +7509,7 @@ export class ForceGeneratorService implements OnDestroy { return this.filterCandidatesByPredicateFilter( candidates, options, - FormationRequirementEngine.getBaseCandidatePredicateFilter(definition), + FormationRequirementEngine.getBaseCandidatePredicateFilter(definition, options.gameSystem), false, ); } diff --git a/src/app/services/lobby.service.ts b/src/app/services/lobby.service.ts index 93593f559..ca0e7a880 100644 --- a/src/app/services/lobby.service.ts +++ b/src/app/services/lobby.service.ts @@ -16,7 +16,7 @@ import { WsService } from './ws.service'; import { normalizeDisplayName } from '../utils/display-name.util'; const LOBBY_CODE_PATTERN = /^[a-z0-9]{4}$/; -const MAX_LOBBY_PARTICIPANTS = 16; +const MAX_LOBBY_PARTICIPANTS = 32; const MAX_LOBBY_FORCES = 8; const MAX_REMOTE_LOAD_ATTEMPTS = 8; diff --git a/src/app/services/unit-search-filters.model.ts b/src/app/services/unit-search-filters.model.ts index b9e7c509d..75748676b 100644 --- a/src/app/services/unit-search-filters.model.ts +++ b/src/app/services/unit-search-filters.model.ts @@ -19,6 +19,7 @@ import type { PvNormalizationSettings, UnitSearchBudgetMode, } from '../models/unit-search-result.model'; +import { BASE_RULES_REFS } from '../utils/rules-ref.util'; /* * @@ -440,6 +441,7 @@ export const DROPDOWN_FILTERS: readonly DropdownFilterConfig[] = Object.freeze([ { key: 'features', semanticKey: 'features', label: 'Features', multistate: true, game: GameSystem.CLASSIC, optionSource: 'indexed', availabilitySource: 'indexed', propertyShape: 'array' }, { key: 'quirks', semanticKey: 'quirks', label: 'Quirks', multistate: true, game: GameSystem.CLASSIC, optionSource: 'indexed', availabilitySource: 'indexed', propertyShape: 'array' }, { key: 'source', semanticKey: 'source', label: 'Source', multistate: true, optionSource: 'indexed', availabilitySource: 'indexed', propertyShape: 'array' }, + { key: 'rulesRefs', semanticKey: 'rulesRefs', label: 'Rulebooks', game: GameSystem.CLASSIC, sortOptions: [...BASE_RULES_REFS, '*'], optionSource: 'indexed', availabilitySource: 'indexed', propertyShape: 'array' }, { key: 'forcePack', semanticKey: 'pack', label: 'Force Packs', external: true, optionSource: 'external', availabilitySource: 'context', propertyShape: 'scalar' }, { key: '_tags', semanticKey: 'tags', label: 'Tags', multistate: true, optionSource: 'indexed', availabilitySource: 'indexed', propertyShape: 'array' }, ]); @@ -538,7 +540,7 @@ export const SORT_OPTIONS: SortOption[] = [ { key: 'name', label: 'Name' }, ...ADVANCED_FILTERS .filter(f => f.type !== AdvFilterType.BOOLEAN) - .filter(f => !['era', 'faction', 'availabilityRarity', 'availabilityFrom', 'forcePack', 'componentName', 'weaponType', 'source', '_tags', 'as.specials', 'name', 'chassis', 'model', 'as._motive', 'quirks', 'features'].includes(f.key)) + .filter(f => !['era', 'faction', 'availabilityRarity', 'availabilityFrom', 'forcePack', 'componentName', 'weaponType', 'source', 'rulesRefs', '_tags', 'as.specials', 'name', 'chassis', 'model', 'as._motive', 'quirks', 'features'].includes(f.key)) .map(f => ({ key: f.key, label: f.label, diff --git a/src/app/services/unit-search-filters.service.spec.ts b/src/app/services/unit-search-filters.service.spec.ts index c772414da..a4bfabef5 100644 --- a/src/app/services/unit-search-filters.service.spec.ts +++ b/src/app/services/unit-search-filters.service.spec.ts @@ -4965,11 +4965,52 @@ describe('UnitSearchFiltersService search telemetry', () => { expect(service.queryParameters()['filters']).toBe(`as.specials:"${special}"`); }); - it('declares indexed dropdown capabilities for source, faction, and era', () => { + it('matches units when the selected rulebooks cover a complete bucket', () => { + const bundle = createStandaloneBundle(); + bundle.units.units[0].name = 'Unit A'; + bundle.units.units[0].rulesRefs = [['Core'], ['TW', 'IO:AE']]; + bundle.units.units[1].name = 'Unit B'; + bundle.units.units[1].rulesRefs = [['TW', 'Shrap01', 'AAA'], ['TM', 'Shrap01']]; + + const { service } = createService(bundle); + const rulebookOptions = service.advOptions()['rulesRefs']?.options ?? []; + expect(rulebookOptions.filter(option => typeof option !== 'number').map(option => option.name)) + .toEqual(['Core', 'TM', 'TW', 'AAA', 'IO:AE', 'Shrap01']); + + service.setFilter('rulesRefs', ['Core']); + expect(service.filteredUnits().map(unit => unit.name)).toEqual(['Unit A']); + + service.setFilter('rulesRefs', ['TW', 'Shrap01']); + expect(service.filteredUnits().map(unit => unit.name)).toEqual([]); + + service.setFilter('rulesRefs', ['TW']); + expect(service.filteredUnits().map(unit => unit.name)).toEqual([]); + + service.setFilter('rulesRefs', ['TW', 'IO:AE']); + expect(service.filteredUnits().map(unit => unit.name)).toEqual(['Unit A']); + + service.setFilter('rulesRefs', ['TW', 'Shrap01', 'AAA']); + expect(service.filteredUnits().map(unit => unit.name)).toEqual(['Unit B']); + + service.setFilter('rulesRefs', ['IO:AE']); + expect(service.filteredUnits().map(unit => unit.name)).toEqual(['Unit A']); + + service.setFilter('rulesRefs', ['Shrap01']); + expect(service.filteredUnits().map(unit => unit.name)).toEqual(['Unit B']); + + service.setFilter('rulesRefs', ['AAA']); + expect(service.filteredUnits().map(unit => unit.name)).toEqual([]); + }); + + it('declares indexed dropdown capabilities for rules references, source, faction, and era', () => { + const rulesRefsConfig = getAdvancedFilterConfigByKey('rulesRefs'); const sourceConfig = getAdvancedFilterConfigByKey('source'); const factionConfig = getAdvancedFilterConfigByKey('faction'); const eraConfig = getAdvancedFilterConfigByKey('era'); + expect(rulesRefsConfig?.multistate).toBeFalsy(); + expect(usesIndexedDropdownUniverse(rulesRefsConfig)).toBeTrue(); + expect(usesIndexedDropdownAvailability(rulesRefsConfig)).toBeTrue(); expect(usesIndexedDropdownUniverse(sourceConfig)).toBeTrue(); expect(usesIndexedDropdownAvailability(sourceConfig)).toBeTrue(); expect(usesIndexedDropdownUniverse(factionConfig)).toBeTrue(); @@ -6467,4 +6508,4 @@ describe('UnitSearchFiltersService search telemetry', () => { expect(service.filteredUnits().map(unit => unit.name)).toEqual(['Test Mek']); }); -}); \ No newline at end of file +}); diff --git a/src/app/services/unit-search-filters.service.ts b/src/app/services/unit-search-filters.service.ts index eec81319f..9e7074cf2 100644 --- a/src/app/services/unit-search-filters.service.ts +++ b/src/app/services/unit-search-filters.service.ts @@ -172,6 +172,7 @@ export class UnitSearchFiltersService { /** Display name resolvers that need service dependencies (can't be defined in static config) */ private readonly displayNameFns: Partial string>> = { 'source': (v) => this.dataService.getSourcebookTitle(v), + 'rulesRefs': (v) => this.dataService.getSourcebookTitle(v), }; private buildIndexedDropdownOptions( @@ -2185,7 +2186,7 @@ export class UnitSearchFiltersService { const definitions: FormationTypeDefinition[] = []; const seen = new Set(); - for (const definition of getFormationDefinitions()) { + for (const definition of getFormationDefinitions(gameSystem)) { if (!FormationRequirementEngine.hasBlueprint(definition.id)) { continue; } diff --git a/src/app/services/unit-search-index.service.spec.ts b/src/app/services/unit-search-index.service.spec.ts index 80040f22e..2af86881f 100644 --- a/src/app/services/unit-search-index.service.spec.ts +++ b/src/app/services/unit-search-index.service.spec.ts @@ -255,6 +255,24 @@ describe('UnitSearchIndexService', () => { ]); }); + it('indexes every unit rules reference as a dropdown value', () => { + const service = new UnitSearchIndexService(); + + service.rebuildIndexes([ + createUnit({ name: 'Atlas AS7-D', rulesRefs: [['TM', 'TO']] }), + createUnit({ name: 'Locust LCT-1V', rulesRefs: [['TM']] }), + createUnit({ name: 'Legacy Unit' }), + ], [], []); + + expect(service.getIndexedFilterValues('rulesRefs')).toEqual(['TM', 'TO']); + expect(service.getIndexedUnitIds('rulesRefs', 'TM')).toEqual(new Set(['Atlas AS7-D', 'Locust LCT-1V'])); + expect(service.getIndexedUnitIds('rulesRefs', 'TO')).toEqual(new Set(['Atlas AS7-D'])); + expect(service.getDropdownOptionUniverse('rulesRefs')).toEqual([ + { name: 'TM' }, + { name: 'TO' }, + ]); + }); + it('indexes canon and published status as yes/no values', () => { const service = new UnitSearchIndexService(); @@ -338,4 +356,4 @@ describe('UnitSearchIndexService', () => { expect(unit._weaponTypeCounts).toEqual({}); expect(service.getIndexedFilterValues('weaponType')).toEqual([]); }); -}); \ No newline at end of file +}); diff --git a/src/app/services/unit-search-index.service.ts b/src/app/services/unit-search-index.service.ts index d9c818e59..d675cd984 100644 --- a/src/app/services/unit-search-index.service.ts +++ b/src/app/services/unit-search-index.service.ts @@ -298,6 +298,7 @@ export class UnitSearchIndexService { this.addSearchIndexValues('as.specials', unit.as?.specials ?? [], unit.name); this.addSearchIndexValues('as._motive', this.getASMotiveDisplayNames(unit), unit.name); this.addSearchIndexValues('source', getUnitSourceFilterValues(unit), unit.name); + this.addSearchIndexValues('rulesRefs', unit.rulesRefs?.flat() ?? [], unit.name); this.addSearchIndexValues('componentName', unit.comp.map(component => component.n), unit.name); this.addComponentCountValues(unit); this.prepareUnitWeaponTypes(unit); @@ -429,6 +430,7 @@ export class UnitSearchIndexService { 'moveType', 'as._motive', 'source', + 'rulesRefs', 'componentName', 'weaponType', 'features', @@ -609,4 +611,4 @@ export class UnitSearchIndexService { return Math.round(sum); } -} \ No newline at end of file +} diff --git a/src/app/testing/unit-test-helpers.ts b/src/app/testing/unit-test-helpers.ts index a57123d15..dd533b025 100644 --- a/src/app/testing/unit-test-helpers.ts +++ b/src/app/testing/unit-test-helpers.ts @@ -106,6 +106,7 @@ export function createEmptyUnit(overrides: TestUnitOverrides = {}): Unit { engineHSType: 'Heat Sink', source: [], published: [], + rulesRefs: [], canon: true, canAntiMech: false, role: '', @@ -151,6 +152,7 @@ export function createEmptyUnit(overrides: TestUnitOverrides = {}): Unit { unit.source = unitOverrides.source ? [...unitOverrides.source] : []; unit.published = unitOverrides.published ? [...unitOverrides.published] : []; + unit.rulesRefs = unitOverrides.rulesRefs ? unitOverrides.rulesRefs.map(bucket => [...bucket]) : []; unit.comp = unitOverrides.comp ? [...unitOverrides.comp] : []; unit.quirks = unitOverrides.quirks ? [...unitOverrides.quirks] : []; unit.features = unitOverrides.features ? [...unitOverrides.features] : []; @@ -199,11 +201,11 @@ export interface CBTForceUnitTestHarnessOptions { export interface CBTForceUnitTestTurnState { moveMode(): MotiveModes | null; + effectiveMoveMode(): MotiveModes | null; airborne(): boolean; getAttackMovementModifier(): number; getAttackModifierBreakdown(): UnitModifierBreakdownEntry[]; missingAttackMovementModifier(): boolean; - getSpottingModifier(): number; heatSources(): Array<{ id: string; label: string; value: number }>; heatDissipationBalance(): number; effectiveHeatDissipation(): number; @@ -265,13 +267,13 @@ export class CBTForceUnitTestHarness { ); this.turnState = { moveMode: () => options.moveMode ?? null, + effectiveMoveMode: () => options.moveMode ?? null, airborne: () => false, getAttackMovementModifier: attackMovementModifier, getAttackModifierBreakdown: () => options.attackModifierBreakdown ?? (attackMovementModifier() !== 0 ? [{ label: getMotiveModeLabel(options.moveMode!, baseUnit, false), modifier: attackMovementModifier(), priority: ATTACK_MOVEMENT_MODIFIER_BREAKDOWN_PRIORITY }] : []), missingAttackMovementModifier: () => (options.moveMode ?? null) === null && (options.attackMovementCanAffectTargetNumbers ?? true), - getSpottingModifier: () => 0, heatSources: () => [ ...(options.heatSources ? [{ id: 'test-source', label: 'Test Source', value: options.heatSources }] : []), ...(firedHeat > 0 ? [{ id: 'weapons', label: 'Weapons', value: firedHeat }] : []), diff --git a/src/app/utils/as-print-reference.util.ts b/src/app/utils/as-print-reference.util.ts index 1cf5b73ff..2e4e88129 100644 --- a/src/app/utils/as-print-reference.util.ts +++ b/src/app/utils/as-print-reference.util.ts @@ -20,7 +20,7 @@ import { type FormationEffectPreview, type FormationSharedPoolPreview, } from './formation-ability-assignment.util'; -import { resolveFormationGameSystemText, type FormationWideAbility } from './formation-type.model'; +import type { FormationWideAbility } from './formation-type.model'; export interface ASPrintFormationApplication { abilityNames: string[]; @@ -145,10 +145,7 @@ export function collectASPrintRulesReferenceData( }); } - const effectDescription = resolveFormationGameSystemText( - formation.effectDescription, - GameSystem.ALPHA_STRIKE, - ); + const effectDescription = formation.effectDescription; formations.push({ groupName, formationName, @@ -679,6 +676,17 @@ function describeFormationEffectApplication(effect: FormationEffectPreview): str const group = effect.descriptor.group; const parts: string[] = []; + if (effect.descriptor.copiedFromFormationName) { + parts.push(`Copied from ${effect.descriptor.copiedFromFormationName}`); + } + + const copiedPools = new Set(effect.descriptor.copiedSharedPoolByAbilityId?.values() ?? []); + for (const pool of copiedPools) { + parts.push(describeSharedPoolApplication(pool) + .replace(/^Shared formation pool/, 'Copied source pool') + .replace(/\.$/, '')); + } + switch (group.selection) { case 'choose-one': parts.push('Choose one listed ability for every recipient'); diff --git a/src/app/utils/asprint.util.spec.ts b/src/app/utils/asprint.util.spec.ts index 26123a137..6bd1c3d52 100644 --- a/src/app/utils/asprint.util.spec.ts +++ b/src/app/utils/asprint.util.spec.ts @@ -13,6 +13,12 @@ interface TestPrintLayout { } describe('ASPrintUtil', () => { + afterEach(() => { + window.dispatchEvent(new Event('afterprint')); + document.getElementById('as-multipage-container')?.remove(); + document.body.classList.remove('as-multipage-container-active'); + }); + it('keeps the standard 2 by 4 layout as the default-size preset', () => { const layout = getPrintLayout('standard'); const styles = getFixedPrintStyles('none', 'standard'); @@ -131,19 +137,76 @@ describe('ASPrintUtil', () => { ); expect(renderedHeat).toEqual([0, 0]); - expect(unit.update).toHaveBeenCalledWith(serialized); - expect(heat).toBe(2); - expect(pendingHeat).toBe(1); - expect(unit.disabledSaving).toBeFalse(); + expect(unit.update).not.toHaveBeenCalled(); + expect(heat).toBe(0); + expect(pendingHeat).toBe(0); + expect(unit.disabledSaving).toBeTrue(); window.dispatchEvent(new Event('afterprint')); + expect(unit.update).toHaveBeenCalledWith(serialized); expect(unit.update).toHaveBeenCalledTimes(1); expect(heat).toBe(2); expect(pendingHeat).toBe(1); expect(unit.disabledSaving).toBeFalse(); }); + it('keeps dynamically rendered card hosts mounted until print cleanup', async () => { + const cardHost = document.createElement('alpha-strike-card'); + const hostView = {}; + const destroy = jasmine.createSpy('destroy'); + const createContainer = (options: { componentRefs: unknown[] }) => { + const overlay = document.createElement('div'); + overlay.id = 'as-multipage-container'; + const cardCell = document.createElement('div'); + cardCell.className = 'as-card-cell'; + cardCell.appendChild(cardHost); + overlay.appendChild(cardCell); + options.componentRefs.push({ hostView, destroy }); + return overlay; + }; + spyOn(ASPrintUtil, 'createFixedPrintContainer').and.callFake(createContainer); + spyOn(ASPrintUtil, 'createFlexPrintContainer').and.callFake(createContainer); + + const detachView = jasmine.createSpy('detachView').and.callFake(() => cardHost.remove()); + const appRef = { + tick: jasmine.createSpy('tick'), + detachView, + }; + const unit = { + disabledSaving: false, + serialize: () => ({ state: 'original' }), + update: jasmine.createSpy('update'), + repairAll: jasmine.createSpy('repairAll'), + getUnit: () => ({ as: { TP: 'BM' } }), + }; + const group = { units: () => [unit] }; + + await ASPrintUtil.multipagePrint( + appRef as never, + {} as never, + {} as never, + [group] as never, + { + clean: true, + ASPrintPageBreakOnGroups: false, + ASPrintCardSize: 'standard', + printMargin: 'none', + }, + false, + ); + + expect(document.querySelector('#as-multipage-container alpha-strike-card')).toBe(cardHost); + expect(detachView).not.toHaveBeenCalled(); + expect(destroy).not.toHaveBeenCalled(); + + window.dispatchEvent(new Event('afterprint')); + + expect(detachView).toHaveBeenCalledOnceWith(hostView); + expect(destroy).toHaveBeenCalledTimes(1); + expect(document.getElementById('as-multipage-container')).toBeNull(); + }); + it('restores unit state and removes the overlay when rendering fails', async () => { const createContainer = () => { const overlay = document.createElement('div'); diff --git a/src/app/utils/asprint.util.ts b/src/app/utils/asprint.util.ts index cdb33369d..345fb4035 100644 --- a/src/app/utils/asprint.util.ts +++ b/src/app/utils/asprint.util.ts @@ -166,8 +166,6 @@ export class ASPrintUtil { triggerPrint, onMount: () => { appRef.tick(); - detachViews(); - restoreUnits(); }, onCleanup: cleanup, }); diff --git a/src/app/utils/formation-ability-assignment.util.spec.ts b/src/app/utils/formation-ability-assignment.util.spec.ts index 00389ed9a..53367fbde 100644 --- a/src/app/utils/formation-ability-assignment.util.spec.ts +++ b/src/app/utils/formation-ability-assignment.util.spec.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake +import { signal } from '@angular/core'; import { GameSystem } from '../models/common.model'; import { type Faction } from '../models/factions.model'; import type { ASForceUnit } from '../models/as-force-unit.model'; @@ -14,6 +15,7 @@ import type { FormationTypeDefinition } from './formation-type.model'; import type { GroupSizeResult } from './org/org-types'; import { MULFACTION_MERCENARY, type FactionAffinity } from '../models/mulfactions.model'; import { PILOT_ABILITIES } from '../models/pilot-abilities.model'; +import { isFormationTargetCopyBonusActive } from './formation-target.util'; function createUnit( id: number, @@ -74,9 +76,11 @@ function createASForceUnit( ): ASForceUnit { let formationAbilities = [...(options.formationAbilities ?? [])]; let commander = options.commander ?? false; + let destroyed = false; return { id, + get destroyed() { return destroyed; }, getUnit: () => unit, formationAbilities: () => formationAbilities, commander: () => commander, @@ -86,6 +90,9 @@ function createASForceUnit( setFormationCommander: (next: boolean) => { commander = next; }, + setDestroyed: (next: boolean) => { + destroyed = next; + }, } as unknown as ASForceUnit; } @@ -95,22 +102,41 @@ function createGroup( resolvedGroups: readonly GroupSizeResult[], faction: Faction, ): UnitGroup { + let group!: UnitGroup; const force = { faction: () => faction, era: () => null, gameSystem: GameSystem.ALPHA_STRIKE, + groups: () => [group], }; - return { + group = { + id: `group-${units[0]?.id ?? 'empty'}`, force, units: () => [...units], activeFormation: () => formation, + formationTargetGroupId: signal(null), organizationalResult: () => ({ name: resolvedGroups.map((group) => group.name).join(' + '), tier: resolvedGroups[0]?.tier ?? 0, groups: resolvedGroups, }), } as unknown as UnitGroup; + return group; +} + +function linkGroups(groups: readonly UnitGroup[]): void { + const firstForce = groups[0]?.force; + if (!firstForce) return; + const sharedForce = { + faction: firstForce.faction, + era: firstForce.era, + gameSystem: GameSystem.ALPHA_STRIKE, + groups: () => [...groups], + }; + for (const group of groups) { + (group as unknown as { force: typeof sharedForce }).force = sharedForce; + } } function getFormation(id: string): FormationTypeDefinition { @@ -222,7 +248,7 @@ describe('FormationAbilityAssignmentUtil', () => { expect(PILOT_ABILITIES.filter((ability) => ability.levelGroup === 'float_like_a_butterfly').map((ability) => ability.level)).toEqual([1, 2, 3, 4]); }); - it('supports fixed command ability assignments with the same recipient limits as pilot abilities', () => { + it('uses Alpha Strike half-round-down limits for Anti-Air command ability assignments', () => { const formation = getFormation('anti-air-lance'); const units = [ createASForceUnit('unit-1', createUnit(1, 'Rifleman', 'Mek', 'BattleMek', 'BM'), { @@ -244,12 +270,12 @@ describe('FormationAbilityAssignmentUtil', () => { expect(preview.effectPreviews).toEqual([ jasmine.objectContaining({ - recipientLimit: 2, - recipientUnitIds: ['unit-1', 'unit-2'], + recipientLimit: 1, + recipientUnitIds: ['unit-1'], }), ]); expect(preview.assignmentsByUnitId.get('unit-1')).toEqual(['anti_aircraft_specialists']); - expect(preview.assignmentsByUnitId.get('unit-2')).toEqual(['anti_aircraft_specialists']); + expect(preview.assignmentsByUnitId.get('unit-2')).toEqual([]); expect(preview.assignmentsByUnitId.get('unit-3')).toEqual([]); }); @@ -290,7 +316,7 @@ describe('FormationAbilityAssignmentUtil', () => { expect(preview.effectPreviews.every((effect) => !effect.candidateUnitIds.includes(flightUnits[0].id))).toBeTrue(); }); - it('keeps commander-only bonuses on the commander and strips commander-excluded bonuses from that unit', () => { + it('allows the Alpha Strike commander to receive a selected SPA in addition to Tactical Genius', () => { const formation = getFormation('command-lance'); const commander = createASForceUnit('unit-1', createUnit(1, 'Atlas', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['antagonizer', 'tactical_genius'], @@ -309,7 +335,7 @@ describe('FormationAbilityAssignmentUtil', () => { FormationAbilityAssignmentUtil.reconcileGroupFormationAssignments(group); - expect(commander.formationAbilities()).toEqual(['tactical_genius']); + expect(commander.formationAbilities()).toEqual(['antagonizer', 'tactical_genius']); expect(wingman.formationAbilities()).toEqual(['marksman']); }); @@ -338,15 +364,15 @@ describe('FormationAbilityAssignmentUtil', () => { expect(unitA.commander()).toBeFalse(); expect(unitB.commander()).toBeTrue(); expect(unitA.formationAbilities()).toEqual([]); - expect(unitB.formationAbilities()).toEqual(['tactical_genius']); + expect(unitB.formationAbilities()).toEqual(['marksman', 'tactical_genius']); }); - it('automatically assigns all-unit pilot effects without requiring manual selection', () => { - const formation = getFormation('light-recon-lance'); + it('keeps one selected Alpha Strike Recon SPA on every unit', () => { + const formation = getFormation('recon-lance'); const units = [ - createASForceUnit('unit-1', createUnit(1, 'Locust', 'Mek', 'BattleMek', 'BM', { role: 'Scout', as: { SZ: 1, MVm: { g: 12 } } })), - createASForceUnit('unit-2', createUnit(2, 'Stinger', 'Mek', 'BattleMek', 'BM', { role: 'Scout', as: { SZ: 1, MVm: { g: 14 } } })), - createASForceUnit('unit-3', createUnit(3, 'Wasp', 'Mek', 'BattleMek', 'BM', { role: 'Scout', as: { SZ: 1, MVm: { g: 12 } } })), + createASForceUnit('unit-1', createUnit(1, 'Locust', 'Mek', 'BattleMek', 'BM', { role: 'Scout', as: { SZ: 1, MVm: { g: 12 } } }), { formationAbilities: ['forward_observer'] }), + createASForceUnit('unit-2', createUnit(2, 'Stinger', 'Mek', 'BattleMek', 'BM', { role: 'Scout', as: { SZ: 1, MVm: { g: 14 } } }), { formationAbilities: ['forward_observer'] }), + createASForceUnit('unit-3', createUnit(3, 'Wasp', 'Mek', 'BattleMek', 'BM', { role: 'Scout', as: { SZ: 1, MVm: { g: 12 } } }), { formationAbilities: ['forward_observer'] }), ]; const group = createGroup( units, @@ -363,17 +389,11 @@ describe('FormationAbilityAssignmentUtil', () => { }); it('lets an explicit override clear an automatic choose-one selection for all recipients', () => { - const formation = getFormation('light-recon-lance'); + const formation = getFormation('recon-lance'); const units = [ - createASForceUnit('unit-1', createUnit(1, 'Locust', 'Mek', 'BattleMek', 'BM', { role: 'Scout', as: { SZ: 1, MVm: { g: 12 } } }), { - formationAbilities: ['eagles_eyes', 'forward_observer'], - }), - createASForceUnit('unit-2', createUnit(2, 'Stinger', 'Mek', 'BattleMek', 'BM', { role: 'Scout', as: { SZ: 1, MVm: { g: 14 } } }), { - formationAbilities: ['eagles_eyes', 'forward_observer'], - }), - createASForceUnit('unit-3', createUnit(3, 'Wasp', 'Mek', 'BattleMek', 'BM', { role: 'Scout', as: { SZ: 1, MVm: { g: 12 } } }), { - formationAbilities: ['eagles_eyes', 'forward_observer'], - }), + createASForceUnit('unit-1', createUnit(1, 'Locust', 'Mek', 'BattleMek', 'BM', { role: 'Scout', as: { SZ: 1, MVm: { g: 12 } } }), { formationAbilities: ['eagles_eyes'] }), + createASForceUnit('unit-2', createUnit(2, 'Stinger', 'Mek', 'BattleMek', 'BM', { role: 'Scout', as: { SZ: 1, MVm: { g: 14 } } }), { formationAbilities: ['eagles_eyes'] }), + createASForceUnit('unit-3', createUnit(3, 'Wasp', 'Mek', 'BattleMek', 'BM', { role: 'Scout', as: { SZ: 1, MVm: { g: 12 } } }), { formationAbilities: ['eagles_eyes'] }), ]; const group = createGroup( units, @@ -383,26 +403,20 @@ describe('FormationAbilityAssignmentUtil', () => { ); const preview = FormationAbilityAssignmentUtil.previewGroupFormationAssignments(group, { - abilityOverrides: new Map([['unit-1', ['forward_observer']]]), + abilityOverrides: new Map([['unit-1', []]]), }); - expect(preview.assignmentsByUnitId.get('unit-1')).toEqual(['forward_observer']); - expect(preview.assignmentsByUnitId.get('unit-2')).toEqual(['forward_observer']); - expect(preview.assignmentsByUnitId.get('unit-3')).toEqual(['forward_observer']); + expect(preview.assignmentsByUnitId.get('unit-1')).toEqual([]); + expect(preview.assignmentsByUnitId.get('unit-2')).toEqual([]); + expect(preview.assignmentsByUnitId.get('unit-3')).toEqual([]); }); it('lets an explicit override replace an automatic choose-one selection for all recipients', () => { - const formation = getFormation('light-recon-lance'); + const formation = getFormation('recon-lance'); const units = [ - createASForceUnit('unit-1', createUnit(1, 'Locust', 'Mek', 'BattleMek', 'BM', { role: 'Scout', as: { SZ: 1, MVm: { g: 12 } } }), { - formationAbilities: ['eagles_eyes', 'forward_observer'], - }), - createASForceUnit('unit-2', createUnit(2, 'Stinger', 'Mek', 'BattleMek', 'BM', { role: 'Scout', as: { SZ: 1, MVm: { g: 14 } } }), { - formationAbilities: ['eagles_eyes', 'forward_observer'], - }), - createASForceUnit('unit-3', createUnit(3, 'Wasp', 'Mek', 'BattleMek', 'BM', { role: 'Scout', as: { SZ: 1, MVm: { g: 12 } } }), { - formationAbilities: ['eagles_eyes', 'forward_observer'], - }), + createASForceUnit('unit-1', createUnit(1, 'Locust', 'Mek', 'BattleMek', 'BM', { role: 'Scout', as: { SZ: 1, MVm: { g: 12 } } }), { formationAbilities: ['eagles_eyes'] }), + createASForceUnit('unit-2', createUnit(2, 'Stinger', 'Mek', 'BattleMek', 'BM', { role: 'Scout', as: { SZ: 1, MVm: { g: 14 } } }), { formationAbilities: ['eagles_eyes'] }), + createASForceUnit('unit-3', createUnit(3, 'Wasp', 'Mek', 'BattleMek', 'BM', { role: 'Scout', as: { SZ: 1, MVm: { g: 12 } } }), { formationAbilities: ['eagles_eyes'] }), ]; const group = createGroup( units, @@ -412,12 +426,408 @@ describe('FormationAbilityAssignmentUtil', () => { ); const preview = FormationAbilityAssignmentUtil.previewGroupFormationAssignments(group, { - abilityOverrides: new Map([['unit-1', ['maneuvering_ace', 'forward_observer']]]), + abilityOverrides: new Map([['unit-1', ['maneuvering_ace']]]), }); - expect(preview.assignmentsByUnitId.get('unit-1')).toEqual(['maneuvering_ace', 'forward_observer']); - expect(preview.assignmentsByUnitId.get('unit-2')).toEqual(['maneuvering_ace', 'forward_observer']); - expect(preview.assignmentsByUnitId.get('unit-3')).toEqual(['maneuvering_ace', 'forward_observer']); + expect(preview.assignmentsByUnitId.get('unit-1')).toEqual(['maneuvering_ace']); + expect(preview.assignmentsByUnitId.get('unit-2')).toEqual(['maneuvering_ace']); + expect(preview.assignmentsByUnitId.get('unit-3')).toEqual(['maneuvering_ace']); + }); + + it('allows each Alpha Strike Light Recon unit to choose a different SPA', () => { + const formation = getFormation('light-recon-lance'); + const units = [ + createASForceUnit('unit-1', createUnit(1, 'Locust', 'Mek', 'BattleMek', 'BM', { role: 'Scout', as: { SZ: 1, MVm: { g: 12 } } }), { formationAbilities: ['eagles_eyes'] }), + createASForceUnit('unit-2', createUnit(2, 'Stinger', 'Mek', 'BattleMek', 'BM', { role: 'Scout', as: { SZ: 1, MVm: { g: 14 } } }), { formationAbilities: ['forward_observer'] }), + createASForceUnit('unit-3', createUnit(3, 'Wasp', 'Mek', 'BattleMek', 'BM', { role: 'Scout', as: { SZ: 1, MVm: { g: 12 } } }), { formationAbilities: ['maneuvering_ace'] }), + ]; + const group = createGroup( + units, + formation, + [createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: units.map((unit) => unit.getUnit()) })], + createFaction('Mercenary', 'Mercenary'), + ); + + const preview = FormationAbilityAssignmentUtil.previewGroupFormationAssignments(group); + + expect(preview.assignmentsByUnitId.get('unit-1')).toEqual(['eagles_eyes']); + expect(preview.assignmentsByUnitId.get('unit-2')).toEqual(['forward_observer']); + expect(preview.assignmentsByUnitId.get('unit-3')).toEqual(['maneuvering_ace']); + }); + + it('copies a targeted Recon Lance SPA to half the Alpha Strike Support Lance', () => { + const faction = createFaction('Mercenary', 'Mercenary'); + const reconUnits = Array.from({ length: 3 }, (_, index) => + createASForceUnit(`recon-${index + 1}`, createUnit(index + 1, `Recon ${index + 1}`, 'Mek', 'BattleMek', 'BM', { + role: 'Scout', as: { SZ: 1, MVm: { g: 12 } }, + }), { formationAbilities: ['forward_observer'] }), + ); + const supportUnits = Array.from({ length: 5 }, (_, index) => + createASForceUnit(`support-${index + 1}`, createUnit(index + 10, `Support ${index + 1}`, 'Mek', 'BattleMek', 'BM'), { + formationAbilities: index < 3 ? ['forward_observer'] : [], + }), + ); + const recon = createGroup(reconUnits, getFormation('recon-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: reconUnits.map(unit => unit.getUnit()) }), + ], faction); + const support = createGroup(supportUnits, getFormation('support-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: supportUnits.map(unit => unit.getUnit()) }), + ], faction); + linkGroups([support, recon]); + support.formationTargetGroupId.set(recon.id); + + const preview = FormationAbilityAssignmentUtil.previewGroupFormationAssignments(support); + + expect(preview.effectPreviews).toEqual([ + jasmine.objectContaining({ + recipientLimit: 2, + recipientUnitIds: ['support-1', 'support-2'], + descriptor: jasmine.objectContaining({ + copiedFromGroupId: recon.id, + copiedFromFormationName: 'Recon', + }), + }), + ]); + expect(preview.assignmentsByUnitId.get('support-1')).toEqual(['forward_observer']); + expect(preview.assignmentsByUnitId.get('support-2')).toEqual(['forward_observer']); + expect(preview.assignmentsByUnitId.get('support-3')).toEqual([]); + }); + + it('caps each copied Support SPA at the number assigned by the targeted formation', () => { + const faction = createFaction('Mercenary', 'Mercenary'); + const reconUnits = [ + createASForceUnit('recon-1', createUnit(1, 'Recon 1', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['eagles_eyes'] }), + createASForceUnit('recon-2', createUnit(2, 'Recon 2', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['forward_observer'] }), + createASForceUnit('recon-3', createUnit(3, 'Recon 3', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['maneuvering_ace'] }), + ]; + const supportUnits = [ + createASForceUnit('support-1', createUnit(11, 'Support 1', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['eagles_eyes'] }), + createASForceUnit('support-2', createUnit(12, 'Support 2', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['eagles_eyes'] }), + createASForceUnit('support-3', createUnit(13, 'Support 3', 'Mek', 'BattleMek', 'BM')), + createASForceUnit('support-4', createUnit(14, 'Support 4', 'Mek', 'BattleMek', 'BM')), + ]; + const recon = createGroup(reconUnits, getFormation('light-recon-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: reconUnits.map(unit => unit.getUnit()) }), + ], faction); + const support = createGroup(supportUnits, getFormation('support-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: supportUnits.map(unit => unit.getUnit()) }), + ], faction); + linkGroups([recon, support]); + support.formationTargetGroupId.set(recon.id); + + const preview = FormationAbilityAssignmentUtil.previewGroupFormationAssignments(support); + + expect(preview.effectPreviews[0].descriptor.maxAssignmentsByAbilityId?.get('eagles_eyes')).toBe(1); + expect(preview.assignmentsByUnitId.get('support-1')).toEqual(['eagles_eyes']); + expect(preview.assignmentsByUnitId.get('support-2')).toEqual([]); + }); + + it('copies a supported Battle Lance shared pool without serializing a duplicate pool', () => { + const faction = createFaction('Mercenary', 'Mercenary'); + const battleUnits = Array.from({ length: 4 }, (_, index) => + createASForceUnit(`battle-${index + 1}`, createUnit(index + 1, `Battle ${index + 1}`, 'Mek', 'BattleMek', 'BM')), + ); + const supportUnits = [ + createASForceUnit('support-1', createUnit(11, 'Support 1', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['lucky'] }), + createASForceUnit('support-2', createUnit(12, 'Support 2', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['lucky'] }), + createASForceUnit('support-3', createUnit(13, 'Support 3', 'Mek', 'BattleMek', 'BM')), + createASForceUnit('support-4', createUnit(14, 'Support 4', 'Mek', 'BattleMek', 'BM')), + ]; + const battle = createGroup(battleUnits, getFormation('battle-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: battleUnits.map(unit => unit.getUnit()) }), + ], faction); + const support = createGroup(supportUnits, getFormation('support-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: supportUnits.map(unit => unit.getUnit()) }), + ], faction); + linkGroups([battle, support]); + support.formationTargetGroupId.set(battle.id); + + const preview = FormationAbilityAssignmentUtil.previewGroupFormationAssignments(support); + const descriptor = preview.effectPreviews[0].descriptor; + + expect(preview.effectPreviews[0].recipientLimit).toBe(2); + expect(descriptor.abilityIds).toEqual(['lucky']); + expect(descriptor.maxAssignmentsByAbilityId?.get('lucky')).toBe(1); + expect(descriptor.copiedSharedPoolByAbilityId?.get('lucky')).toEqual(jasmine.objectContaining({ + formationUnitCount: 4, + resolvedLevel: 6, + maxUsesPerUnitPerScenario: 4, + })); + expect(preview.assignmentsByUnitId.get('support-1')).toEqual(['lucky']); + expect(preview.assignmentsByUnitId.get('support-2')).toEqual([]); + }); + + it('does not copy a Special Command Ability as a Support SPA', () => { + const faction = createFaction('Mercenary', 'Mercenary'); + const antiAirUnits = Array.from({ length: 4 }, (_, index) => + createASForceUnit(`anti-air-${index + 1}`, createUnit(index + 1, `Anti-Air ${index + 1}`, 'Mek', 'BattleMek', 'BM'), { + formationAbilities: index < 2 ? ['anti_aircraft_specialists'] : [], + }), + ); + const supportUnits = Array.from({ length: 4 }, (_, index) => + createASForceUnit(`support-${index + 1}`, createUnit(index + 10, `Support ${index + 1}`, 'Mek', 'BattleMek', 'BM'), { + formationAbilities: index === 0 ? ['anti_aircraft_specialists'] : [], + }), + ); + const antiAir = createGroup(antiAirUnits, getFormation('anti-air-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: antiAirUnits.map(unit => unit.getUnit()) }), + ], faction); + const support = createGroup(supportUnits, getFormation('support-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: supportUnits.map(unit => unit.getUnit()) }), + ], faction); + linkGroups([antiAir, support]); + support.formationTargetGroupId.set(antiAir.id); + + const preview = FormationAbilityAssignmentUtil.reconcileGroupFormationAssignments(support); + + expect(preview.effectPreviews).toEqual([]); + expect(supportUnits[0].formationAbilities()).toEqual([]); + }); + + it('retains the Support setup choice when a source bonus rotates per turn', () => { + const faction = createFaction('Federated Suns', 'Mercenary'); + const rifleUnits = [ + createASForceUnit('rifle-1', createUnit(1, 'Rifle 1', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['weapon_specialist'] }), + createASForceUnit('rifle-2', createUnit(2, 'Rifle 2', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['weapon_specialist'] }), + createASForceUnit('rifle-3', createUnit(3, 'Rifle 3', 'Mek', 'BattleMek', 'BM')), + ]; + const supportUnits = [ + createASForceUnit('support-1', createUnit(11, 'Support 1', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['sandblaster'] }), + createASForceUnit('support-2', createUnit(12, 'Support 2', 'Mek', 'BattleMek', 'BM')), + createASForceUnit('support-3', createUnit(13, 'Support 3', 'Mek', 'BattleMek', 'BM')), + createASForceUnit('support-4', createUnit(14, 'Support 4', 'Mek', 'BattleMek', 'BM')), + ]; + const rifle = createGroup(rifleUnits, getFormation('rifle-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: rifleUnits.map(unit => unit.getUnit()) }), + ], faction); + const support = createGroup(supportUnits, getFormation('support-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: supportUnits.map(unit => unit.getUnit()) }), + ], faction); + linkGroups([rifle, support]); + support.formationTargetGroupId.set(rifle.id); + + const preview = FormationAbilityAssignmentUtil.previewGroupFormationAssignments(support); + + expect(preview.effectPreviews[0].descriptor.abilityIds).toEqual(['weapon_specialist', 'sandblaster']); + expect(preview.effectPreviews[0].descriptor.maxAssignmentsByAbilityId?.get('sandblaster')).toBe(1); + expect(preview.assignmentsByUnitId.get('support-1')).toEqual(['sandblaster']); + }); + + it('keeps Support setup choices serialized but deactivates them below three active units', () => { + const faction = createFaction('Mercenary', 'Mercenary'); + const sourceUnits = Array.from({ length: 3 }, (_, index) => + createASForceUnit(`source-${index + 1}`, createUnit(index + 1, `Source ${index + 1}`, 'Mek', 'BattleMek', 'BM'), { + formationAbilities: ['eagles_eyes'], + }), + ); + const supportUnits = [ + createASForceUnit('support-1', createUnit(11, 'Support 1', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['eagles_eyes'] }), + createASForceUnit('support-2', createUnit(12, 'Support 2', 'Mek', 'BattleMek', 'BM')), + createASForceUnit('support-3', createUnit(13, 'Support 3', 'Mek', 'BattleMek', 'BM')), + ]; + const source = createGroup(sourceUnits, getFormation('recon-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: sourceUnits.map(unit => unit.getUnit()) }), + ], faction); + const support = createGroup(supportUnits, getFormation('support-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: supportUnits.map(unit => unit.getUnit()) }), + ], faction); + linkGroups([source, support]); + support.formationTargetGroupId.set(source.id); + + expect(isFormationTargetCopyBonusActive(support)).toBeTrue(); + supportUnits[2].setDestroyed(true); + expect(isFormationTargetCopyBonusActive(support)).toBeFalse(); + expect(supportUnits[0].formationAbilities()).toEqual(['eagles_eyes']); + supportUnits[2].setDestroyed(false); + expect(isFormationTargetCopyBonusActive(support)).toBeTrue(); + support.formationTargetGroupId.set(null); + expect(isFormationTargetCopyBonusActive(support)).toBeFalse(); + }); + + it('removes a copied SPA from Support units that are not appropriate for that ability', () => { + const faction = createFaction('Mercenary', 'Mercenary'); + const sourceUnits = Array.from({ length: 3 }, (_, index) => + createASForceUnit(`source-${index + 1}`, createUnit(index + 1, `Source ${index + 1}`, 'Mek', 'BattleMek', 'BM'), { + formationAbilities: index < 2 ? ['swordsman'] : [], + }), + ); + const supportUnits = [ + createASForceUnit('support-cv', createUnit(11, 'Support Vehicle', 'Tank', 'Combat Vehicle', 'CV'), { formationAbilities: ['swordsman'] }), + createASForceUnit('support-bm', createUnit(12, 'Support Mek', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['swordsman'] }), + createASForceUnit('support-3', createUnit(13, 'Support 3', 'Mek', 'BattleMek', 'BM')), + createASForceUnit('support-4', createUnit(14, 'Support 4', 'Mek', 'BattleMek', 'BM')), + ]; + const source = createGroup(sourceUnits, getFormation('berserker-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: sourceUnits.map(unit => unit.getUnit()) }), + ], faction); + const support = createGroup(supportUnits, getFormation('support-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: supportUnits.map(unit => unit.getUnit()) }), + ], faction); + linkGroups([source, support]); + support.formationTargetGroupId.set(source.id); + + const preview = FormationAbilityAssignmentUtil.previewGroupFormationAssignments(support); + + expect(preview.assignmentsByUnitId.get('support-cv')).toEqual([]); + expect(preview.assignmentsByUnitId.get('support-bm')).toEqual(['swordsman']); + }); + + it('uses the Command Lance exception and never copies the commander or Tactical Genius', () => { + const faction = createFaction('Mercenary', 'Mercenary'); + const commandUnits = [ + createASForceUnit('command-1', createUnit(1, 'Command 1', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['marksman'] }), + createASForceUnit('command-2', createUnit(2, 'Command 2', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['eagles_eyes'] }), + createASForceUnit('command-3', createUnit(3, 'Command 3', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['blood_stalker'] }), + createASForceUnit('commander', createUnit(4, 'Commander', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['tactical_genius'], commander: true }), + createASForceUnit('command-5', createUnit(5, 'Command 5', 'Mek', 'BattleMek', 'BM')), + ]; + const supportUnits = [ + createASForceUnit('support-1', createUnit(11, 'Support 1', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['marksman'] }), + createASForceUnit('support-2', createUnit(12, 'Support 2', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['eagles_eyes'] }), + createASForceUnit('support-3', createUnit(13, 'Support 3', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['tactical_genius'] }), + ]; + const command = createGroup(commandUnits, getFormation('command-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: commandUnits.map(unit => unit.getUnit()) }), + ], faction); + const support = createGroup(supportUnits, getFormation('support-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: supportUnits.map(unit => unit.getUnit()) }), + ], faction); + linkGroups([support, command]); + support.formationTargetGroupId.set(command.id); + + for (const unit of supportUnits) { + unit.setFormationAbilities([]); + } + const setupPreview = FormationAbilityAssignmentUtil.previewGroupFormationAssignments(support); + expect(setupPreview.effectPreviews[0].descriptor.abilityIds).toEqual([ + 'marksman', + 'eagles_eyes', + 'blood_stalker', + ]); + + supportUnits[0].setFormationAbilities(['marksman']); + supportUnits[1].setFormationAbilities(['eagles_eyes']); + supportUnits[2].setFormationAbilities(['tactical_genius']); + const preview = FormationAbilityAssignmentUtil.previewGroupFormationAssignments(support); + + expect(preview.effectPreviews[0].recipientLimit).toBe(2); + expect(preview.effectPreviews[0].maxPerUnit).toBe(1); + expect(preview.effectPreviews[0].descriptor.abilityIds).toEqual(['marksman', 'eagles_eyes', 'blood_stalker']); + expect(preview.assignmentsByUnitId.get('support-1')).toEqual(['marksman']); + expect(preview.assignmentsByUnitId.get('support-2')).toEqual(['eagles_eyes']); + expect(preview.assignmentsByUnitId.get('support-3')).toEqual([]); + }); + + it('applies the Command exception to a Strategic Command Star', () => { + const faction = createFaction('Clan', 'Mercenary'); + const commandUnits = [ + createASForceUnit('command-1', createUnit(1, 'Command 1', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['marksman'] }), + createASForceUnit('command-2', createUnit(2, 'Command 2', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['combat_intuition'] }), + createASForceUnit('command-3', createUnit(3, 'Command 3', 'Mek', 'BattleMek', 'BM')), + createASForceUnit('commander', createUnit(4, 'Commander', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['tactical_genius'], commander: true }), + createASForceUnit('fighter', createUnit(5, 'Fighter', 'Aero', 'Aerospace Fighter', 'AF')), + ]; + const supportUnits = [ + createASForceUnit('support-1', createUnit(11, 'Support 1', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['marksman'] }), + createASForceUnit('support-2', createUnit(12, 'Support 2', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['combat_intuition'] }), + createASForceUnit('support-3', createUnit(13, 'Support 3', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['tactical_genius'] }), + ]; + const command = createGroup(commandUnits, getFormation('strategic-command-star'), [ + createResolvedGroup({ name: 'Star', type: 'Star', tier: 1, units: commandUnits.map(unit => unit.getUnit()) }), + ], faction); + const support = createGroup(supportUnits, getFormation('support-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: supportUnits.map(unit => unit.getUnit()) }), + ], faction); + linkGroups([support, command]); + support.formationTargetGroupId.set(command.id); + + const preview = FormationAbilityAssignmentUtil.previewGroupFormationAssignments(support); + + expect(preview.effectPreviews[0].recipientLimit).toBe(2); + expect(preview.effectPreviews[0].maxPerUnit).toBe(1); + expect(preview.effectPreviews[0].descriptor.abilityIds).toEqual(['marksman', 'combat_intuition']); + expect(preview.assignmentsByUnitId.get('support-1')).toEqual(['marksman']); + expect(preview.assignmentsByUnitId.get('support-2')).toEqual(['combat_intuition']); + expect(preview.assignmentsByUnitId.get('support-3')).toEqual([]); + }); + + it('revalidates a targeted Support Lance when a static source assignment changes', () => { + const faction = createFaction('Mercenary', 'Mercenary'); + const reconUnits = Array.from({ length: 3 }, (_, index) => + createASForceUnit(`recon-${index + 1}`, createUnit(index + 1, `Recon ${index + 1}`, 'Mek', 'BattleMek', 'BM'), { + formationAbilities: ['eagles_eyes'], + }), + ); + const supportUnits = [ + createASForceUnit('support-1', createUnit(11, 'Support 1', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['eagles_eyes'] }), + createASForceUnit('support-2', createUnit(12, 'Support 2', 'Mek', 'BattleMek', 'BM')), + createASForceUnit('support-3', createUnit(13, 'Support 3', 'Mek', 'BattleMek', 'BM')), + ]; + const recon = createGroup(reconUnits, getFormation('recon-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: reconUnits.map(unit => unit.getUnit()) }), + ], faction); + const support = createGroup(supportUnits, getFormation('support-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: supportUnits.map(unit => unit.getUnit()) }), + ], faction); + linkGroups([support, recon]); + support.formationTargetGroupId.set(recon.id); + + FormationAbilityAssignmentUtil.reconcileGroupAndDependents(recon, { + abilityOverrides: new Map([['recon-1', ['maneuvering_ace']]]), + }); + + for (const unit of reconUnits) { + expect(unit.formationAbilities()).toEqual(['maneuvering_ace']); + } + expect(supportUnits[0].formationAbilities()).toEqual([]); + }); + + it('clears a dependent Support snapshot when its source stops being a legal target', () => { + const faction = createFaction('Mercenary', 'Mercenary'); + const sourceUnits = Array.from({ length: 3 }, (_, index) => + createASForceUnit(`source-${index + 1}`, createUnit(index + 1, `Source ${index + 1}`, 'Mek', 'BattleMek', 'BM'), { + formationAbilities: ['eagles_eyes'], + }), + ); + const supportUnits = Array.from({ length: 3 }, (_, index) => + createASForceUnit(`support-${index + 1}`, createUnit(index + 10, `Support ${index + 1}`, 'Mek', 'BattleMek', 'BM'), { + formationAbilities: index === 0 ? ['eagles_eyes'] : [], + }), + ); + const source = createGroup(sourceUnits, getFormation('recon-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: sourceUnits.map(unit => unit.getUnit()) }), + ], faction); + const support = createGroup(supportUnits, getFormation('support-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: supportUnits.map(unit => unit.getUnit()) }), + ], faction); + linkGroups([source, support]); + support.formationTargetGroupId.set(source.id); + (source as unknown as { activeFormation: () => FormationTypeDefinition }).activeFormation = () => getFormation('support-lance'); + + FormationAbilityAssignmentUtil.reconcileGroupAndDependents(source); + + expect(support.formationTargetGroupId()).toBeNull(); + expect(supportUnits[0].formationAbilities()).toEqual([]); + }); + + it('rejects missing and recursive Support targets and clears stale copied assignments', () => { + const faction = createFaction('Mercenary', 'Mercenary'); + const firstUnits = [createASForceUnit('support-1', createUnit(1, 'Support 1', 'Mek', 'BattleMek', 'BM'), { formationAbilities: ['marksman'] })]; + const secondUnits = [createASForceUnit('support-2', createUnit(2, 'Support 2', 'Mek', 'BattleMek', 'BM'))]; + const first = createGroup(firstUnits, getFormation('support-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: firstUnits.map(unit => unit.getUnit()) }), + ], faction); + const second = createGroup(secondUnits, getFormation('support-lance'), [ + createResolvedGroup({ name: 'Lance', type: 'Lance', tier: 1, units: secondUnits.map(unit => unit.getUnit()) }), + ], faction); + linkGroups([first, second]); + first.formationTargetGroupId.set(second.id); + + const preview = FormationAbilityAssignmentUtil.reconcileGroupFormationAssignments(first); + + expect(preview.effectPreviews).toEqual([]); + expect(firstUnits[0].formationAbilities()).toEqual([]); + expect(first.formationTargetGroupId()).toBeNull(); }); it('keeps formation-wide Communications Disruption out of unit assignments', () => { @@ -472,4 +882,4 @@ describe('FormationAbilityAssignmentUtil', () => { expect(unit.formationAbilities()).toEqual([]); expect(unit.commander()).toBeTrue(); }); -}); \ No newline at end of file +}); diff --git a/src/app/utils/formation-ability-assignment.util.ts b/src/app/utils/formation-ability-assignment.util.ts index 734b51856..e27e35bca 100644 --- a/src/app/utils/formation-ability-assignment.util.ts +++ b/src/app/utils/formation-ability-assignment.util.ts @@ -3,10 +3,23 @@ // Author: Drake import type { ASForceUnit } from '../models/as-force-unit.model'; -import type { UnitGroup } from '../models/force.model'; +import { GameSystem } from '../models/common.model'; +import type { Force, UnitGroup } from '../models/force.model'; +import { getAbilityDetails, PILOT_ABILITIES } from '../models/pilot-abilities.model'; import { getFormationDefinition } from './formation-blueprints'; +import { clearInvalidFormationTargetSelection, resolveFormationTargetGroup } from './formation-target.util'; import { LanceTypeIdentifierUtil } from './lance-type-identifier.util'; -import { formationInheritsParentEffects, type FormationAssignmentEffectGroup, type FormationEffectGroup, type FormationSharedPoolEffectGroup, type FormationTypeDefinition, type FormationWideAbility } from './formation-type.model'; +import { + formationHasTargetCopyEffect, + formationInheritsParentEffects, + isFormationTargetCopyEffectGroup, + type FormationAssignmentEffectGroup, + type FormationEffectGroup, + type FormationSharedPoolEffectGroup, + type FormationTargetCopyEffectGroup, + type FormationTypeDefinition, + type FormationWideAbility, +} from './formation-type.model'; export interface FormationAssignmentPreviewOptions { readonly abilityOverrides?: ReadonlyMap; @@ -25,6 +38,13 @@ export interface FormationEffectDescriptor { readonly group: FormationAssignmentEffectGroup; /** Formation-granted ability ids from either PILOT_ABILITIES or COMMAND_ABILITIES. */ readonly abilityIds: readonly string[]; + /** Optional hard cap for each copied ability across the whole recipient formation. */ + readonly maxAssignmentsByAbilityId?: ReadonlyMap; + /** Concrete source group for a dynamically copied formation bonus. */ + readonly copiedFromGroupId?: string; + readonly copiedFromFormationName?: string; + /** Shared-pool details derived from the supported formation; never serialized. */ + readonly copiedSharedPoolByAbilityId?: ReadonlyMap; } export interface FormationSharedPoolDescriptor { @@ -117,20 +137,56 @@ export function resolveFormationSharedPoolLevel( } } -function getParentFormationDefinition(definition: FormationTypeDefinition): FormationTypeDefinition | null { +function getParentFormationDefinition( + definition: FormationTypeDefinition, + gameSystem: GameSystem, +): FormationTypeDefinition | null { return definition.parent - ? getFormationDefinition(definition.parent) + ? getFormationDefinition(definition.parent, gameSystem) : null; } -function getFormationEffectChain(definition: FormationTypeDefinition | null | undefined, visited = new Set()): FormationTypeDefinition[] { +const PILOT_ABILITY_BY_ID = new Map(PILOT_ABILITIES.map((ability) => [ability.id, ability])); + +function isCopiedAbilityAppropriateForUnit( + descriptor: FormationEffectDescriptor, + unit: ASForceUnit, + abilityId: string, +): boolean { + if (!descriptor.copiedFromGroupId) { + return true; + } + + const pilotAbility = PILOT_ABILITY_BY_ID.get(abilityId); + const unitType = unit.getUnit().as?.TP; + if (!pilotAbility || !unitType) { + return true; + } + + const unitTypeFilter = getAbilityDetails(pilotAbility, GameSystem.ALPHA_STRIKE).unitTypeFilter; + return !unitTypeFilter?.length || unitTypeFilter.includes(unitType); +} + +interface FormationTargetCopyDescriptor { + readonly key: string; + readonly sourceFormationId: string; + readonly sourceFormationName: string; + readonly sourceFormationDescription: string; + readonly group: FormationTargetCopyEffectGroup; +} + +function getFormationEffectChain( + definition: FormationTypeDefinition | null | undefined, + gameSystem: GameSystem, + visited = new Set(), +): FormationTypeDefinition[] { if (!definition || visited.has(definition.id)) { return []; } visited.add(definition.id); const inheritedParentDefinitions = formationInheritsParentEffects(definition) - ? getFormationEffectChain(getParentFormationDefinition(definition), visited) + ? getFormationEffectChain(getParentFormationDefinition(definition, gameSystem), gameSystem, visited) : []; return [ @@ -139,8 +195,11 @@ function getFormationEffectChain(definition: FormationTypeDefinition | null | un ]; } -export function getInheritedFormationEffectGroups(definition: FormationTypeDefinition | null | undefined): FormationEffectGroup[] { - return getFormationEffectChain(definition).flatMap((sourceDefinition) => sourceDefinition.effectGroups ?? []); +export function getInheritedFormationEffectGroups( + definition: FormationTypeDefinition | null | undefined, + gameSystem: GameSystem, +): FormationEffectGroup[] { + return getFormationEffectChain(definition, gameSystem).flatMap((sourceDefinition) => sourceDefinition.effectGroups ?? []); } function orderAbilityIds(abilityIds: readonly string[], preferredOrder: readonly string[]): string[] { @@ -185,7 +244,7 @@ function getRequestedAssignments( return assignments; } -function hasAutomaticRecipients(group: FormationEffectGroup): boolean { +function hasAutomaticRecipients(group: FormationAssignmentEffectGroup): boolean { switch (group.distribution) { case 'all': case 'conditional': @@ -198,20 +257,22 @@ function hasAutomaticRecipients(group: FormationEffectGroup): boolean { } } -function getSupportedEffectDescriptors(definition: FormationTypeDefinition | null): { +function getSupportedEffectDescriptors(definition: FormationTypeDefinition | null, gameSystem: GameSystem): { supported: FormationEffectDescriptor[]; sharedPools: FormationSharedPoolDescriptor[]; formationWideAbilities: FormationWideAbilityDescriptor[]; + targetCopies: FormationTargetCopyDescriptor[]; } { if (!definition) { - return { supported: [], sharedPools: [], formationWideAbilities: [] }; + return { supported: [], sharedPools: [], formationWideAbilities: [], targetCopies: [] }; } const supported: FormationEffectDescriptor[] = []; const sharedPools: FormationSharedPoolDescriptor[] = []; const formationWideAbilities: FormationWideAbilityDescriptor[] = []; + const targetCopies: FormationTargetCopyDescriptor[] = []; - for (const sourceDefinition of getFormationEffectChain(definition)) { + for (const sourceDefinition of getFormationEffectChain(definition, gameSystem)) { const effectGroups = sourceDefinition.effectGroups ?? []; effectGroups.forEach((group, index) => { if (group.distribution === 'formation-wide') { @@ -227,6 +288,17 @@ function getSupportedEffectDescriptors(definition: FormationTypeDefinition | nul } const key = `${sourceDefinition.id}:${index}`; + if (isFormationTargetCopyEffectGroup(group)) { + targetCopies.push({ + key, + sourceFormationId: sourceDefinition.id, + sourceFormationName: sourceDefinition.name, + sourceFormationDescription: sourceDefinition.description, + group, + }); + return; + } + const abilityIds = getEffectAbilityIds(group); if (group.distribution === 'shared-pool') { @@ -257,10 +329,10 @@ function getSupportedEffectDescriptors(definition: FormationTypeDefinition | nul }); } - return { supported, sharedPools, formationWideAbilities }; + return { supported, sharedPools, formationWideAbilities, targetCopies }; } -function getConditionalCandidate(unit: ASForceUnit, group: FormationEffectGroup): boolean { +function getConditionalCandidate(unit: ASForceUnit, group: FormationAssignmentEffectGroup): boolean { if (group.condition === 'Move (Thrust) ≤ 9') { const movementValues = Object.values(unit.getUnit().as?.MVm ?? {}); if (movementValues.length === 0) { @@ -272,7 +344,7 @@ function getConditionalCandidate(unit: ASForceUnit, group: FormationEffectGroup) return false; } -function getRecipientLimit(group: FormationEffectGroup, candidateCount: number): number | null { +function getRecipientLimit(group: FormationAssignmentEffectGroup, candidateCount: number): number | null { switch (group.distribution) { case 'all': case 'conditional': @@ -413,30 +485,61 @@ function buildChooseEachAssignments( ): Map { const maxPerUnit = descriptor.group.maxPerUnit ?? 1; const nextAssignments = new Map(); + const usageCounts = new Map(); + + const addWithinAbilityCap = (abilityId: string): boolean => { + const usageCount = usageCounts.get(abilityId) ?? 0; + const assignmentLimit = descriptor.maxAssignmentsByAbilityId?.get(abilityId); + if (assignmentLimit !== undefined && usageCount >= assignmentLimit) { + return false; + } + usageCounts.set(abilityId, usageCount + 1); + return true; + }; if (descriptor.group.distribution !== 'fixed-pairs') { for (const unit of recipientUnits) { - nextAssignments.set(unit.id, (currentAssignments.get(unit.id) ?? []).slice(0, maxPerUnit)); + const selectedAbilityIds: string[] = []; + for (const abilityId of currentAssignments.get(unit.id) ?? []) { + if (selectedAbilityIds.length >= maxPerUnit) { + break; + } + if (!isCopiedAbilityAppropriateForUnit(descriptor, unit, abilityId)) { + continue; + } + if (addWithinAbilityCap(abilityId)) { + selectedAbilityIds.push(abilityId); + } + } + if (selectedAbilityIds.length > 0) { + nextAssignments.set(unit.id, selectedAbilityIds); + } } return nextAssignments; } const maxPairs = descriptor.group.count ?? 0; - const usageCounts = new Map(); + const pairUsageCounts = new Map(); for (const unit of recipientUnits) { const selectedAbilityIds: string[] = []; for (const abilityId of currentAssignments.get(unit.id) ?? []) { - const usageCount = usageCounts.get(abilityId) ?? 0; + if (!isCopiedAbilityAppropriateForUnit(descriptor, unit, abilityId)) { + continue; + } + const usageCount = pairUsageCounts.get(abilityId) ?? 0; if (usageCount >= 2) { continue; } - if (usageCount === 0 && usageCounts.size >= maxPairs) { + if (usageCount === 0 && pairUsageCounts.size >= maxPairs) { + continue; + } + if (!addWithinAbilityCap(abilityId)) { continue; } selectedAbilityIds.push(abilityId); - usageCounts.set(abilityId, usageCount + 1); + pairUsageCounts.set(abilityId, usageCount + 1); if (selectedAbilityIds.length >= maxPerUnit) { break; } @@ -450,6 +553,174 @@ function buildChooseEachAssignments( return nextAssignments; } +function isCommandFormation(formationId: string): boolean { + return formationId === 'command-lance' + || formationId === 'vehicle-command-lance' + || formationId === 'strategic-command-star'; +} + +function resolveTargetCopyEffectDescriptor( + owner: UnitGroup, + descriptor: FormationTargetCopyDescriptor, +): FormationEffectDescriptor | null { + const target = resolveFormationTargetGroup(owner); + const targetFormation = target?.activeFormation(); + if (!target || !targetFormation) { + return null; + } + + // Target-copy formations are rejected by resolveFormationTargetGroup, so this preview cannot recurse. + const targetPreview = FormationAbilityAssignmentUtil.previewGroupFormationAssignments(target); + const commandFormation = isCommandFormation(targetFormation.id); + const abilityCounts = new Map(); + const copiedSharedPoolByAbilityId = new Map(); + let targetRecipientCount = 0; + + for (const unit of target.units()) { + if (commandFormation && unit.id === targetPreview.commanderUnitId) { + continue; + } + + const copyableAbilityIds = (targetPreview.assignmentsByUnitId.get(unit.id) ?? []) + .filter((abilityId) => abilityId !== 'tactical_genius' && PILOT_ABILITY_BY_ID.has(abilityId)); + if (copyableAbilityIds.length === 0) { + continue; + } + + targetRecipientCount += 1; + for (const abilityId of copyableAbilityIds) { + abilityCounts.set(abilityId, (abilityCounts.get(abilityId) ?? 0) + 1); + } + } + + // A shared formation pool is one SPA received by the supported formation, + // even though any of its units may spend that pool. Keep its level/usage + // metadata derived from the source preview and cap Support to one copy. + if (!commandFormation) { + for (const pool of targetPreview.sharedPoolPreviews) { + for (const abilityId of pool.descriptor.abilityIds) { + if (!PILOT_ABILITY_BY_ID.has(abilityId)) continue; + abilityCounts.set(abilityId, (abilityCounts.get(abilityId) ?? 0) + 1); + if (!copiedSharedPoolByAbilityId.has(abilityId)) { + copiedSharedPoolByAbilityId.set(abilityId, pool); + } + } + } + if (targetPreview.sharedPoolPreviews.length > 0) { + targetRecipientCount = Math.max(targetRecipientCount, target.units().length); + } + } + + // Support assignments are fixed at setup even when the source formation + // redistributes its own bonus each turn. Existing Support assignments are + // therefore the persisted setup snapshot. Retain them while the selected + // source formation can legally grant that SPA, bounded by the source + // effect's maximum possible count so malformed saves cannot expand it. + const maximumPossibleCounts = new Map(); + const perTurnAbilityIds = new Set(); + for (const effect of targetPreview.effectPreviews) { + if (commandFormation && effect.descriptor.group.distribution === 'commander') { + continue; + } + const recipientCapacity = effect.recipientLimit ?? effect.candidateUnitIds.length; + const perAbilityCapacity = effect.descriptor.group.distribution === 'fixed-pairs' + ? Math.min(2, recipientCapacity) + : recipientCapacity; + for (const abilityId of effect.descriptor.abilityIds) { + if (abilityId === 'tactical_genius' || !PILOT_ABILITY_BY_ID.has(abilityId)) continue; + if (effect.descriptor.group.perTurn) { + perTurnAbilityIds.add(abilityId); + } + maximumPossibleCounts.set( + abilityId, + (maximumPossibleCounts.get(abilityId) ?? 0) + perAbilityCapacity, + ); + } + } + for (const pool of targetPreview.sharedPoolPreviews) { + for (const abilityId of pool.descriptor.abilityIds) { + if (!PILOT_ABILITY_BY_ID.has(abilityId)) continue; + maximumPossibleCounts.set(abilityId, (maximumPossibleCounts.get(abilityId) ?? 0) + 1); + } + } + + const retainedSetupCounts = new Map(); + for (const unit of owner.units()) { + for (const abilityId of uniqueAbilityIds(unit.formationAbilities())) { + const maximumPossible = maximumPossibleCounts.get(abilityId) ?? 0; + const retainedCount = retainedSetupCounts.get(abilityId) ?? 0; + if (retainedCount < maximumPossible) { + retainedSetupCounts.set(abilityId, retainedCount + 1); + } + } + } + + const retainedPerTurnCounts = new Map(); + for (const [abilityId, retainedCount] of retainedSetupCounts) { + if (perTurnAbilityIds.has(abilityId)) { + retainedPerTurnCounts.set(abilityId, retainedCount); + } + } + + if (commandFormation && retainedPerTurnCounts.size > 0) { + const currentCounts = new Map(abilityCounts); + abilityCounts.clear(); + let remainingCopies = 2; + const addCommandCounts = (counts: ReadonlyMap): void => { + for (const [abilityId, count] of counts) { + if (remainingCopies <= 0) break; + const currentCount = abilityCounts.get(abilityId) ?? 0; + const maximumPossible = maximumPossibleCounts.get(abilityId) ?? 0; + const desiredCount = Math.min(count, maximumPossible); + const addCount = Math.min(desiredCount - currentCount, remainingCopies); + if (addCount <= 0) continue; + abilityCounts.set(abilityId, currentCount + addCount); + remainingCopies -= addCount; + } + }; + addCommandCounts(retainedPerTurnCounts); + addCommandCounts(currentCounts); + } else { + for (const [abilityId, setupCount] of retainedPerTurnCounts) { + abilityCounts.set(abilityId, Math.max(abilityCounts.get(abilityId) ?? 0, setupCount)); + } + } + + const abilityIds = [...abilityCounts.keys()]; + if (abilityIds.length === 0) { + return null; + } + + const totalCopiedAbilities = [...abilityCounts.values()].reduce((sum, count) => sum + count, 0); + const recipientCount = commandFormation + ? Math.min(owner.units().length, totalCopiedAbilities, 2) + : descriptor.group.recipientLimit === 'half-self-round-down' + ? Math.floor(owner.units().length / 2) + : Math.floor(targetRecipientCount / 2); + const assignmentGroup: FormationAssignmentEffectGroup = { + abilityIds, + selection: 'choose-each', + distribution: 'fixed', + count: recipientCount, + maxPerUnit: commandFormation ? 1 : Math.max(1, abilityIds.length), + }; + + return { + key: `${descriptor.key}:${target.id}`, + sourceFormationId: descriptor.sourceFormationId, + sourceFormationName: descriptor.sourceFormationName, + sourceFormationDescription: descriptor.sourceFormationDescription, + group: assignmentGroup, + abilityIds, + maxAssignmentsByAbilityId: abilityCounts, + copiedFromGroupId: target.id, + copiedFromFormationName: targetFormation.name, + copiedSharedPoolByAbilityId: copiedSharedPoolByAbilityId.size > 0 + ? copiedSharedPoolByAbilityId + : undefined, + }; +} + function freezeEffectPreview(preview: MutableFormationEffectPreview): FormationEffectPreview { const frozenAssignments = new Map(); preview.assignedByUnitId.forEach((abilityIds, unitId) => { @@ -473,7 +744,16 @@ export class FormationAbilityAssignmentUtil { options?: FormationAssignmentPreviewOptions, ): FormationAssignmentPreview { const formation = group.activeFormation(); - const { supported, sharedPools, formationWideAbilities } = getSupportedEffectDescriptors(formation); + const { supported, sharedPools, formationWideAbilities, targetCopies } = getSupportedEffectDescriptors( + formation, + group.force.gameSystem, + ); + const supportedEffects = [ + ...supported, + ...targetCopies + .map((descriptor) => resolveTargetCopyEffectDescriptor(group, descriptor)) + .filter((descriptor): descriptor is FormationEffectDescriptor => descriptor !== null), + ]; const formationUnitCount = group.units().length; const filterContext = LanceTypeIdentifierUtil.getRequirementsFilterContextForGroup(group); const baseEligibleUnits = (filterContext.filteredUnits as ASForceUnit[] | undefined) ?? group.units(); @@ -488,7 +768,7 @@ export class FormationAbilityAssignmentUtil { const previousRecipientIds = new Set(); const previews: MutableFormationEffectPreview[] = []; - for (const descriptor of supported) { + for (const descriptor of supportedEffects) { const candidateUnits = getCandidateUnits(descriptor, baseEligibleUnits, commanderUnitId, previousRecipientIds); const recipientLimit = getRecipientLimit(descriptor.group, candidateUnits.length); const currentAssignments = getInitialAssignedAbilityIds(candidateUnits, requestedAssignments, descriptor); @@ -579,6 +859,7 @@ export class FormationAbilityAssignmentUtil { group: UnitGroup, options?: ReconcileFormationAssignmentOptions, ): FormationAssignmentPreview { + clearInvalidFormationTargetSelection(group); const preview = this.previewGroupFormationAssignments(group, options); const markModified = options?.markModified ?? true; @@ -590,4 +871,44 @@ export class FormationAbilityAssignmentUtil { return preview; } -} \ No newline at end of file + + /** Reconcile a changed group first, then every Support formation that targets it. */ + public static reconcileGroupAndDependents( + group: UnitGroup, + options?: ReconcileFormationAssignmentOptions, + ): FormationAssignmentPreview { + const preview = this.reconcileGroupFormationAssignments(group, options); + const dependentOptions = options?.markModified === undefined + ? undefined + : { markModified: options.markModified }; + + for (const candidate of group.force.groups()) { + if (candidate.id === group.id + || candidate.formationTargetGroupId() !== group.id + || !formationHasTargetCopyEffect(candidate.activeFormation())) { + continue; + } + this.reconcileGroupFormationAssignments(candidate as UnitGroup, dependentOptions); + } + + return preview; + } + + /** Reconcile ordinary formations before target-copy formations so load order cannot change results. */ + public static reconcileForceFormationAssignments( + force: Force, + options?: ReconcileFormationAssignmentOptions, + ): void { + const groups = force.groups(); + for (const group of groups) { + if (!formationHasTargetCopyEffect(group.activeFormation())) { + this.reconcileGroupFormationAssignments(group, options); + } + } + for (const group of groups) { + if (formationHasTargetCopyEffect(group.activeFormation())) { + this.reconcileGroupFormationAssignments(group, options); + } + } + } +} diff --git a/src/app/utils/formation-blueprints.spec.ts b/src/app/utils/formation-blueprints.spec.ts new file mode 100644 index 000000000..392eb1fd8 --- /dev/null +++ b/src/app/utils/formation-blueprints.spec.ts @@ -0,0 +1,265 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { GameSystem, Rulebook } from '../models/common.model'; +import { + FORMATION_RUNTIME_DEFINITIONS, + getFormationBlueprint, + getFormationDefinition, + getFormationDefinitions, +} from './formation-blueprints'; +import type { + FormationAssignmentEffectGroup, + FormationSharedPoolEffectGroup, + FormationTypeDefinition, +} from './formation-type.model'; + +function definition(id: string, gameSystem: GameSystem): FormationTypeDefinition { + const result = getFormationDefinition(id, gameSystem); + if (!result) throw new Error(`Formation '${id}' not found for ${gameSystem}.`); + return result; +} + +function assignmentGroup( + id: string, + gameSystem: GameSystem, + index = 0, +): FormationAssignmentEffectGroup { + const group = definition(id, gameSystem).effectGroups?.[index]; + if (!group || group.distribution === 'shared-pool' || group.distribution === 'formation-wide' || group.distribution === 'formation-target') { + throw new Error(`Formation '${id}' effect group ${index} is not assignable.`); + } + return group; +} + +function sharedPoolGroup(id: string, gameSystem: GameSystem): FormationSharedPoolEffectGroup { + const group = definition(id, gameSystem).effectGroups?.[0]; + if (!group || group.distribution !== 'shared-pool') { + throw new Error(`Formation '${id}' does not have a shared-pool effect.`); + } + return group; +} + +describe('formation blueprint game-system rules', () => { + it('keeps identity common while resolving every formation for both systems', () => { + const classic = getFormationDefinitions(GameSystem.CLASSIC); + const alphaStrike = getFormationDefinitions(GameSystem.ALPHA_STRIKE); + + expect(classic.map(item => item.id)).toEqual(FORMATION_RUNTIME_DEFINITIONS.map(item => item.id)); + expect(alphaStrike.map(item => item.id)).toEqual(FORMATION_RUNTIME_DEFINITIONS.map(item => item.id)); + + for (const source of FORMATION_RUNTIME_DEFINITIONS) { + expect('effectDescription' in source).toBeFalse(); + expect('effectGroups' in source).toBeFalse(); + expect('requirements' in source).toBeFalse(); + expect('rulesRef' in source).toBeFalse(); + + const classicDefinition = definition(source.id, GameSystem.CLASSIC); + const alphaStrikeDefinition = definition(source.id, GameSystem.ALPHA_STRIKE); + expect(classicDefinition.name).toBe(source.name); + expect(alphaStrikeDefinition.name).toBe(source.name); + expect(classicDefinition.gameSystem).toBe(GameSystem.CLASSIC); + expect(alphaStrikeDefinition.gameSystem).toBe(GameSystem.ALPHA_STRIKE); + } + }); + + it('keeps ASCE references Alpha-Strike-only and preserves intentionally shared CO references', () => { + for (const source of FORMATION_RUNTIME_DEFINITIONS) { + const classicRefs = source.classic.rulesRef ?? []; + const alphaStrikeRefs = source.alphaStrike.rulesRef ?? []; + + expect(classicRefs.some(reference => reference.book === Rulebook.ASCE)) + .withContext(source.id) + .toBeFalse(); + + for (const alphaStrikeCoRef of alphaStrikeRefs.filter(reference => reference.book === Rulebook.CO)) { + expect(classicRefs) + .withContext(source.id) + .toContain(alphaStrikeCoRef); + } + + expect(classicRefs.filter(reference => reference.book !== Rulebook.CO && reference.book !== Rulebook.ASCE)) + .withContext(source.id) + .toEqual(alphaStrikeRefs.filter(reference => reference.book !== Rulebook.CO && reference.book !== Rulebook.ASCE)); + } + }); + + it('uses the source pages for the weight-specific Battle Lance variants', () => { + for (const id of ['light-battle-lance', 'medium-battle-lance', 'heavy-battle-lance']) { + expect(definition(id, GameSystem.CLASSIC).rulesRef) + .withContext(id) + .toContain(jasmine.objectContaining({ book: Rulebook.CO, page: 63 })); + expect(definition(id, GameSystem.ALPHA_STRIKE).rulesRef) + .withContext(id) + .toContain(jasmine.objectContaining({ book: Rulebook.ASCE, page: 118 })); + } + }); + + it('uses the system-specific Assault, Command, and Fire recipient counts', () => { + expect(assignmentGroup('assault-lance', GameSystem.CLASSIC)).toEqual(jasmine.objectContaining({ + distribution: 'fixed', + count: 2, + perTurn: true, + })); + expect(assignmentGroup('assault-lance', GameSystem.ALPHA_STRIKE)).toEqual(jasmine.objectContaining({ + distribution: 'half-round-down', + perTurn: true, + })); + + for (const id of ['command-lance', 'vehicle-command-lance']) { + expect(assignmentGroup(id, GameSystem.CLASSIC)).withContext(id).toEqual(jasmine.objectContaining({ + distribution: 'fixed', + count: 2, + excludeCommander: true, + })); + expect(assignmentGroup(id, GameSystem.ALPHA_STRIKE)).withContext(id).toEqual(jasmine.objectContaining({ + distribution: 'half-round-up', + })); + } + + for (const id of ['fire-lance', 'anti-air-lance', 'artillery-fire-lance', 'direct-fire-lance', 'fire-support-lance']) { + expect(assignmentGroup(id, GameSystem.CLASSIC)).withContext(id).toEqual(jasmine.objectContaining({ + distribution: 'fixed', + count: 2, + perTurn: true, + })); + expect(assignmentGroup(id, GameSystem.ALPHA_STRIKE)).withContext(id).toEqual(jasmine.objectContaining({ + distribution: 'half-round-down', + perTurn: true, + })); + } + }); + + it('uses the correct Battle Lance Lucky pools', () => { + const classic = sharedPoolGroup('battle-lance', GameSystem.CLASSIC).sharedPool; + const alphaStrike = sharedPoolGroup('battle-lance', GameSystem.ALPHA_STRIKE).sharedPool; + + expect(classic).toEqual(jasmine.objectContaining({ + level: { kind: 'fixed', value: 6 }, + totalUsesPerScenario: 6, + maxUsesPerUnitPerScenario: 4, + stacksWithIndividualAbility: true, + })); + expect(alphaStrike).toEqual(jasmine.objectContaining({ + level: { kind: 'unit-count-plus', offset: 2 }, + maxUsesPerUnitPerScenario: 4, + stacksWithIndividualAbility: true, + })); + expect(alphaStrike.totalUsesPerScenario).toBeUndefined(); + }); + + it('models all three Recon variants independently for Classic and Alpha Strike', () => { + expect(definition('recon-lance', GameSystem.CLASSIC).effectGroups).toEqual([ + { abilityIds: ['eagles_eyes', 'maneuvering_ace'], selection: 'choose-one', distribution: 'fixed', count: 3 }, + { abilityIds: ['forward_observer'], selection: 'all', distribution: 'all' }, + ]); + expect(definition('recon-lance', GameSystem.ALPHA_STRIKE).effectGroups).toEqual([{ + abilityIds: ['eagles_eyes', 'forward_observer', 'maneuvering_ace'], + selection: 'choose-one', + distribution: 'all', + }]); + + expect(definition('heavy-recon-lance', GameSystem.CLASSIC).effectGroups).toEqual([ + { abilityIds: ['eagles_eyes', 'maneuvering_ace'], selection: 'choose-one', distribution: 'fixed', count: 2 }, + { abilityIds: ['forward_observer'], selection: 'all', distribution: 'all' }, + ]); + expect(definition('heavy-recon-lance', GameSystem.ALPHA_STRIKE).effectGroups).toEqual([{ + abilityIds: ['eagles_eyes', 'forward_observer', 'maneuvering_ace'], + selection: 'choose-one', + distribution: 'half-round-up', + }]); + + expect(definition('light-recon-lance', GameSystem.CLASSIC).effectGroups).toEqual([ + { abilityIds: ['eagles_eyes', 'maneuvering_ace'], selection: 'choose-one', distribution: 'all' }, + { abilityIds: ['forward_observer'], selection: 'all', distribution: 'all' }, + ]); + expect(definition('light-recon-lance', GameSystem.ALPHA_STRIKE).effectGroups).toEqual([{ + abilityIds: ['eagles_eyes', 'forward_observer', 'maneuvering_ace'], + selection: 'choose-each', + distribution: 'all', + }]); + }); + + it('keeps Alpha Strike-only Blood Stalker formation targeting and system-specific Support text', () => { + for (const id of ['pursuit-lance', 'probe-lance', 'sweep-lance']) { + expect(definition(id, GameSystem.CLASSIC).effectDescription).withContext(id).not.toContain('enemy formation'); + expect(definition(id, GameSystem.ALPHA_STRIKE).effectDescription).withContext(id).toContain('enemy formation'); + } + + const classicSupport = definition('support-lance', GameSystem.CLASSIC); + const alphaStrikeSupport = definition('support-lance', GameSystem.ALPHA_STRIKE); + + expect(classicSupport.effectDescription).toContain('For every two units'); + expect(classicSupport.effectDescription).toContain('choice of SPAs'); + expect(classicSupport.effectDescription).toContain('those choices may not change during play'); + expect(alphaStrikeSupport.effectDescription).toContain('Half the Support Lance units (round down)'); + expect(alphaStrikeSupport.effectDescription).toContain('number of copies of each SPA may not exceed'); + expect(alphaStrikeSupport.effectDescription).toContain('they may not be moved during play'); + expect(classicSupport.effectGroups).toEqual([{ + selection: 'copy', + distribution: 'formation-target', + recipientLimit: 'one-per-two-target-recipients', + }]); + expect(alphaStrikeSupport.effectGroups).toEqual([{ + selection: 'copy', + distribution: 'formation-target', + recipientLimit: 'half-self-round-down', + }]); + + for (const supportDefinition of [classicSupport, alphaStrikeSupport]) { + expect(supportDefinition.effectDescription).toContain('at least three active units'); + expect(supportDefinition.effectDescription).toContain('not lost if the supported formation falls below its own retention threshold'); + expect(supportDefinition.effectDescription).toContain('SPAs actually granted to its non-commander units'); + expect(supportDefinition.effectDescription).toContain('Tactical Genius is never copied'); + } + }); + + it('uses each system\'s Communications Disruption effect', () => { + const classicDefinition = definition('electronic-warfare-squadron', GameSystem.CLASSIC); + const alphaStrikeDefinition = definition('electronic-warfare-squadron', GameSystem.ALPHA_STRIKE); + const classic = classicDefinition.effectGroups?.[0]; + const alphaStrike = alphaStrikeDefinition.effectGroups?.[0]; + if (classic?.distribution !== 'formation-wide' || alphaStrike?.distribution !== 'formation-wide') { + throw new Error('Electronic Warfare Squadron must expose a formation-wide ability.'); + } + + for (const effectDescription of [classicDefinition.effectDescription, alphaStrikeDefinition.effectDescription]) { + expect(effectDescription).toContain('already has Communications Disruption'); + expect(effectDescription).toContain('choose the affected enemy lance or squadron'); + expect(effectDescription).toContain('Ground units can be affected only while'); + } + expect(classic.formationWideAbilities[0].summary.join(' ')).toContain('Walking, Cruising, or Safe Thrust'); + expect(classic.formationWideAbilities[0].rulesRef).toEqual([{ book: Rulebook.CO, page: 84 }]); + expect(alphaStrike.formationWideAbilities[0].summary.join(' ')).toContain('reduces Move by'); + expect(alphaStrike.formationWideAbilities[0].rulesRef).toEqual([{ book: Rulebook.ASCE, page: 103 }]); + }); + + it('splits system-specific composition constraints and encodes the corrected squadron and vehicle rules', () => { + const classicBattle = getFormationBlueprint('battle-lance', GameSystem.CLASSIC); + const alphaStrikeBattle = getFormationBlueprint('battle-lance', GameSystem.ALPHA_STRIKE); + expect(classicBattle?.constraints.some(constraint => constraint.kind === 'matched-pairs-min')).toBeTrue(); + expect(alphaStrikeBattle?.constraints.some(constraint => constraint.kind === 'matched-pairs-min')).toBeFalse(); + + const classicPursuitMove = getFormationBlueprint('pursuit-lance', GameSystem.CLASSIC)?.constraints + .find(constraint => constraint.id === 'pursuit-move-percent'); + const alphaStrikePursuitMove = getFormationBlueprint('pursuit-lance', GameSystem.ALPHA_STRIKE)?.constraints + .find(constraint => constraint.id === 'pursuit-move-percent'); + expect(classicPursuitMove).toEqual(jasmine.objectContaining({ rounding: 'ceil' })); + expect(alphaStrikePursuitMove).toEqual(jasmine.objectContaining({ rounding: 'normal' })); + + const fireSupportConstraints = getFormationBlueprint('fire-support-squadron', GameSystem.ALPHA_STRIKE)?.constraints ?? []; + expect(fireSupportConstraints).toContain(jasmine.objectContaining({ + kind: 'all', + predicate: 'fire-support-or-dogfighter-role', + })); + + const vehicleCommandConstraint = getFormationBlueprint('vehicle-command-lance', GameSystem.CLASSIC)?.constraints + .find(constraint => constraint.id === 'vehicle-command-command-pair'); + expect(vehicleCommandConstraint).toEqual(jasmine.objectContaining({ + kind: 'count-min', + predicate: 'command-heavy-role', + count: 2, + })); + }); +}); diff --git a/src/app/utils/formation-blueprints.ts b/src/app/utils/formation-blueprints.ts index 52f8d946c..64526035a 100644 --- a/src/app/utils/formation-blueprints.ts +++ b/src/app/utils/formation-blueprints.ts @@ -3,8 +3,8 @@ // Author: Drake import { GameSystem, Rulebook } from '../models/common.model'; -import type { FormationTypeDefinition } from './formation-type.model'; -import type { FormationConstraint, FormationPredicateId, FormationRequirementBlueprint } from './formation-requirement.model'; +import { resolveFormationTypeDefinition, type FormationTypeDefinition, type FormationTypeDefinitionSource } from './formation-type.model'; +import type { FormationConstraint, FormationPredicateId, FormationRequirementBlueprint, FormationRequirementBlueprintSource } from './formation-requirement.model'; function all(id: string, label: string, predicate: FormationPredicateId): FormationConstraint { return { id, kind: 'all', label, predicate }; @@ -26,6 +26,10 @@ function percent(id: string, label: string, predicate: FormationPredicateId, rat return { id, kind: 'percent-min', label, predicate, ratio, rounding: 'ceil' }; } +function percentNormally(id: string, label: string, predicate: FormationPredicateId, ratio: number): FormationConstraint { + return { id, kind: 'percent-min', label, predicate, ratio, rounding: 'normal' }; +} + function strictMajority(id: string, label: string, predicate: FormationPredicateId): FormationConstraint { return { id, kind: 'percent-min', label, predicate, ratio: 0.5, rounding: 'strict-majority' }; } @@ -87,9 +91,13 @@ const assaultLanceConstraints: readonly FormationConstraint[] = [ ]), ]; -const battleLanceConstraints: readonly FormationConstraint[] = [ +const battleLanceCoreConstraints: readonly FormationConstraint[] = [ percent('battle-heavy-percent', '50% heavy/Size 3+ units', 'heavy-size', 0.5), countMin('battle-role-count', '3 Brawler/Sniper/Skirmisher units', 'battle-role', 3), +]; + +const classicBattleLanceConstraints: readonly FormationConstraint[] = [ + ...battleLanceCoreConstraints, matchedPairs('battle-vehicle-pairs', '2 matched heavy/Size 3+ vehicle pairs', 'heavy-size', 2, 'combat-vehicle'), ]; @@ -102,31 +110,30 @@ const clanOnlyConstraints: readonly FormationConstraint[] = [ ]; const CLAN_EXCLUSIVE_FACTIONS = ['Clan']; -function bloodStalkerFormationEffectDescription(formationName: string): (gameSystem: GameSystem) => string { - return (gameSystem) => { - const isAs = gameSystem === GameSystem.ALPHA_STRIKE; - if (isAs) { - return `75% of the units receive the Blood Stalker SPA. The ${formationName} may choose an enemy formation rather than a single unit as the Blood Stalker target. All members must choose the same enemy formation.`; - } else { - return '75% of the units receive the Blood Stalker SPA.'; - } - }; +function sharedBlueprint( + id: string, + constraints: readonly FormationConstraint[], +): FormationRequirementBlueprintSource { + return { id, classic: constraints, alphaStrike: constraints }; } -export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ +export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinitionSource[] = [ { id: 'anti-mech-lance', name: 'Anti-\'Mech', description: 'Infantry trained to disrupt and damage enemy BattleMechs while supporting allied forces.', - effectDescription: (gameSystem) => gameSystem === GameSystem.ALPHA_STRIKE - ? 'Enemy Units in base-to-base contact with an Anti-\'Mech Lance suffer a -1 To-Hit Modifier penalty to any weapon attacks made by that enemy Unit.' - : 'Distracting Swarm: units in this formation swarming an enemy unit cause a +1 To-Hit modifier to any weapon attacks made by the enemy unit.', - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 61 }, { book: Rulebook.FMK, page: 87 }], - requirements: (gameSystem) => { - const inf = gameSystem === GameSystem.ALPHA_STRIKE ? ' (CI, BA, or PM)' : ''; - return `Minimum 3 units. All units must be Infantry${inf}.`; + classic: { + effectDescription: 'Distracting Swarm: units in this formation swarming an enemy unit cause a +1 To-Hit modifier to any weapon attacks made by the enemy unit.', + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 61 }, { book: Rulebook.FMK, page: 87 }], + requirements: 'Minimum 3 units. All units must be Infantry.', + }, + alphaStrike: { + effectDescription: 'Enemy Units in base-to-base contact with an Anti-\'Mech Lance suffer a -1 To-Hit Modifier penalty to any weapon attacks made by that enemy Unit.', + minUnits: 3, + rulesRef: [{ book: Rulebook.FMK, page: 87 }], + requirements: 'Minimum 3 units. All units must be Infantry (CI, BA, or PM).', }, }, @@ -135,28 +142,40 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ // Requirements (AS): At least 3 units Size 3+. No Size 1. All armor ≥ 5. // 75% medium-range ≥ 3. At least 1 Juggernaut or 2 Snipers. // Requirements (CBT): At least 3 heavy+. No light. All armor ≥ 135. - // 75% can deal 25 dmg at 7 hexes. 1 Juggernaut + 2 Snipers. - // Bonus: Choose Demoralizer or Multi-Tasker; up to half (round down) per turn. + // 75% can deal 25 dmg at 7 hexes. Must contain at least 1 Juggernaut or 2 Snipers. + // Bonus: Choose Demoralizer or Multi-Tasker; Classic grants it to up to 2 + // units per turn, while Alpha Strike grants it to half (round down). // { id: 'assault-lance', name: 'Assault', description: 'A slow, heavily armored powerhouse that uses massive firepower and brute force to break through enemy lines.', - effectDescription: 'At the beginning of play, choose either Demoralizer or Multi-Tasker SPA. Each turn, designate up to half the units (rounded down) to receive the chosen ability for that turn. Destroyed or withdrawn units do not count.', - effectGroups: [{ - abilityIds: ['demoralizer', 'multi_tasker'], - selection: 'choose-one', - distribution: 'half-round-down', - perTurn: true, - }], - idealRole: 'Juggernaut', - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 61 }, { book: Rulebook.ASCE, page: 118 }], - requirements: (gameSystem) => { - if (gameSystem === GameSystem.ALPHA_STRIKE) { - return 'Minimum 3 units. At least 3 units Size 3+. No Size 1 units. All armor ≥ 5. 75% must have medium-range damage ≥ 3. At least 1 Juggernaut or 2 Snipers.'; - } - return 'Minimum 3 units. At least 3 heavy or assault. No light units. All armor ≥ 135 points. 75% must deal 25+ damage at 7 hexes. At least 1 Juggernaut or 2 Snipers.'; + classic: { + effectDescription: 'At the beginning of play, choose either Demoralizer or Multi-Tasker SPA. At the beginning of each turn, designate up to two units to receive the chosen ability for that turn. The recipients may change each turn, but the chosen SPA may not change during the scenario.', + effectGroups: [{ + abilityIds: ['demoralizer', 'multi_tasker'], + selection: 'choose-one', + distribution: 'fixed', + count: 2, + perTurn: true, + }], + idealRole: 'Juggernaut', + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 61 }], + requirements: 'Minimum 3 units. At least 3 heavy or assault. No light units. All armor ≥ 135 points. 75% must deal 25+ damage at 7 hexes. At least 1 Juggernaut or 2 Snipers.', + }, + alphaStrike: { + effectDescription: 'At the beginning of play, choose either Demoralizer or Multi-Tasker SPA. Each turn, designate up to half the units (rounded down) to receive the chosen ability for that turn. Destroyed or withdrawn units do not count.', + effectGroups: [{ + abilityIds: ['demoralizer', 'multi_tasker'], + selection: 'choose-one', + distribution: 'half-round-down', + perTurn: true, + }], + idealRole: 'Juggernaut', + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 118 }], + requirements: 'Minimum 3 units. At least 3 units Size 3+. No Size 1 units. All armor ≥ 5. 75% must have medium-range damage ≥ 3. At least 1 Juggernaut or 2 Snipers.', }, }, @@ -169,23 +188,22 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'anvil-lance', name: 'Anvil', description: 'A tough Marik formation that holds the enemy\'s attention and stops its advance while Hammer units maneuver.', - effectDescription: 'At the beginning of each turn, up to two units in this formation may receive the Cluster Hitter or Sandblaster SPA. The player may assign the same SPA to both units, or one Sandblaster and the other Cluster Hitter.', - effectGroups: [{ - abilityIds: ['cluster_hitter', 'sandblaster'], - selection: 'choose-each', - distribution: 'fixed', - count: 2, - perTurn: true, - }], exclusiveFaction: ['Free Worlds League'], - idealRole: 'Juggernaut', - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 62 }], - requirements: (gameSystem) => { - if (gameSystem === GameSystem.ALPHA_STRIKE) { - return 'Minimum 3 units. Free Worlds League only. All Size 2+. All armor ≥ 4. 50% must have AC, FLK, LRM, or SRM specials.'; - } - return 'Minimum 3 units. Free Worlds League only. All medium or heavier. All armor ≥ 105 points. 50% must have autocannons, LRMs, or SRMs.'; + classic: { + effectDescription: 'At the beginning of each turn, up to two units in this formation may receive the Cluster Hitter or Sandblaster SPA. The player may assign the same SPA to both units, or one Sandblaster and the other Cluster Hitter.', + effectGroups: [{ abilityIds: ['cluster_hitter', 'sandblaster'], selection: 'choose-each', distribution: 'fixed', count: 2, perTurn: true }], + idealRole: 'Juggernaut', + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 62 }], + requirements: 'Minimum 3 units. Free Worlds League only. All medium or heavier. All armor ≥ 105 points. 50% must have autocannons, LRMs, or SRMs.', + }, + alphaStrike: { + effectDescription: 'At the beginning of each turn, up to two units in this formation may receive the Cluster Hitter or Sandblaster SPA. The player may assign the same SPA to both units, or one Sandblaster and the other Cluster Hitter.', + effectGroups: [{ abilityIds: ['cluster_hitter', 'sandblaster'], selection: 'choose-each', distribution: 'fixed', count: 2, perTurn: true }], + idealRole: 'Juggernaut', + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 62 }], + requirements: 'Minimum 3 units. Free Worlds League only. All Size 2+. All armor ≥ 4. 50% must have AC, FLK, LRM, or SRM specials.', }, }, @@ -199,20 +217,20 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ parent: 'assault-lance', name: 'Fast Assault', description: 'A mobile assault variant built to close faster and keep pressure on the enemy.', - effectDescription: 'In addition to the Assault Lance bonus, up to 2 units per Fast Assault Lance may receive the Stand Aside SPA per turn. These may stack with the Demoralizer or Multi-Tasker abilities.', inheritParentEffects: true, - effectGroups: [{ - abilityIds: ['stand_aside'], - selection: 'all', - distribution: 'fixed', - count: 2, - perTurn: true, - }], - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 62 }, { book: Rulebook.ASCE, page: 118 }], - requirements: (gameSystem) => { - const move = gameSystem === GameSystem.ALPHA_STRIKE ? '[[10]]+ or any jump capability' : 'walk ≥ 5 or jump > 0'; - return `Must meet Assault Lance requirements. All units must have ${move}.`; + classic: { + effectDescription: 'In addition to the Assault Lance bonus, at the beginning of each turn up to two units may receive the Stand Aside SPA. These need not be the same units receiving Demoralizer or Multi-Tasker.', + effectGroups: [{ abilityIds: ['stand_aside'], selection: 'all', distribution: 'fixed', count: 2, perTurn: true }], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 62 }], + requirements: 'Must meet Assault Lance requirements. All units must have walk ≥ 5 or jump > 0.', + }, + alphaStrike: { + effectDescription: 'In addition to the Assault Lance bonus, up to two units per Fast Assault Lance may receive the Stand Aside SPA per turn. These may stack with the Demoralizer or Multi-Tasker abilities.', + effectGroups: [{ abilityIds: ['stand_aside'], selection: 'all', distribution: 'fixed', count: 2, perTurn: true }], + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 118 }], + requirements: 'Must meet Assault Lance requirements. All units must have [[10]]+ or any jump capability.', }, }, @@ -225,48 +243,69 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'hunter-lance', name: 'Hunter', description: 'Ambush specialists that prefer heavy woods or urban terrain, where they can strike and destroy enemy forces.', - effectDescription: 'At the beginning of each turn, 50 percent of the units in the formation may be granted the Combat Intuition SPA.', - effectGroups: [{ - abilityIds: ['combat_intuition'], - selection: 'all', - distribution: 'up-to-50-percent', - perTurn: true, - }], - idealRole: 'Ambusher', - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 62 }, { book: Rulebook.FMD, page: 82 }], - requirements: () => 'Minimum 3 units. At least 50% must have the Ambusher or Juggernaut role.', + classic: { + effectDescription: 'At the beginning of each turn, 50 percent of the units in the formation may be granted the Combat Intuition SPA.', + effectGroups: [{ abilityIds: ['combat_intuition'], selection: 'all', distribution: 'up-to-50-percent', perTurn: true }], + idealRole: 'Ambusher', + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 62 }, { book: Rulebook.FMD, page: 82 }], + requirements: 'Minimum 3 units. At least 50% must have the Ambusher or Juggernaut role.', + }, + alphaStrike: { + effectDescription: 'At the beginning of each turn, 50 percent of the units in the formation may be granted the Combat Intuition SPA.', + effectGroups: [{ abilityIds: ['combat_intuition'], selection: 'all', distribution: 'up-to-50-percent', perTurn: true }], + idealRole: 'Ambusher', + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 62 }, { book: Rulebook.FMD, page: 82 }], + requirements: 'Minimum 3 units. At least 50% must have the Ambusher or Juggernaut role.', + }, }, // ─── Battle Lance ──────────────────────────────────────────────────── // // Requirements: 50% heavy+. 3+ Brawler/Sniper/Skirmisher. // Vehicle formations need 2 matched pairs of heavy units. - // Bonus: Lucky SPA shared pool (units at setup + 2). Max 4 rerolls per unit. + // Bonus: Classic grants a fixed 6-point Lucky pool; Alpha Strike uses the + // number of units at setup + 2. Both cap each unit at 4 rerolls. // { id: 'battle-lance', name: 'Battle', description: 'Line troops that hold the center or support an assault, relying on armor, mass, and sustained firepower to close with the enemy.', - effectDescription: 'The formation receives a Lucky SPA as a level equal to the number of units in the formation at setup plus 2. Useable by any unit in the formation. May stack with individual Lucky SPA (max 4 rerolls per unit per scenario).', - effectGroups: [{ - abilityIds: ['lucky'], - selection: 'all', - distribution: 'shared-pool', - sharedPool: { - level: { kind: 'unit-count-plus', offset: 2 }, - maxUsesPerUnitPerScenario: 4, - stacksWithIndividualAbility: true, - }, - }], - idealRole: 'Brawler', - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 62 }, { book: Rulebook.ASCE, page: 117 }], - requirements: (gameSystem) => { - if (gameSystem === GameSystem.ALPHA_STRIKE) { - return 'Minimum 3 units. 50% must be Size 3+. At least 3 Brawler, Sniper, or Skirmisher roles. Vehicle formations require 2 matched pairs of Size 3+ units.'; - } - return `Minimum 3 units. 50% must be heavy or assault. At least 3 Brawler, Sniper, or Skirmisher roles. Vehicle formations require 2 matched pairs of heavy units.`; + classic: { + effectDescription: 'The formation receives the equivalent of a 6-point Lucky SPA, usable by any unit in the formation for up to six rerolls. It may stack with individual Lucky SPAs, but each unit is limited to four rerolls per scenario.', + effectGroups: [{ + abilityIds: ['lucky'], + selection: 'all', + distribution: 'shared-pool', + sharedPool: { + level: { kind: 'fixed', value: 6 }, + totalUsesPerScenario: 6, + maxUsesPerUnitPerScenario: 4, + stacksWithIndividualAbility: true, + }, + }], + idealRole: 'Brawler', + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 62 }], + requirements: 'Minimum 3 units. 50% must be heavy or assault. At least 3 Brawler, Sniper, or Skirmisher roles. Vehicle formations require 2 matched pairs of heavy units.', + }, + alphaStrike: { + effectDescription: 'The formation receives a Lucky SPA as a level equal to the number of units in the formation at setup plus 2. Usable by any unit in the formation. May stack with individual Lucky SPA (max 4 rerolls per unit per scenario).', + effectGroups: [{ + abilityIds: ['lucky'], + selection: 'all', + distribution: 'shared-pool', + sharedPool: { + level: { kind: 'unit-count-plus', offset: 2 }, + maxUsesPerUnitPerScenario: 4, + stacksWithIndividualAbility: true, + }, + }], + idealRole: 'Brawler', + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 117 }], + requirements: 'Minimum 3 units. 50% must be Size 3+. At least 3 Brawler, Sniper, or Skirmisher roles.', }, }, @@ -277,24 +316,19 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'light-battle-lance', name: 'Light Battle', description: 'A light Battle variant for fast reconnaissance and skirmishing, relying on speed and coordinated fire rather than mass.', - effectDescription: 'The formation receives a Lucky SPA as a level equal to the number of units in the formation at setup plus 2. Useable by any unit in the formation. May stack with individual Lucky SPA (max 4 rerolls per unit per scenario).', - effectGroups: [{ - abilityIds: ['lucky'], - selection: 'all', - distribution: 'shared-pool', - sharedPool: { - level: { kind: 'unit-count-plus', offset: 2 }, - maxUsesPerUnitPerScenario: 4, - stacksWithIndividualAbility: true, - }, - }], - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 62 }, { book: Rulebook.ASCE, page: 118 }], - requirements: (gameSystem) => { - if (gameSystem === GameSystem.ALPHA_STRIKE) { - return 'Minimum 3 units. 75% must be Size 1. No Size 4+ units. At least 1 Scout. Vehicle formations require 2 matched pairs of Size 1 units.'; - } - return 'Minimum 3 units. 75% must be light. No assault units. At least 1 Scout. Vehicle formations require 2 matched pairs of light units.'; + classic: { + effectDescription: 'The formation receives the equivalent of a 6-point Lucky SPA, usable by any unit in the formation for up to six rerolls. It may stack with individual Lucky SPAs, but each unit is limited to four rerolls per scenario.', + effectGroups: [{ abilityIds: ['lucky'], selection: 'all', distribution: 'shared-pool', sharedPool: { level: { kind: 'fixed', value: 6 }, totalUsesPerScenario: 6, maxUsesPerUnitPerScenario: 4, stacksWithIndividualAbility: true } }], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 63 }], + requirements: 'Minimum 3 units. 75% must be light. No assault units. At least 1 Scout. Vehicle formations require 2 matched pairs of light units.', + }, + alphaStrike: { + effectDescription: 'The formation receives a Lucky SPA as a level equal to the number of units in the formation at setup plus 2. Usable by any unit in the formation. May stack with individual Lucky SPA (max 4 rerolls per unit per scenario).', + effectGroups: [{ abilityIds: ['lucky'], selection: 'all', distribution: 'shared-pool', sharedPool: { level: { kind: 'unit-count-plus', offset: 2 }, maxUsesPerUnitPerScenario: 4, stacksWithIndividualAbility: true } }], + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 118 }], + requirements: 'Minimum 3 units. 75% must be Size 1. No Size 4+ units. At least 1 Scout. Vehicle formations require 2 matched pairs of Size 1 units.', }, }, @@ -305,24 +339,19 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'medium-battle-lance', name: 'Medium Battle', description: 'A balanced Battle variant that combines medium-unit mobility with enough armor and firepower for the line.', - effectDescription: 'The formation receives a Lucky SPA as a level equal to the number of units in the formation at setup plus 2. Useable by any unit in the formation. May stack with individual Lucky SPA (max 4 rerolls per unit per scenario).', - effectGroups: [{ - abilityIds: ['lucky'], - selection: 'all', - distribution: 'shared-pool', - sharedPool: { - level: { kind: 'unit-count-plus', offset: 2 }, - maxUsesPerUnitPerScenario: 4, - stacksWithIndividualAbility: true, - }, - }], - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 62 }, { book: Rulebook.ASCE, page: 118 }], - requirements: (gameSystem) => { - if (gameSystem === GameSystem.ALPHA_STRIKE) { - return 'Minimum 3 units. 50% must be Size 2. No Size 4+ units. Vehicle formations require 2 matched pairs of Size 2 units.'; - } - return 'Minimum 3 units. 50% must be medium. No assault units. Vehicle formations require 2 matched pairs of medium units.'; + classic: { + effectDescription: 'The formation receives the equivalent of a 6-point Lucky SPA, usable by any unit in the formation for up to six rerolls. It may stack with individual Lucky SPAs, but each unit is limited to four rerolls per scenario.', + effectGroups: [{ abilityIds: ['lucky'], selection: 'all', distribution: 'shared-pool', sharedPool: { level: { kind: 'fixed', value: 6 }, totalUsesPerScenario: 6, maxUsesPerUnitPerScenario: 4, stacksWithIndividualAbility: true } }], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 63 }], + requirements: 'Minimum 3 units. 50% must be medium. No assault units. Vehicle formations require 2 matched pairs of medium units.', + }, + alphaStrike: { + effectDescription: 'The formation receives a Lucky SPA as a level equal to the number of units in the formation at setup plus 2. Usable by any unit in the formation. May stack with individual Lucky SPA (max 4 rerolls per unit per scenario).', + effectGroups: [{ abilityIds: ['lucky'], selection: 'all', distribution: 'shared-pool', sharedPool: { level: { kind: 'unit-count-plus', offset: 2 }, maxUsesPerUnitPerScenario: 4, stacksWithIndividualAbility: true } }], + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 118 }], + requirements: 'Minimum 3 units. 50% must be Size 2. No Size 4+ units. Vehicle formations require 2 matched pairs of Size 2 units.', }, }, @@ -333,24 +362,19 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'heavy-battle-lance', name: 'Heavy Battle', description: 'A heavy Battle variant that brings durable line-fighting power to heavily armored units.', - effectDescription: 'The formation receives a Lucky SPA as a level equal to the number of units in the formation at setup plus 2. Useable by any unit in the formation. May stack with individual Lucky SPA (max 4 rerolls per unit per scenario).', - effectGroups: [{ - abilityIds: ['lucky'], - selection: 'all', - distribution: 'shared-pool', - sharedPool: { - level: { kind: 'unit-count-plus', offset: 2 }, - maxUsesPerUnitPerScenario: 4, - stacksWithIndividualAbility: true, - }, - }], - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 63 }, { book: Rulebook.ASCE, page: 118 }], - requirements: (gameSystem) => { - if (gameSystem === GameSystem.ALPHA_STRIKE) { - return 'Minimum 3 units. 50% must be Size 3+. No Size 1 units. Vehicle formations require 2 matched pairs of Size 3+ units.'; - } - return 'Minimum 3 units. 50% must be heavy or assault. No light units. Vehicle formations require 2 matched pairs of heavy units.'; + classic: { + effectDescription: 'The formation receives the equivalent of a 6-point Lucky SPA, usable by any unit in the formation for up to six rerolls. It may stack with individual Lucky SPAs, but each unit is limited to four rerolls per scenario.', + effectGroups: [{ abilityIds: ['lucky'], selection: 'all', distribution: 'shared-pool', sharedPool: { level: { kind: 'fixed', value: 6 }, totalUsesPerScenario: 6, maxUsesPerUnitPerScenario: 4, stacksWithIndividualAbility: true } }], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 63 }], + requirements: 'Minimum 3 units. 50% must be heavy or assault. No light units. Vehicle formations require 2 matched pairs of heavy units.', + }, + alphaStrike: { + effectDescription: 'The formation receives a Lucky SPA as a level equal to the number of units in the formation at setup plus 2. Usable by any unit in the formation. May stack with individual Lucky SPA (max 4 rerolls per unit per scenario).', + effectGroups: [{ abilityIds: ['lucky'], selection: 'all', distribution: 'shared-pool', sharedPool: { level: { kind: 'unit-count-plus', offset: 2 }, maxUsesPerUnitPerScenario: 4, stacksWithIndividualAbility: true } }], + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 118 }], + requirements: 'Minimum 3 units. 50% must be Size 3+. No Size 1 units. Vehicle formations require 2 matched pairs of Size 3+ units.', }, }, @@ -362,22 +386,20 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'rifle-lance', name: 'Rifle', description: 'Davion autocannon specialists trained to coordinate accurate long-range fire.', - effectDescription: 'At the beginning of each turn, up to two units in this formation may receive either the Sandblaster or Weapon Specialist SPA. The player may assign the same SPA to both units, or one Weapon Specialist and the other Sandblaster.', - effectGroups: [{ - abilityIds: ['sandblaster', 'weapon_specialist'], - selection: 'choose-each', - distribution: 'fixed', - count: 2, - perTurn: true, - }], exclusiveFaction: ['Federated Suns', 'Federated Commonwealth'], - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 63 }, { book: Rulebook.FMD, page: 82 }], - requirements: (gameSystem) => { - if (gameSystem === GameSystem.ALPHA_STRIKE) { - return 'Minimum 3 units. Federated Suns only. 75% must be Size 2-3. 50% must have AC or FLK special. All units Move [[8]]+.'; - } - return 'Minimum 3 units. Federated Suns only. 75% must be medium or heavy. 50% must have autocannons (including LB-X, Ultra, or Rotary). All units walk ≥ 4.'; + classic: { + effectDescription: 'At the beginning of each turn, up to two units in this formation may receive either the Sandblaster or Weapon Specialist SPA. The player may assign the same SPA to both units, or one Weapon Specialist and the other Sandblaster.', + effectGroups: [{ abilityIds: ['sandblaster', 'weapon_specialist'], selection: 'choose-each', distribution: 'fixed', count: 2, perTurn: true }], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 63 }, { book: Rulebook.FMD, page: 82 }], + requirements: 'Minimum 3 units. Federated Suns only. 75% must be medium or heavy. 50% must have autocannons (including LB-X, Ultra, or Rotary). All units walk ≥ 4.', + }, + alphaStrike: { + effectDescription: 'At the beginning of each turn, up to two units in this formation may receive either the Sandblaster or Weapon Specialist SPA. The player may assign the same SPA to both units, or one Weapon Specialist and the other Sandblaster.', + effectGroups: [{ abilityIds: ['sandblaster', 'weapon_specialist'], selection: 'choose-each', distribution: 'fixed', count: 2, perTurn: true }], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 63 }, { book: Rulebook.FMD, page: 82 }], + requirements: 'Minimum 3 units. Federated Suns only. 75% must be Size 2-3. 50% must have AC or FLK special. All units Move [[8]]+.', }, }, @@ -392,48 +414,52 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ name: 'Berserker/Close Combat', nameAliases: ['Berserker', 'Close Combat'], description: 'Close-combat specialists made famous by Rasalhague Regulars and the KungsArmé, trained to smash the enemy with BattleMech strength.', - effectDescription: 'Two units in this formation receive the Swordsman or Zweihander SPA. The same ability must be assigned to both units.', - effectGroups: [{ - abilityIds: ['swordsman', 'zweihander'], - selection: 'choose-one', - distribution: 'fixed', - count: 2, - }], - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 63 }, { book: Rulebook.FMK, page: 87 }], - requirements: (gameSystem) => { - return 'Must meet Battle Lance requirements.'; + classic: { + effectDescription: 'Two units in this formation receive the Swordsman or Zweihander SPA. The same ability must be assigned to both units.', + effectGroups: [{ abilityIds: ['swordsman', 'zweihander'], selection: 'choose-one', distribution: 'fixed', count: 2 }], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 63 }, { book: Rulebook.FMK, page: 87 }], + requirements: 'Must meet Battle Lance requirements.', + }, + alphaStrike: { + effectDescription: 'Two units in this formation receive the Swordsman or Zweihander SPA. The same ability must be assigned to both units.', + effectGroups: [{ abilityIds: ['swordsman', 'zweihander'], selection: 'choose-one', distribution: 'fixed', count: 2 }], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 63 }, { book: Rulebook.FMK, page: 87 }], + requirements: 'Must meet Battle Lance requirements.', }, }, // ─── Command Lance ─────────────────────────────────────────────────── // - // Bonus: Two non-commander units get one free SPA each (Antagonizer, - // Blood Stalker, Combat Intuition, Eagle's Eyes, Marksman, Multi-Tasker). - // Commander gets Tactical Genius. + // Bonus: Classic grants one SPA each to 2 non-commanders; Alpha Strike + // grants one SPA each to half the formation (round up). The commander + // also gets Tactical Genius. // { id: 'command-lance', name: 'Command', description: 'A command-centered formation with diverse capabilities intended to support and protect its leader.', - effectDescription: 'Prior to the beginning of play, two of the non-commander units in this formation receive one of the following Special Pilot Abilities for free (each unit may receive a different SPA): Antagonizer, Combat Intuition, Blood Stalker, Eagle\'s Eyes, Marksman, or Multi-Tasker. In addition, the commander\'s unit receives the Tactical Genius SPA. If the commander already has the Tactical Genius SPA, instead add a +1 modifier to the force\'s Initiative roll results, including any rerolls made as a result of the Tactical Genius SPA.', - effectGroups: [ - { - abilityIds: ['antagonizer', 'blood_stalker', 'combat_intuition', 'eagles_eyes', 'marksman', 'multi_tasker'], - selection: 'choose-each', - distribution: 'fixed', - count: 2, - excludeCommander: true, - }, - { - abilityIds: ['tactical_genius'], - selection: 'all', - distribution: 'commander', - }, - ], - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 63 }, { book: Rulebook.ASCE, page: 120 }], - requirements: () => 'Minimum 3 units. 50% must have Sniper, Missile Boat, Skirmisher, or Juggernaut role. At least 1 Brawler, Striker, or Scout.', + classic: { + effectDescription: 'Prior to the beginning of play, two non-commander units each receive one of these SPAs (each may choose differently): Antagonizer, Blood Stalker, Combat Intuition, Eagle\'s Eyes, Marksman, or Multi-Tasker. The commander receives Tactical Genius; if the commander already has it, instead add +1 to the force\'s Initiative results, including Tactical Genius rerolls.', + effectGroups: [ + { abilityIds: ['antagonizer', 'blood_stalker', 'combat_intuition', 'eagles_eyes', 'marksman', 'multi_tasker'], selection: 'choose-each', distribution: 'fixed', count: 2, excludeCommander: true }, + { abilityIds: ['tactical_genius'], selection: 'all', distribution: 'commander' }, + ], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 63 }], + requirements: 'Minimum 3 units. 50% must have Sniper, Missile Boat, Skirmisher, or Juggernaut role. At least 1 additional Brawler, Striker, or Scout.', + }, + alphaStrike: { + effectDescription: 'Prior to the beginning of play, half the units in this formation (round up) each receive one of these SPAs (each may choose differently): Antagonizer, Blood Stalker, Combat Intuition, Eagle\'s Eyes, Marksman, or Multi-Tasker. In addition, the commander receives Tactical Genius; if the commander already has it, instead add +1 to the force\'s Initiative results, including Tactical Genius rerolls.', + effectGroups: [ + { abilityIds: ['antagonizer', 'blood_stalker', 'combat_intuition', 'eagles_eyes', 'marksman', 'multi_tasker'], selection: 'choose-each', distribution: 'half-round-up' }, + { abilityIds: ['tactical_genius'], selection: 'all', distribution: 'commander' }, + ], + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 120 }], + requirements: 'Minimum 3 units. 50% must have Sniper, Missile Boat, Skirmisher, or Juggernaut role. At least 1 additional Brawler, Striker, or Scout.', + }, }, // @@ -445,25 +471,26 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'order-lance', name: 'Order', description: 'Highly organized Kurita units trained to operate as a synchronized whole.', - effectDescription: 'Designate one unit as the formation\'s commander; that unit receives the Tactical Genius, Antagonizer, or Sniper SPA. All units in the formation receive the Iron Will or Speed Demon SPA; the entire formation must select the same ability.', - effectGroups: [ - { - abilityIds: ['tactical_genius', 'antagonizer', 'sniper'], - selection: 'choose-one', - distribution: 'commander', - }, - { - abilityIds: ['iron_will', 'speed_demon'], - selection: 'choose-one', - distribution: 'all', - }, - ], exclusiveFaction: ['Draconis Combine'], - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 63 }, { book: Rulebook.FMK, page: 87 }], - requirements: (gameSystem) => { - const tier = gameSystem === GameSystem.ALPHA_STRIKE ? 'Size' : 'weight'; - return `Minimum 3 units. Draconis Combine only. All units must share the same ${tier} class and chassis.`; + classic: { + effectDescription: 'Designate one unit as the formation\'s commander; that unit receives the Tactical Genius, Antagonizer, or Sniper SPA. All units in the formation receive the Iron Will or Speed Demon SPA; the entire formation must select the same ability.', + effectGroups: [ + { abilityIds: ['tactical_genius', 'antagonizer', 'sniper'], selection: 'choose-one', distribution: 'commander' }, + { abilityIds: ['iron_will', 'speed_demon'], selection: 'choose-one', distribution: 'all' }, + ], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 63 }, { book: Rulebook.FMK, page: 87 }], + requirements: 'Minimum 3 units. Draconis Combine only. All units must share the same weight class and chassis.', + }, + alphaStrike: { + effectDescription: 'Designate one unit as the formation\'s commander; that unit receives the Tactical Genius, Antagonizer, or Sniper SPA. All units in the formation receive the Iron Will or Speed Demon SPA; the entire formation must select the same ability.', + effectGroups: [ + { abilityIds: ['tactical_genius', 'antagonizer', 'sniper'], selection: 'choose-one', distribution: 'commander' }, + { abilityIds: ['iron_will', 'speed_demon'], selection: 'choose-one', distribution: 'all' }, + ], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 63 }, { book: Rulebook.FMK, page: 87 }], + requirements: 'Minimum 3 units. Draconis Combine only. All units must share the same Size class and chassis.', }, }, @@ -473,47 +500,55 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ { id: 'vehicle-command-lance', name: 'Vehicle Command', - description: 'A vehicle command variant built around a designated commander and one matched pair of vehicles with a qualifying combat role.', - effectDescription: 'Prior to the beginning of play, two of the non-commander units in this formation receive one of the following Special Pilot Abilities for free (each unit may receive a different SPA): Antagonizer, Combat Intuition, Blood Stalker, Eagle\'s Eyes, Marksman, or Multi-Tasker. In addition, the commander\'s unit receives the Tactical Genius SPA. If the commander already has the Tactical Genius SPA, instead add a +1 modifier to the force\'s Initiative roll results, including any rerolls made as a result of the Tactical Genius SPA.', - effectGroups: [ - { - abilityIds: ['antagonizer', 'blood_stalker', 'combat_intuition', 'eagles_eyes', 'marksman', 'multi_tasker'], - selection: 'choose-each', - distribution: 'half-round-up', - excludeCommander: true, - }, - { - abilityIds: ['tactical_genius'], - selection: 'all', - distribution: 'commander', - }, - ], - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 63 }, { book: Rulebook.ASCE, page: 120 }], - requirements: () => 'Minimum 3 units. All must be combat vehicles. At least one matched pair with Sniper, Missile Boat, Skirmisher, or Juggernaut role.', + description: 'A vehicle command variant built around a designated commander and two vehicles with qualifying combat roles.', + classic: { + effectDescription: 'As the standard Command Lance: two non-commander units each receive one eligible SPA, and the commander receives Tactical Genius (or the Initiative bonus if it already has Tactical Genius).', + effectGroups: [ + { abilityIds: ['antagonizer', 'blood_stalker', 'combat_intuition', 'eagles_eyes', 'marksman', 'multi_tasker'], selection: 'choose-each', distribution: 'fixed', count: 2, excludeCommander: true }, + { abilityIds: ['tactical_genius'], selection: 'all', distribution: 'commander' }, + ], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 63 }], + requirements: 'Minimum 3 units. All must be combat vehicles. At least two units must have the Sniper, Missile Boat, Skirmisher, or Juggernaut role.', + }, + alphaStrike: { + effectDescription: 'As the standard Command Lance: half the units (round up) each receive one eligible SPA, and the commander receives Tactical Genius (or the Initiative bonus if it already has Tactical Genius).', + effectGroups: [ + { abilityIds: ['antagonizer', 'blood_stalker', 'combat_intuition', 'eagles_eyes', 'marksman', 'multi_tasker'], selection: 'choose-each', distribution: 'half-round-up' }, + { abilityIds: ['tactical_genius'], selection: 'all', distribution: 'commander' }, + ], + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 120 }], + requirements: 'Minimum 3 units. All must be combat vehicles. At least two units must have the Sniper, Missile Boat, Skirmisher, or Juggernaut role.', + }, }, // ─── Fire Lance ────────────────────────────────────────────────────── // // 75% Missile Boat or Sniper roles. - // Bonus: Up to 2 units per turn get Sniper SPA. + // Bonus: Classic grants Sniper to up to 2 units per turn; Alpha Strike + // grants it to up to half (round down). // { id: 'fire-lance', name: 'Fire', description: 'Long-range firepower specialists that stay clear of the enemy while raining down destructive attacks.', - effectDescription: 'At the beginning of each turn, up to two units in this formation may receive the Sniper SPA, which will affect their weapon attacks during that turn.', - effectGroups: [{ - abilityIds: ['sniper'], - selection: 'all', - distribution: 'fixed', - count: 2, - perTurn: true, - }], - idealRole: 'Missile Boat', - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 64 }, { book: Rulebook.ASCE, page: 119 }], - requirements: () => 'Minimum 3 units. 75% must have the Missile Boat or Sniper role.', + classic: { + effectDescription: 'At the beginning of each turn, up to two units in this formation may receive the Sniper SPA, which affects their weapon attacks during that turn.', + effectGroups: [{ abilityIds: ['sniper'], selection: 'all', distribution: 'fixed', count: 2, perTurn: true }], + idealRole: 'Missile Boat', + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 64 }], + requirements: 'Minimum 3 units. 75% must have the Missile Boat or Sniper role.', + }, + alphaStrike: { + effectDescription: 'At the beginning of each turn, up to half the units (rounded down) may receive the Sniper SPA, which affects their weapon attacks during that turn. Destroyed or withdrawn units do not count.', + effectGroups: [{ abilityIds: ['sniper'], selection: 'all', distribution: 'half-round-down', perTurn: true }], + idealRole: 'Missile Boat', + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 119 }], + requirements: 'Minimum 3 units. 75% must have the Missile Boat or Sniper role.', + }, }, // @@ -525,21 +560,19 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ parent: 'fire-lance', name: 'Anti-Air', description: 'A Fire Lance variant specializing in engaging airborne threats with dedicated anti-air capabilities.', - effectDescription: 'At the beginning of each turn, up to two units in this formation may receive the Anti-Aircraft Specialist Special Command Ability. This will affect the weapon attacks made by the designated units during that turn.', - effectGroups: [{ - commandAbilityIds: ['anti_aircraft_specialists'], - selection: 'all', - distribution: 'fixed', - count: 2, - perTurn: true, - }], - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 64 }, { book: Rulebook.ASCE, page: 119 }], - requirements: (gameSystem) => { - if (gameSystem === GameSystem.ALPHA_STRIKE) { - return 'Minimum 3 units. Must meet Fire Lance requirements. At least 2 units with FLK, AC, or ART specials.'; - } - return 'Minimum 3 units. Must meet Fire Lance requirements. At least 2 units with an LBX autocannon, standard autocannon, artillery weapon, or Anti-Aircraft Targeting quirk.'; + classic: { + effectDescription: 'At the beginning of each turn, up to two units may receive the Anti-Aircraft Specialist Special Command Ability, which affects their weapon attacks during that turn.', + effectGroups: [{ commandAbilityIds: ['anti_aircraft_specialists'], selection: 'all', distribution: 'fixed', count: 2, perTurn: true }], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 64 }], + requirements: 'Minimum 3 units. Must meet Fire Lance requirements. At least 2 units with an LBX autocannon, standard autocannon, artillery weapon, or Anti-Aircraft Targeting quirk.', + }, + alphaStrike: { + effectDescription: 'At the beginning of each turn, up to half the units (rounded down) may receive the Anti-Aircraft Specialists Special Command Ability, which affects their weapon attacks during that turn. Destroyed or withdrawn units do not count.', + effectGroups: [{ commandAbilityIds: ['anti_aircraft_specialists'], selection: 'all', distribution: 'half-round-down', perTurn: true }], + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 119 }], + requirements: 'Minimum 3 units. Must meet Fire Lance requirements. At least 2 units with FLK, AC, or ART specials.', }, }, @@ -551,19 +584,19 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'artillery-fire-lance', name: 'Artillery Fire', description: 'A Fire Lance variant built to coordinate artillery attacks from a protected distance.', - effectDescription: 'At the beginning of each turn, up to two units in this formation may receive the Oblique Artilleryman Special Pilot Ability, which will affect their artillery weapon attacks made during that turn.', - effectGroups: [{ - abilityIds: ['oblique_artilleryman'], - selection: 'all', - distribution: 'fixed', - count: 2, - perTurn: true, - }], - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 64 }, { book: Rulebook.ASCE, page: 119 }], - requirements: (gameSystem) => { - const artillery = gameSystem === GameSystem.ALPHA_STRIKE ? 'the ART special' : 'artillery weapons'; - return `Minimum 3 units. At least 2 units with ${artillery}.`; + classic: { + effectDescription: 'At the beginning of each turn, up to two units may receive the Oblique Artilleryman SPA, which affects their artillery weapon attacks during that turn.', + effectGroups: [{ abilityIds: ['oblique_artilleryman'], selection: 'all', distribution: 'fixed', count: 2, perTurn: true }], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 64 }], + requirements: 'Minimum 3 units. At least 2 units with artillery weapons.', + }, + alphaStrike: { + effectDescription: 'At the beginning of each turn, up to half the units (rounded down) may receive the Oblique Artilleryman SPA, which affects their artillery weapon attacks during that turn. Destroyed or withdrawn units do not count.', + effectGroups: [{ abilityIds: ['oblique_artilleryman'], selection: 'all', distribution: 'half-round-down', perTurn: true }], + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 119 }], + requirements: 'Minimum 3 units. At least 2 units with the ART special.', }, }, @@ -575,21 +608,19 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'direct-fire-lance', name: 'Direct Fire', description: 'Heavy direct-fire specialists that concentrate powerful attacks on priority targets.', - effectDescription: 'At the beginning of each turn, up to two units in this formation may receive the Weapon Specialist SPA. This ability will affect the weapon attacks made by the designated units during that turn.', - effectGroups: [{ - abilityIds: ['weapon_specialist'], - selection: 'all', - distribution: 'fixed', - count: 2, - perTurn: true, - }], - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 64 }, { book: Rulebook.ASCE, page: 119 }], - requirements: (gameSystem) => { - if (gameSystem === GameSystem.ALPHA_STRIKE) { - return 'Minimum 3 units. At least 2 Size 3+ units. All units must have long-range damage ≥ 2.'; - } - return 'Minimum 3 units. At least 2 heavy or assault units. All units must deal 10+ damage at 18 hexes.'; + classic: { + effectDescription: 'At the beginning of each turn, up to two units may receive the Weapon Specialist SPA, which affects their weapon attacks during that turn.', + effectGroups: [{ abilityIds: ['weapon_specialist'], selection: 'all', distribution: 'fixed', count: 2, perTurn: true }], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 64 }], + requirements: 'Minimum 3 units. At least 2 heavy or assault units. All units must deal 10+ damage at 18 hexes.', + }, + alphaStrike: { + effectDescription: 'At the beginning of each turn, up to half the units (rounded down) may receive the Weapon Specialist SPA, which affects their weapon attacks during that turn. Destroyed or withdrawn units do not count.', + effectGroups: [{ abilityIds: ['weapon_specialist'], selection: 'all', distribution: 'half-round-down', perTurn: true }], + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 119 }], + requirements: 'Minimum 3 units. At least 2 Size 3+ units. All units must have long-range damage ≥ 2.', }, }, @@ -601,20 +632,19 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'fire-support-lance', name: 'Fire Support', description: 'Indirect-fire specialists that coordinate artillery support for the rest of the force.', - effectDescription: 'At the beginning of each turn, up to two units in this formation may receive the Oblique Attacker Special Pilot Ability, which will affect their indirect weapon attacks during that turn.', - effectGroups: [{ - abilityIds: ['oblique_attacker'], - selection: 'all', - distribution: 'fixed', - count: 2, - perTurn: true, - }], - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 64 }, { book: Rulebook.ASCE, page: 119 }], - requirements: (gameSystem) => { - const indirectFire = gameSystem === GameSystem.ALPHA_STRIKE ? 'the IF (Indirect Fire) special' : 'LRMs or artillery'; - - return `Minimum 3 units. At least 3 units with ${indirectFire}.`; + classic: { + effectDescription: 'At the beginning of each turn, up to two units may receive the Oblique Attacker SPA, which affects their indirect weapon attacks during that turn.', + effectGroups: [{ abilityIds: ['oblique_attacker'], selection: 'all', distribution: 'fixed', count: 2, perTurn: true }], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 64 }], + requirements: 'Minimum 3 units. At least 3 units with weapons capable of indirect fire.', + }, + alphaStrike: { + effectDescription: 'At the beginning of each turn, up to half the units (rounded down) may receive the Oblique Attacker SPA, which affects their indirect weapon attacks during that turn. Destroyed or withdrawn units do not count.', + effectGroups: [{ abilityIds: ['oblique_attacker'], selection: 'all', distribution: 'half-round-down', perTurn: true }], + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 119 }], + requirements: 'Minimum 3 units. At least 3 units with the IF (Indirect Fire) special.', }, }, @@ -626,12 +656,17 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'light-fire-lance', name: 'Light Fire', description: 'Light units trained to combine their fire so they can threaten targets too large for any one unit.', - effectDescription: 'Coordinated Fire Support: If a unit in this formation hits a target with at least one of its weapons, other units in this formation making weapon attacks against the same target receive a -1 modifier to their attack rolls. This bonus is cumulative per attacking unit, up to a -3 To-Hit modifier.', - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 64 }, { book: Rulebook.FMD, page: 82 }], - requirements: (gameSystem) => { - const noHeavy = gameSystem === GameSystem.ALPHA_STRIKE ? 'Size 3+' : 'heavy or assault'; - return `Minimum 3 units. No ${noHeavy} units. 50% must have the Missile Boat or Sniper role.`; + classic: { + effectDescription: 'Coordinated Fire Support: If a unit in this formation hits a target with at least one weapon, other units attacking the same target receive a -1 modifier to their attack rolls. This is cumulative per attacking unit, to a maximum -3 To-Hit modifier.', + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 64 }, { book: Rulebook.FMD, page: 82 }], + requirements: 'Minimum 3 units. No heavy or assault units. 50% must have the Missile Boat or Sniper role.', + }, + alphaStrike: { + effectDescription: 'Coordinated Fire Support: If a unit in this formation hits a target with at least one weapon, other units attacking the same target receive a -1 modifier to their attack rolls. This is cumulative per attacking unit, to a maximum -3 To-Hit modifier.', + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 64 }, { book: Rulebook.FMD, page: 82 }], + requirements: 'Minimum 3 units. No Size 3+ units. 50% must have the Missile Boat or Sniper role.', }, }, @@ -643,20 +678,21 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'pursuit-lance', name: 'Pursuit', description: 'Fast, hard-hitting scout hunters that can chase reconnaissance units or conduct reconnaissance in force.', - effectDescription: bloodStalkerFormationEffectDescription('Pursuit Lance'), - effectGroups: [{ - abilityIds: ['blood_stalker'], - selection: 'all', - distribution: 'percent-75', - }], - idealRole: 'Striker', - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 65 }, { book: Rulebook.ASCE, page: 120 }], - requirements: (gameSystem) => { - if (gameSystem === GameSystem.ALPHA_STRIKE) { - return 'Minimum 3 units. All Size ≤ 2. 75% must have Move [[12]]+. At least 1 unit with medium-range damage > 1.'; - } - return 'Minimum 3 units. All light or medium. 75% must have walk ≥ 6. At least 1 unit dealing 5+ damage at 15 hexes.'; + classic: { + effectDescription: '75% of the units receive the Blood Stalker SPA.', + effectGroups: [{ abilityIds: ['blood_stalker'], selection: 'all', distribution: 'percent-75' }], + idealRole: 'Striker', + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 65 }], + requirements: 'Minimum 3 units. All light or medium. 75% must have walk ≥ 6. At least 1 unit dealing 5+ damage at 15 hexes.', + }, + alphaStrike: { + effectDescription: '75% of the units receive the Blood Stalker SPA. The Pursuit Lance may choose an enemy formation rather than a single unit as the Blood Stalker target. All members must choose the same enemy formation.', + effectGroups: [{ abilityIds: ['blood_stalker'], selection: 'all', distribution: 'percent-75' }], + idealRole: 'Striker', + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 120 }], + requirements: 'Minimum 3 units. All Size ≤ 2. 75% must have Move [[12]]+. At least 1 unit with medium-range damage > 1.', }, }, @@ -667,19 +703,19 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'probe-lance', name: 'Probe', description: 'A lighter Pursuit variant for aggressive reconnaissance, using mobility and coordinated fire to probe enemy positions.', - effectDescription: bloodStalkerFormationEffectDescription('Probe Lance'), - effectGroups: [{ - abilityIds: ['blood_stalker'], - selection: 'all', - distribution: 'percent-75', - }], - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 65 }, { book: Rulebook.ASCE, page: 120 }], - requirements: (gameSystem) => { - if (gameSystem === GameSystem.ALPHA_STRIKE) { - return 'Minimum 3 units. No Size 4+ units. 75% must have Move [[10]]+. All units must have medium-range damage ≥ 2.'; - } - return 'Minimum 3 units. No assault units. 75% must have walk ≥ 6. All units must deal 10+ damage at 9 hexes.'; + classic: { + effectDescription: '75% of the units receive the Blood Stalker SPA.', + effectGroups: [{ abilityIds: ['blood_stalker'], selection: 'all', distribution: 'percent-75' }], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 65 }], + requirements: 'Minimum 3 units. No assault units. 75% must have walk ≥ 6. All units must deal 10+ damage at 9 hexes.', + }, + alphaStrike: { + effectDescription: '75% of the units receive the Blood Stalker SPA. The Probe Lance may choose an enemy formation rather than a single unit as the Blood Stalker target. All members must choose the same enemy formation.', + effectGroups: [{ abilityIds: ['blood_stalker'], selection: 'all', distribution: 'percent-75' }], + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 120 }], + requirements: 'Minimum 3 units. No Size 4+ units. 75% must have Move [[10]]+. All units must have medium-range damage ≥ 2.', }, }, @@ -690,51 +726,54 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'sweep-lance', name: 'Sweep', description: 'A mobile Pursuit variant focused on close-range sweeping attacks against exposed enemy formations.', - effectDescription: bloodStalkerFormationEffectDescription('Sweep Lance'), - effectGroups: [{ - abilityIds: ['blood_stalker'], - selection: 'all', - distribution: 'percent-75', - }], - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 65 }, { book: Rulebook.ASCE, page: 120 }], - requirements: (gameSystem) => { - if (gameSystem === GameSystem.ALPHA_STRIKE) { - return 'Minimum 3 units. All Size ≤ 2. All units must have Move [[10]]+. All units must have short-range damage ≥ 2.'; - } - return 'Minimum 3 units. All light or medium. All units must have walk ≥ 5. All units must deal 10+ damage at 6 hexes.'; + classic: { + effectDescription: '75% of the units receive the Blood Stalker SPA.', + effectGroups: [{ abilityIds: ['blood_stalker'], selection: 'all', distribution: 'percent-75' }], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 65 }], + requirements: 'Minimum 3 units. All light or medium. All units must have walk ≥ 5. All units must deal 10+ damage at 6 hexes.', + }, + alphaStrike: { + effectDescription: '75% of the units receive the Blood Stalker SPA. The Sweep Lance may choose an enemy formation rather than a single unit as the Blood Stalker target. All members must choose the same enemy formation.', + effectGroups: [{ abilityIds: ['blood_stalker'], selection: 'all', distribution: 'percent-75' }], + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 120 }], + requirements: 'Minimum 3 units. All Size ≤ 2. All units must have Move [[10]]+. All units must have short-range damage ≥ 2.', }, }, // ─── Recon Lance ───────────────────────────────────────────────────── // - // Bonus: Choose Eagle's Eyes or Maneuvering Ace → up to 3 units. - // All units also receive Forward Observer. + // Bonus: Classic chooses Eagle's Eyes or Maneuvering Ace for up to 3 and + // grants Forward Observer to all. Alpha Strike chooses one of all three + // SPAs and grants the chosen ability to the entire formation. // { id: 'recon-lance', name: 'Recon', description: 'Extremely fast scouts that rush ahead to identify objectives, evade fire, and harass or flank opponents.', - effectDescription: 'At the beginning of play, choose either Eagle\'s Eyes or Maneuvering Ace SPA and apply it to up to three units in this formation. The chosen ability cannot be switched between units or changed during the scenario. In addition, all units in this formation receive the Forward Observer SPA.', - effectGroups: [ - { - abilityIds: ['eagles_eyes', 'maneuvering_ace'], + classic: { + effectDescription: 'At the beginning of play, choose either Eagle\'s Eyes or Maneuvering Ace and apply it to up to three units. The recipients and chosen SPA cannot change during the scenario. In addition, all units receive Forward Observer.', + effectGroups: [ + { abilityIds: ['eagles_eyes', 'maneuvering_ace'], selection: 'choose-one', distribution: 'fixed', count: 3 }, + { abilityIds: ['forward_observer'], selection: 'all', distribution: 'all' }, + ], + idealRole: 'Scout', + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 65 }], + requirements: 'Minimum 3 units. All units must have walk ≥ 5. At least 2 Scout or Striker roles.', + }, + alphaStrike: { + effectDescription: 'At the beginning of play, choose Eagle\'s Eyes, Forward Observer, or Maneuvering Ace. Every unit in the formation receives the chosen SPA, which cannot be changed during the scenario.', + effectGroups: [{ + abilityIds: ['eagles_eyes', 'forward_observer', 'maneuvering_ace'], selection: 'choose-one', - distribution: 'fixed', - count: 3, - }, - { - abilityIds: ['forward_observer'], - selection: 'all', distribution: 'all', - }, - ], - idealRole: 'Scout', - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 65 }, { book: Rulebook.ASCE, page: 119 }], - requirements: (gameSystem) => { - const fast = gameSystem === GameSystem.ALPHA_STRIKE ? 'Move [[10]]+' : 'walk ≥ 5'; - return `Minimum 3 units. All units must have ${fast}. At least 2 Scout or Striker roles.`; + }], + idealRole: 'Scout', + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 119 }], + requirements: 'Minimum 3 units. All units must have Move [[10]]+. At least 2 Scout or Striker roles.', }, }, @@ -745,27 +784,26 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'heavy-recon-lance', name: 'Heavy Recon', description: 'An armored reconnaissance variant that keeps the scouting role while adding heavier units.', - effectDescription: 'At the beginning of play, choose either Eagle\'s Eyes or Maneuvering Ace SPA and apply it to up to two units in this formation. The chosen ability cannot be switched between units or changed during the scenario. In addition, all units in this formation receive the Forward Observer SPA.', - effectGroups: [ - { - abilityIds: ['eagles_eyes', 'maneuvering_ace'], + classic: { + effectDescription: 'As the standard Recon Lance, except only two units may receive the chosen Eagle\'s Eyes or Maneuvering Ace SPA. All units still receive Forward Observer.', + effectGroups: [ + { abilityIds: ['eagles_eyes', 'maneuvering_ace'], selection: 'choose-one', distribution: 'fixed', count: 2 }, + { abilityIds: ['forward_observer'], selection: 'all', distribution: 'all' }, + ], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 65 }], + requirements: 'Minimum 3 units. All walk ≥ 4. At least 2 with walk ≥ 5. At least 1 heavy or assault. At least 2 Scouts.', + }, + alphaStrike: { + effectDescription: 'As the standard Recon Lance, except only up to half the units (round up) may receive the chosen Eagle\'s Eyes, Forward Observer, or Maneuvering Ace SPA.', + effectGroups: [{ + abilityIds: ['eagles_eyes', 'forward_observer', 'maneuvering_ace'], selection: 'choose-one', - distribution: 'fixed', - count: 2, - }, - { - abilityIds: ['forward_observer'], - selection: 'all', - distribution: 'all', - }, - ], - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 65 }, { book: Rulebook.ASCE, page: 120 }], - requirements: (gameSystem) => { - if (gameSystem === GameSystem.ALPHA_STRIKE) { - return 'Minimum 3 units. All Move [[8]]+. At least 2 with Move [[10]]+. At least 1 Size 3+ unit. At least 2 Scouts.'; - } - return 'Minimum 3 units. All walk ≥ 4. At least 2 with walk ≥ 5. At least 1 heavy or assault. At least 2 Scouts.'; + distribution: 'half-round-up', + }], + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 120 }], + requirements: 'Minimum 3 units. All Move [[8]]+. At least 2 with Move [[10]]+. At least 1 Size 3+ unit. At least 2 Scouts.', }, }, @@ -776,26 +814,26 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'light-recon-lance', name: 'Light Recon', description: 'Ultra-mobile light scouts built for deep reconnaissance, spotting, and rapid maneuver.', - effectDescription: 'At the beginning of play, choose either Eagle\'s Eyes or Maneuvering Ace SPA and apply it to all units in this formation. This choice is permanent for the scenario. Additionally, all units receive the Forward Observer SPA.', - effectGroups: [ - { - abilityIds: ['eagles_eyes', 'maneuvering_ace'], - selection: 'choose-one', - distribution: 'all', - }, - { - abilityIds: ['forward_observer'], - selection: 'all', + classic: { + effectDescription: 'As the standard Recon Lance, except all units receive the chosen Eagle\'s Eyes or Maneuvering Ace SPA, in addition to Forward Observer.', + effectGroups: [ + { abilityIds: ['eagles_eyes', 'maneuvering_ace'], selection: 'choose-one', distribution: 'all' }, + { abilityIds: ['forward_observer'], selection: 'all', distribution: 'all' }, + ], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 65 }], + requirements: 'Minimum 3 units. All light. All walk ≥ 6. All must have the Scout role.', + }, + alphaStrike: { + effectDescription: 'As the standard Recon Lance, except each unit may independently receive Eagle\'s Eyes, Forward Observer, or Maneuvering Ace.', + effectGroups: [{ + abilityIds: ['eagles_eyes', 'forward_observer', 'maneuvering_ace'], + selection: 'choose-each', distribution: 'all', - }, - ], - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 65 }, { book: Rulebook.ASCE, page: 119 }], - requirements: (gameSystem) => { - if (gameSystem === GameSystem.ALPHA_STRIKE) { - return 'Minimum 3 units. All Size 1. All Move [[12]]+. All must have the Scout role.'; - } - return 'Minimum 3 units. All light. All walk ≥ 6. All must have the Scout role.'; + }], + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 119 }], + requirements: 'Minimum 3 units. All Size 1. All Move [[12]]+. All must have the Scout role.', }, }, @@ -808,17 +846,19 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'security-lance', name: 'Security', description: 'Independent defenders for installations and other vital sites, combining terrain expertise with the speed to pursue raiders.', - effectDescription: 'If acting as the Defender in a scenario, at the beginning of play 75% of the units are assigned Environmental Specialist or Terrain Master SPA of their choice; the same variation must be chosen for each unit. If not acting as the Defender, 75% are assigned the Speed Demon SPA at the beginning of play.', - effectGroups: [{ - abilityIds: ['speed_demon', 'environmental_specialist', 'terrain_master_drag_racer', 'terrain_master_forest_ranger', 'terrain_master_frogman', 'terrain_master_mountaineer', 'terrain_master_nightwalker', 'terrain_master_sea_monster', 'terrain_master_swamp_beast'], - selection: 'choose-one', - distribution: 'percent-75', - }], - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 65 }, { book: Rulebook.FMMERC, page: 91 }], - requirements: (gameSystem) => { - const assault = gameSystem === GameSystem.ALPHA_STRIKE ? 'Size 4+' : 'assault'; - return `Minimum 3 units. At most 1 ${assault} unit. At least 1 Scout or Striker. At least 1 Sniper or Missile Boat.`; + classic: { + effectDescription: 'If acting as the Defender, 75% of the units receive Environmental Specialist or Terrain Master (the same variation for each unit). Otherwise, 75% receive Speed Demon.', + effectGroups: [{ abilityIds: ['speed_demon', 'environmental_specialist', 'terrain_master_drag_racer', 'terrain_master_forest_ranger', 'terrain_master_frogman', 'terrain_master_mountaineer', 'terrain_master_nightwalker', 'terrain_master_sea_monster', 'terrain_master_swamp_beast'], selection: 'choose-one', distribution: 'percent-75' }], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 65 }, { book: Rulebook.FMMERC, page: 91 }], + requirements: 'Minimum 3 units. At most 1 assault unit. At least 1 Scout or Striker. At least 1 Sniper or Missile Boat.', + }, + alphaStrike: { + effectDescription: 'If acting as the Defender, 75% of the units receive Environmental Specialist or Terrain Master (the same variation for each unit). Otherwise, 75% receive Speed Demon.', + effectGroups: [{ abilityIds: ['speed_demon', 'environmental_specialist', 'terrain_master_drag_racer', 'terrain_master_forest_ranger', 'terrain_master_frogman', 'terrain_master_mountaineer', 'terrain_master_nightwalker', 'terrain_master_sea_monster', 'terrain_master_swamp_beast'], selection: 'choose-one', distribution: 'percent-75' }], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 65 }, { book: Rulebook.FMMERC, page: 91 }], + requirements: 'Minimum 3 units. At most 1 Size 4+ unit. At least 1 Scout or Striker. At least 1 Sniper or Missile Boat.', }, }, @@ -831,20 +871,21 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ name: 'Striker/Cavalry', nameAliases: ['Striker', 'Cavalry'], description: 'Fast-moving units that bring firepower to the fight, survive the engagement, then withdraw or hold until the main force arrives.', - effectDescription: '75% of the units (round normally) receive the Speed Demon SPA.', - effectGroups: [{ - abilityIds: ['speed_demon'], - selection: 'all', - distribution: 'percent-75', - }], - idealRole: 'Striker', - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 66 }, { book: Rulebook.ASCE, page: 118 }], - requirements: (gameSystem) => { - if (gameSystem === GameSystem.ALPHA_STRIKE) { - return 'Minimum 3 units. All Move [[10]]+ or Jump [[8]]+. No Size 4+ units. 50% must have Striker or Skirmisher role.'; - } - return 'Minimum 3 units. All walk ≥ 5 or jump ≥ 4. No assault units. 50% must have Striker or Skirmisher role.'; + classic: { + effectDescription: '75% of the units receive the Speed Demon SPA.', + effectGroups: [{ abilityIds: ['speed_demon'], selection: 'all', distribution: 'percent-75' }], + idealRole: 'Striker', + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 66 }], + requirements: 'Minimum 3 units. All walk ≥ 5 or jump ≥ 4. No assault units. 50% must have Striker or Skirmisher role.', + }, + alphaStrike: { + effectDescription: '75% of the units (round normally) receive the Speed Demon SPA.', + effectGroups: [{ abilityIds: ['speed_demon'], selection: 'all', distribution: 'percent-75' }], + idealRole: 'Striker', + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 118 }], + requirements: 'Minimum 3 units. All Move [[10]]+ or Jump [[8]]+. No Size 4+ units. 50% must have Striker or Skirmisher role.', }, }, @@ -856,21 +897,22 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'hammer-lance', name: 'Hammer', description: 'Fast Marik flanking units trained to strike the enemy\'s flank or rear while an Anvil formation holds its attention.', - effectDescription: 'At the beginning of each turn, up to two Hammer Lance units may receive either the Jumping Jack or Speed Demon SPA. The player may assign the same SPA to both units, or one may receive Jumping Jack and the other Speed Demon.', - effectGroups: [{ - abilityIds: ['jumping_jack', 'speed_demon'], - selection: 'choose-each', - distribution: 'fixed', - count: 2, - perTurn: true, - }], exclusiveFaction: ['Free Worlds League'], - idealRole: 'Striker', - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 66 }], - requirements: (gameSystem) => { - const fast = gameSystem === GameSystem.ALPHA_STRIKE ? 'Move [[10]]+' : 'walk ≥ 5'; - return `Minimum 3 units. Free Worlds League only. All units must have ${fast}.`; + classic: { + effectDescription: 'At the beginning of each turn, up to two units may receive either Jumping Jack or Speed Demon. The same SPA may be assigned to both, or each may receive a different one.', + effectGroups: [{ abilityIds: ['jumping_jack', 'speed_demon'], selection: 'choose-each', distribution: 'fixed', count: 2, perTurn: true }], + idealRole: 'Striker', + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 66 }], + requirements: 'Minimum 3 units. Free Worlds League only. All units must have walk ≥ 5.', + }, + alphaStrike: { + effectDescription: 'At the beginning of each turn, up to two units may receive either Jumping Jack or Speed Demon. The same SPA may be assigned to both, or each may receive a different one.', + effectGroups: [{ abilityIds: ['jumping_jack', 'speed_demon'], selection: 'choose-each', distribution: 'fixed', count: 2, perTurn: true }], + idealRole: 'Striker', + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 66 }], + requirements: 'Minimum 3 units. Free Worlds League only. All units must have Move [[10]]+.', }, }, @@ -882,19 +924,19 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ name: 'Light Striker/Cavalry', nameAliases: ['Light Striker', 'Light Cavalry'], description: 'A light cavalry variant for swift flanking attacks and harassment.', - effectDescription: '75% of the units (round normally) receive the Speed Demon SPA.', - effectGroups: [{ - abilityIds: ['speed_demon'], - selection: 'all', - distribution: 'percent-75', - }], - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 66 }, { book: Rulebook.ASCE, page: 118 }], - requirements: (gameSystem) => { - if (gameSystem === GameSystem.ALPHA_STRIKE) { - return 'Minimum 3 units. All Move [[10]]+. No Size 3+ units. At least 2 with long-range damage > 0. At least 2 Striker or Skirmisher roles.'; - } - return 'Minimum 3 units. All walk ≥ 5. No heavy or assault units. At least 2 deal 5+ damage at 18 hexes. At least 2 Striker or Skirmisher roles.'; + classic: { + effectDescription: '75% of the units receive the Speed Demon SPA.', + effectGroups: [{ abilityIds: ['speed_demon'], selection: 'all', distribution: 'percent-75' }], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 66 }], + requirements: 'Minimum 3 units. All walk ≥ 5. No heavy or assault units. At least 2 deal 5+ damage at 18 hexes. At least 2 Striker or Skirmisher roles.', + }, + alphaStrike: { + effectDescription: '75% of the units (round normally) receive the Speed Demon SPA.', + effectGroups: [{ abilityIds: ['speed_demon'], selection: 'all', distribution: 'percent-75' }], + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 118 }], + requirements: 'Minimum 3 units. All Move [[10]]+. No Size 3+ units. At least 2 with long-range damage > 0. At least 2 Striker or Skirmisher roles.', }, }, @@ -906,19 +948,19 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ name: 'Heavy Striker/Cavalry', nameAliases: ['Heavy Striker', 'Heavy Cavalry'], description: 'A heavier cavalry variant combining speed with armor and long-range firepower.', - effectDescription: '75% of the units (round normally) receive the Speed Demon SPA.', - effectGroups: [{ - abilityIds: ['speed_demon'], - selection: 'all', - distribution: 'percent-75', - }], - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 66 }, { book: Rulebook.ASCE, page: 119 }], - requirements: (gameSystem) => { - if (gameSystem === GameSystem.ALPHA_STRIKE) { - return 'Minimum 3 units. All Move [[8]]+. At least 3 Size 3+. No Size 1 units. At least 1 with long-range damage > 1. At least 2 Striker or Skirmisher roles.'; - } - return 'Minimum 3 units. All walk ≥ 4. At least 3 heavy or assault. No light units. At least 1 deals 5+ damage at 18 hexes. At least 2 Striker or Skirmisher roles.'; + classic: { + effectDescription: '75% of the units receive the Speed Demon SPA.', + effectGroups: [{ abilityIds: ['speed_demon'], selection: 'all', distribution: 'percent-75' }], + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 66 }], + requirements: 'Minimum 3 units. All walk ≥ 4. At least 3 heavy or assault. No light units. At least 1 deals 5+ damage at 18 hexes. At least 2 Striker or Skirmisher roles.', + }, + alphaStrike: { + effectDescription: '75% of the units (round normally) receive the Speed Demon SPA.', + effectGroups: [{ abilityIds: ['speed_demon'], selection: 'all', distribution: 'percent-75' }], + minUnits: 3, + rulesRef: [{ book: Rulebook.ASCE, page: 119 }], + requirements: 'Minimum 3 units. All Move [[8]]+. At least 3 Size 3+. No Size 1 units. At least 1 with long-range damage > 1. At least 2 Striker or Skirmisher roles.', }, }, @@ -930,17 +972,19 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'horde', name: 'Horde', description: 'Light "bug" BattleMechs that swarm and overwhelm larger opponents through numbers.', - effectDescription: (gameSystem) => gameSystem === GameSystem.ALPHA_STRIKE - ? 'Swarm: When any Unit in this Formation is targeted, the targeted Unit\'s player may switch the target to any other Unit in this Formation that is a legal target (within line of sight) and at the same range (or less) from the attacker.' - : 'Swarm: When any unit in this formation is targeted by an enemy attack, that unit\'s player may switch the target to any other unit in this formation that is still a legal target (within line of sight) and at the same range or less from the attacker. This ability can only be used by units which spent Running, Jumping, or Flank movement points that turn.', - minUnits: 5, - maxUnits: 10, - rulesRef: [{ book: Rulebook.CO, page: 66 }, { book: Rulebook.FMK, page: 87 }], - requirements: (gameSystem) => { - if (gameSystem === GameSystem.ALPHA_STRIKE) { - return '5-10 units. All Size 1. All must have medium-range damage < 2.'; - } - return '5-10 units. All light. All must deal less than 11 damage at 9 hexes.'; + classic: { + effectDescription: 'Swarm: When a unit is targeted, its player may switch the attack to another legal target in this formation at the same or shorter range. Only units that used Running, Jumping, or Flank movement points that turn may use this ability.', + minUnits: 5, + maxUnits: 10, + rulesRef: [{ book: Rulebook.CO, page: 66 }, { book: Rulebook.FMK, page: 87 }], + requirements: '5-10 units. All light. All must deal less than 11 damage at 9 hexes.', + }, + alphaStrike: { + effectDescription: 'Swarm: When any Unit in this Formation is targeted, the targeted Unit\'s player may switch the target to any other Unit in this Formation that is a legal target and at the same range or less from the attacker.', + minUnits: 5, + maxUnits: 10, + rulesRef: [{ book: Rulebook.CO, page: 66 }, { book: Rulebook.FMK, page: 87 }], + requirements: '5-10 units. All Size 1. All must have medium-range damage < 2.', }, }, @@ -948,25 +992,20 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'swarm', name: 'Swarm', description: 'A formation composed exclusively of small VTOL units.', - effectDescription: 'Coordinated Fire: The formation may make a standard weapon attack against a target within Short Range and Line of Sight of all members as if it were a single Unit. The targeted player chooses one attacking unit from which to calculate the to-hit modifiers. Make one to-hit roll for the formation. If the attack hits, add 1 damage point to one of the attacks; all other attacks use their standard damage.', - effectGroups: [{ - formationWideAbilities: [{ - id: 'coordinated_fire', - name: 'Coordinated Fire', - summary: [ - 'The formation may make a standard weapon attack against a target within Short Range and Line of Sight of all members as if it were a single Unit.', - 'The targeted player chooses one attacking unit from which to calculate the to-hit modifiers. Make one to-hit roll for the formation; it hits or misses as one.', - 'If the attack hits, add 1 damage point to one of the attacks. All other attacks use their standard damage.', - ], - rulesRef: [{ book: Rulebook.FMMERC, page: 52 }], - }], - distribution: 'formation-wide', - }], - minUnits: 4, - rulesRef: [{ book: Rulebook.FMMERC, page: 52 }], - requirements: (gameSystem) => gameSystem === GameSystem.ALPHA_STRIKE - ? 'VTOL Company. All units must be VTOLs. No Size 3+ units.' - : 'VTOL Company. All units must be VTOLs. No heavy or assault units.', + classic: { + effectDescription: 'Coordinated Fire: The formation may make a standard weapon attack against a target within Short Range and Line of Sight of all members as if it were a single Unit. Make one to-hit roll; on a hit, add 1 damage point to one attack.', + effectGroups: [{ formationWideAbilities: [{ id: 'coordinated_fire', name: 'Coordinated Fire', summary: ['The formation may make a standard weapon attack against a target within Short Range and Line of Sight of all members as if it were a single Unit.', 'The targeted player chooses one attacking unit from which to calculate the to-hit modifiers. Make one to-hit roll for the formation; it hits or misses as one.', 'If the attack hits, add 1 damage point to one of the attacks. All other attacks use their standard damage.'], rulesRef: [{ book: Rulebook.FMMERC, page: 52 }] }], distribution: 'formation-wide' }], + minUnits: 4, + rulesRef: [{ book: Rulebook.FMMERC, page: 52 }], + requirements: 'VTOL Company. All units must be VTOLs. No heavy or assault units.', + }, + alphaStrike: { + effectDescription: 'Coordinated Fire: The formation may make a standard weapon attack against a target within Short Range and Line of Sight of all members as if it were a single Unit. Make one to-hit roll; on a hit, add 1 damage point to one attack.', + effectGroups: [{ formationWideAbilities: [{ id: 'coordinated_fire', name: 'Coordinated Fire', summary: ['The formation may make a standard weapon attack against a target within Short Range and Line of Sight of all members as if it were a single Unit.', 'The targeted player chooses one attacking unit from which to calculate the to-hit modifiers. Make one to-hit roll for the formation; it hits or misses as one.', 'If the attack hits, add 1 damage point to one of the attacks. All other attacks use their standard damage.'], rulesRef: [{ book: Rulebook.FMMERC, page: 52 }] }], distribution: 'formation-wide' }], + minUnits: 4, + rulesRef: [{ book: Rulebook.FMMERC, page: 52 }], + requirements: 'VTOL Company. All units must be VTOLs. No Size 3+ units.', + }, }, // @@ -977,18 +1016,21 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'ranger-lance', name: 'Ranger', description: 'Terrain specialists trained to fight in heavy cover or ground that slows other forces.', - effectDescription: 'At the beginning of play, 75% of the units in this formation receive one Terrain Master SPA. The same Terrain Master variation must be assigned to these units.', - effectGroups: [{ - abilityIds: ['terrain_master_drag_racer', 'terrain_master_forest_ranger', 'terrain_master_frogman', 'terrain_master_mountaineer', 'terrain_master_nightwalker', 'terrain_master_sea_monster', 'terrain_master_swamp_beast'], - selection: 'choose-one', - distribution: 'percent-75', - }], - idealRole: 'Skirmisher', - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 66 }], - requirements: (gameSystem) => { - const assault = gameSystem === GameSystem.ALPHA_STRIKE ? 'Size 4+' : 'assault'; - return `Minimum 3 units. No ${assault} units.`; + classic: { + effectDescription: 'At the beginning of play, 75% of the units receive one Terrain Master SPA. The same variation must be assigned to all recipients.', + effectGroups: [{ abilityIds: ['terrain_master_drag_racer', 'terrain_master_forest_ranger', 'terrain_master_frogman', 'terrain_master_mountaineer', 'terrain_master_nightwalker', 'terrain_master_sea_monster', 'terrain_master_swamp_beast'], selection: 'choose-one', distribution: 'percent-75' }], + idealRole: 'Skirmisher', + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 66 }], + requirements: 'Minimum 3 units. No assault units.', + }, + alphaStrike: { + effectDescription: 'At the beginning of play, 75% of the units receive one Terrain Master SPA. The same variation must be assigned to all recipients.', + effectGroups: [{ abilityIds: ['terrain_master_drag_racer', 'terrain_master_forest_ranger', 'terrain_master_frogman', 'terrain_master_mountaineer', 'terrain_master_nightwalker', 'terrain_master_sea_monster', 'terrain_master_swamp_beast'], selection: 'choose-one', distribution: 'percent-75' }], + idealRole: 'Skirmisher', + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 66 }], + requirements: 'Minimum 3 units. No Size 4+ units.', }, }, @@ -997,10 +1039,20 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'support-lance', name: 'Support', description: 'A multi-role formation that does not excel at one mission, instead reinforcing other formations.', - minUnits: 3, - effectDescription: 'Before play, designate one other formation to support. Half the units (round down) receive the same SPAs as the supported formation. SPA count may not exceed the supported formation\'s count.', - rulesRef: [{ book: Rulebook.CO, page: 66 }, { book: Rulebook.ASCE, page: 121 }], - requirements: () => 'Minimum 3 units. No additional composition requirements.', + classic: { + minUnits: 3, + effectDescription: 'Before play, designate another formation to support. For every two units in the supported formation using a formation bonus, one Support Lance unit receives the same ability. The copied bonus is retained while the Support Lance has at least three active units and is not lost if the supported formation falls below its own retention threshold. If the supported formation offers a choice of SPAs, choose the Support Lance\'s SPAs at setup; those choices may not change during play. When supporting a Command Lance, copy the SPAs actually granted to its non-commander units and assign each copied SPA to a Support Lance unit eligible for it; Tactical Genius is never copied.', + effectGroups: [{ selection: 'copy', distribution: 'formation-target', recipientLimit: 'one-per-two-target-recipients' }], + rulesRef: [{ book: Rulebook.CO, page: 66 }], + requirements: 'Minimum 3 units. No additional composition requirements.', + }, + alphaStrike: { + minUnits: 3, + effectDescription: 'Before play, designate another formation to support. Half the Support Lance units (round down) receive the same SPAs as the supported formation. The number of copies of each SPA may not exceed the number the supported formation receives at setup. If the supported formation assigns a bonus at the beginning of each turn, choose the Support Lance assignments at setup; they may not be moved during play. The copied bonuses are retained while the Support Lance has at least three active units and are not lost if the supported formation falls below its own retention threshold. When supporting a Command Lance, copy the SPAs actually granted to its non-commander units and assign each copied SPA to an appropriate Support Lance unit; Tactical Genius is never copied.', + effectGroups: [{ selection: 'copy', distribution: 'formation-target', recipientLimit: 'half-self-round-down' }], + rulesRef: [{ book: Rulebook.ASCE, page: 121 }], + requirements: 'Minimum 3 units. No additional composition requirements.', + }, }, // ─── Urban Combat Lance ────────────────────────────────────────────── @@ -1012,19 +1064,21 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'urban-lance', name: 'Urban Combat', description: 'Short-range, intensive fighters built for city combat, using jump movement to attack around buildings.', - effectDescription: 'At the beginning of each turn, up to 75% of the units may receive the Street Fighter (if \'Mech or ProtoMech) or Urban Guerrilla (if infantry) SPAs. Vehicles receive the equivalent of 1-point of Luck and a one-time use of the Marksman SPA.', - effectGroups: [{ - abilityIds: ['street_fighter', 'urban_guerrilla', 'lucky', 'marksman'], - selection: 'choose-each', - distribution: 'percent-75', - perTurn: true, - }], - idealRole: 'Ambusher', - minUnits: 3, - rulesRef: [{ book: Rulebook.CO, page: 67 }], - requirements: (gameSystem) => { - const move = gameSystem === GameSystem.ALPHA_STRIKE ? `ground Move ≤ [[8]]+` : 'walk ≤ 4'; - return `Minimum 3 units. 50% must have jump movement or be infantry. 50% must have ${move}.`; + classic: { + effectDescription: 'At the beginning of each turn, up to 75% of the units may receive Street Fighter (\'Mechs or ProtoMechs) or Urban Guerrilla (infantry). Vehicles receive the equivalent of 1-point Luck and a one-time use of Marksman.', + effectGroups: [{ abilityIds: ['street_fighter', 'urban_guerrilla', 'lucky', 'marksman'], selection: 'choose-each', distribution: 'percent-75', perTurn: true }], + idealRole: 'Ambusher', + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 67 }], + requirements: 'Minimum 3 units. 50% must have jump movement or be infantry. 50% must have walk ≤ 4.', + }, + alphaStrike: { + effectDescription: 'At the beginning of each turn, up to 75% of the units may receive Street Fighter (\'Mechs or ProtoMechs) or Urban Guerrilla (infantry). Vehicles receive the equivalent of 1-point Luck and a one-time use of Marksman.', + effectGroups: [{ abilityIds: ['street_fighter', 'urban_guerrilla', 'lucky', 'marksman'], selection: 'choose-each', distribution: 'percent-75', perTurn: true }], + idealRole: 'Ambusher', + minUnits: 3, + rulesRef: [{ book: Rulebook.CO, page: 67 }], + requirements: 'Minimum 3 units. 50% must have jump movement or be infantry. 50% must have ground Move ≤ [[8]].', }, }, @@ -1037,19 +1091,21 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'phalanx-star', name: 'Phalanx', description: 'A Clan combined-arms defensive Star that mixes BattleMechs or vehicles with Elementals and other ground units.', - effectDescription: 'The formation receives a Float Like a Butterfly SPA. Useable by any unit in the formation. (max 6 rerolls per scenario).', - effectGroups: [{ - abilityIds: ['float_like_a_butterfly'], - selection: 'all', - distribution: 'shared-pool', - sharedPool: { - totalUsesPerScenario: 6, - }, - }], exclusiveFaction: CLAN_EXCLUSIVE_FACTIONS, - minUnits: 3, - rulesRef: [{ book: Rulebook.BOT, page: 27 }], - requirements: () => 'Clan only. Minimum 2 combat vehicles or BattleMeks. Remainder must be Elementals, combat vehicles, or BattleMeks. Must be at least two different unit types.', + classic: { + effectDescription: 'The formation receives a Float Like a Butterfly SPA usable by any unit in the formation, with a maximum of six rerolls per scenario.', + effectGroups: [{ abilityIds: ['float_like_a_butterfly'], selection: 'all', distribution: 'shared-pool', sharedPool: { totalUsesPerScenario: 6 } }], + minUnits: 3, + rulesRef: [{ book: Rulebook.BOT, page: 27 }], + requirements: 'Clan only. Minimum 2 combat vehicles or BattleMeks. Remainder must be Elementals, combat vehicles, or BattleMeks. Must be at least two different unit types.', + }, + alphaStrike: { + effectDescription: 'The formation receives a Float Like a Butterfly SPA usable by any unit in the formation, with a maximum of six rerolls per scenario.', + effectGroups: [{ abilityIds: ['float_like_a_butterfly'], selection: 'all', distribution: 'shared-pool', sharedPool: { totalUsesPerScenario: 6 } }], + minUnits: 3, + rulesRef: [{ book: Rulebook.BOT, page: 27 }], + requirements: 'Clan only. Minimum 2 combat vehicles or BattleMeks. Remainder must be Elementals, combat vehicles, or BattleMeks. Must be at least two different unit types.', + }, }, // @@ -1060,18 +1116,21 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'rogue-star', name: 'Rogue', description: 'A swift Clan strike formation built for sudden attacks and rapid pressure.', - effectDescription: 'At the beginning of each turn, up to two units in this formation may receive the Combat Intuition SPA.', - effectGroups: [{ - abilityIds: ['combat_intuition'], - selection: 'all', - distribution: 'fixed', - count: 2, - perTurn: true, - }], exclusiveFaction: CLAN_EXCLUSIVE_FACTIONS, - minUnits: 3, - rulesRef: [{ book: Rulebook.BOT, page: 27 }], - requirements: () => 'Clan only. At least two units in the Formation must be the same model (including the same OmniMek configuration)', + classic: { + effectDescription: 'At the beginning of each turn, up to two units may receive the Combat Intuition SPA.', + effectGroups: [{ abilityIds: ['combat_intuition'], selection: 'all', distribution: 'fixed', count: 2, perTurn: true }], + minUnits: 3, + rulesRef: [{ book: Rulebook.BOT, page: 27 }], + requirements: 'Clan only. At least two units in the formation must be the same model (including the same OmniMek configuration).', + }, + alphaStrike: { + effectDescription: 'At the beginning of each turn, up to two units may receive the Combat Intuition SPA.', + effectGroups: [{ abilityIds: ['combat_intuition'], selection: 'all', distribution: 'fixed', count: 2, perTurn: true }], + minUnits: 3, + rulesRef: [{ book: Rulebook.BOT, page: 27 }], + requirements: 'Clan only. At least two units in the formation must be the same model (including the same OmniMek configuration).', + }, }, // @@ -1083,29 +1142,26 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'strategic-command-star', name: 'Strategic Command', description: 'A Clan combined-arms command formation that coordinates aerospace and ground units around a skilled leader.', - effectDescription: 'Clan only. Prior to the beginning of play, two of the non-commander units in this formation receive one of the following Special Pilot Abilities for free (each unit may receive a different SPA): Antagonizer, Combat Intuition, Blood Stalker, Eagle\'s Eyes, Marksman, or Multi-Tasker. In addition, the commander\'s unit receives the Tactical Genius SPA. If the commander already has the Tactical Genius SPA, instead add a +1 modifier to the force\'s Initiative roll results, including any rerolls made as a result of the Tactical Genius SPA. Aerospace units cannot be designated force commander. Counts as Command Star.', - effectGroups: [ - { - abilityIds: ['antagonizer', 'blood_stalker', 'combat_intuition', 'eagles_eyes', 'marksman', 'multi_tasker'], - selection: 'choose-each', - distribution: 'fixed', - count: 2, - excludeCommander: true, - }, - { - abilityIds: ['tactical_genius'], - selection: 'all', - distribution: 'commander', - }, - ], exclusiveFaction: CLAN_EXCLUSIVE_FACTIONS, - minUnits: 3, - rulesRef: [{ book: Rulebook.BOT, page: 27 }], - requirements: (gameSystem) => { - if (gameSystem === GameSystem.ALPHA_STRIKE) { - return 'Minimum 3 units. All must have skill 3 or lower. Must have 2 AF. Others must be BM, IM, or BA. If BM or IM, at least 2 units Size 3+ and, no Size 1.'; - } - return 'Minimum 3 units. All must have Gunnery Skill 3 or lower. Must have 1 Aerospace Point. Others must be Mek or Battle Armor. If Mek, at least 2 units heavy or assault, and no lights.'; + classic: { + effectDescription: 'Two non-commander units each receive one eligible Command Lance SPA, and the commander receives Tactical Genius (or its Initiative bonus). Aerospace units cannot be the force commander. Counts as a Command Star.', + effectGroups: [ + { abilityIds: ['antagonizer', 'blood_stalker', 'combat_intuition', 'eagles_eyes', 'marksman', 'multi_tasker'], selection: 'choose-each', distribution: 'fixed', count: 2, excludeCommander: true }, + { abilityIds: ['tactical_genius'], selection: 'all', distribution: 'commander' }, + ], + minUnits: 3, + rulesRef: [{ book: Rulebook.BOT, page: 27 }], + requirements: 'Minimum 3 units. All must have Gunnery Skill 3 or lower. Must have 1 Aerospace Point. Others must be Mek or Battle Armor. If Mek, at least 2 units heavy or assault, and no lights.', + }, + alphaStrike: { + effectDescription: 'Two non-commander units each receive one eligible Command Lance SPA, and the commander receives Tactical Genius (or its Initiative bonus). Aerospace units cannot be the force commander. Counts as a Command Star.', + effectGroups: [ + { abilityIds: ['antagonizer', 'blood_stalker', 'combat_intuition', 'eagles_eyes', 'marksman', 'multi_tasker'], selection: 'choose-each', distribution: 'fixed', count: 2, excludeCommander: true }, + { abilityIds: ['tactical_genius'], selection: 'all', distribution: 'commander' }, + ], + minUnits: 3, + rulesRef: [{ book: Rulebook.BOT, page: 27 }], + requirements: 'Minimum 3 units. All must have skill 3 or lower. Must have 2 AF. Others must be BM, IM, or BA. If BM or IM, at least 2 units Size 3+ and no Size 1.', }, }, @@ -1119,24 +1175,26 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'interceptor-squadron', name: 'Interceptor', description: 'Fast aerospace combat groups that strike approaching threats before they reach the main force, trading armor and firepower for speed.', - effectDescription: 'Any units with Move (Thrust) of 9 or less receive the Speed Demon SPA. In addition, up to 2 fighters may also receive the Range Master (Long) SPA.', - effectGroups: [ - { - abilityIds: ['speed_demon'], - selection: 'all', - distribution: 'conditional', - condition: 'Move (Thrust) ≤ 9', - }, - { - abilityIds: ['range_master'], - selection: 'all', - distribution: 'fixed', - count: 2, - }, - ], - minUnits: 6, - rulesRef: [{ book: Rulebook.CO, page: 68 }, { book: Rulebook.ASCE, page: 122 }], - requirements: () => 'Minimum 6 units. All must be aerospace units. More than 50% must have the Interceptor role.', + classic: { + effectDescription: 'Units with Thrust 9 or less receive Speed Demon. In addition, up to two fighters may receive Range Master (Long).', + effectGroups: [ + { abilityIds: ['speed_demon'], selection: 'all', distribution: 'conditional', condition: 'Move (Thrust) ≤ 9' }, + { abilityIds: ['range_master'], selection: 'all', distribution: 'fixed', count: 2 }, + ], + minUnits: 6, + rulesRef: [{ book: Rulebook.CO, page: 68 }], + requirements: 'Minimum 6 units. All must be aerospace or conventional fighters. More than 50% must have the Interceptor role.', + }, + alphaStrike: { + effectDescription: 'Units with Move (Thrust) 9 or less receive Speed Demon. In addition, up to two fighters may receive Range Master (Long).', + effectGroups: [ + { abilityIds: ['speed_demon'], selection: 'all', distribution: 'conditional', condition: 'Move (Thrust) ≤ 9' }, + { abilityIds: ['range_master'], selection: 'all', distribution: 'fixed', count: 2 }, + ], + minUnits: 6, + rulesRef: [{ book: Rulebook.ASCE, page: 122 }], + requirements: 'Minimum 6 units. All must be aerospace or conventional fighters. More than 50% must have the Interceptor role.', + }, }, // @@ -1147,16 +1205,20 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'aerospace-superiority-squadron', name: 'Aerospace Superiority', description: 'An air-superiority formation balancing speed, firepower, and armor to defeat opposing aerospace units.', - effectDescription: 'Prior to the start of the scenario, select up to 50% of the units and assign up to 2 of the following SPAs (in any combination): Blood Stalker, Ride the Wash, Hot Dog.', - effectGroups: [{ - abilityIds: ['blood_stalker', 'ride_the_wash', 'hot_dog'], - selection: 'choose-each', - distribution: 'up-to-50-percent', - maxPerUnit: 2, - }], - minUnits: 6, - rulesRef: [{ book: Rulebook.CO, page: 67 }, { book: Rulebook.ASCE, page: 122 }], - requirements: () => 'Minimum 6 units. All must be aerospace units. More than 50% must have the Interceptor or Fast Dogfighter role.', + classic: { + effectDescription: 'Before the scenario, assign up to two of Blood Stalker, Hot Dog, and Ride the Wash—in any combination—to up to half the units.', + effectGroups: [{ abilityIds: ['blood_stalker', 'ride_the_wash', 'hot_dog'], selection: 'choose-each', distribution: 'up-to-50-percent', maxPerUnit: 2 }], + minUnits: 6, + rulesRef: [{ book: Rulebook.CO, page: 67 }], + requirements: 'Minimum 6 units. All must be aerospace or conventional fighters. More than 50% must have the Interceptor or Fast Dogfighter role.', + }, + alphaStrike: { + effectDescription: 'Before the scenario, assign up to two of Blood Stalker, Hot Dog, and Ride the Wash—in any combination—to up to half the units.', + effectGroups: [{ abilityIds: ['blood_stalker', 'ride_the_wash', 'hot_dog'], selection: 'choose-each', distribution: 'up-to-50-percent', maxPerUnit: 2 }], + minUnits: 6, + rulesRef: [{ book: Rulebook.ASCE, page: 122 }], + requirements: 'Minimum 6 units. All must be aerospace or conventional fighters. More than 50% must have the Interceptor or Fast Dogfighter role.', + }, }, // @@ -1168,16 +1230,20 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'fire-support-squadron', name: 'Fire Support', description: 'Long-range aerospace formations optimized for ground attack that can also back up interceptors and strike fighters.', - effectDescription: 'Prior to the start of the scenario, choose 2 pairs of fighters and assign one SPA each pair: Golden Goose, Ground Hugger, Hot Dog, or Shaky Stick. The two pairs may not receive the same SPA.', - effectGroups: [{ - abilityIds: ['golden_goose', 'ground_hugger', 'hot_dog', 'shaky_stick'], - selection: 'choose-each', - distribution: 'fixed-pairs', - count: 2, - }], - minUnits: 6, - rulesRef: [{ book: Rulebook.CO, page: 68 }, { book: Rulebook.ASCE, page: 122 }], - requirements: () => 'Minimum 6 units. All must be aerospace units. 50% or more must have the Fire Support role. At least 1 Dogfighter.', + classic: { + effectDescription: 'Before the scenario, choose two fighter pairs and assign one SPA to each pair: Golden Goose, Ground Hugger, Hot Dog, or Shaky Stick. The pairs may not receive the same SPA.', + effectGroups: [{ abilityIds: ['golden_goose', 'ground_hugger', 'hot_dog', 'shaky_stick'], selection: 'choose-each', distribution: 'fixed-pairs', count: 2 }], + minUnits: 6, + rulesRef: [{ book: Rulebook.CO, page: 68 }], + requirements: 'Minimum 6 units. All must be aerospace or conventional fighters. At least 50% must have the Fire Support role; every remaining unit must have the Dogfighter role.', + }, + alphaStrike: { + effectDescription: 'Before the scenario, choose two fighter pairs and assign one SPA to each pair: Golden Goose, Ground Hugger, Hot Dog, or Shaky Stick. The pairs may not receive the same SPA.', + effectGroups: [{ abilityIds: ['golden_goose', 'ground_hugger', 'hot_dog', 'shaky_stick'], selection: 'choose-each', distribution: 'fixed-pairs', count: 2 }], + minUnits: 6, + rulesRef: [{ book: Rulebook.ASCE, page: 122 }], + requirements: 'Minimum 6 units. All must be aerospace or conventional fighters. At least 50% must have the Fire Support role; every remaining unit must have the Dogfighter role.', + }, }, // @@ -1188,22 +1254,26 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'strike-squadron', name: 'Strike', description: 'Aerospace formations for close air support and air-to-ground attacks, balancing potent firepower with reliable armor.', - effectDescription: 'Up to 50% of the units may receive the Speed Demon SPA. The remaining fighters receive the Golden Goose SPA.', - effectGroups: [ - { - abilityIds: ['speed_demon'], - selection: 'all', - distribution: 'up-to-50-percent', - }, - { - abilityIds: ['golden_goose'], - selection: 'all', - distribution: 'remainder', - }, - ], - minUnits: 6, - rulesRef: [{ book: Rulebook.CO, page: 68 }, { book: Rulebook.ASCE, page: 122 }], - requirements: () => 'Minimum 6 units. All must be aerospace units. More than 50% must have an Attack or Dogfighter role.', + classic: { + effectDescription: 'Up to 50% of the units may receive Speed Demon. The remaining fighters receive Golden Goose.', + effectGroups: [ + { abilityIds: ['speed_demon'], selection: 'all', distribution: 'up-to-50-percent' }, + { abilityIds: ['golden_goose'], selection: 'all', distribution: 'remainder' }, + ], + minUnits: 6, + rulesRef: [{ book: Rulebook.CO, page: 68 }], + requirements: 'Minimum 6 units. All must be aerospace or conventional fighters. More than 50% must have an Attack or Dogfighter role.', + }, + alphaStrike: { + effectDescription: 'Up to 50% of the units may receive Speed Demon. The remaining fighters receive Golden Goose.', + effectGroups: [ + { abilityIds: ['speed_demon'], selection: 'all', distribution: 'up-to-50-percent' }, + { abilityIds: ['golden_goose'], selection: 'all', distribution: 'remainder' }, + ], + minUnits: 6, + rulesRef: [{ book: Rulebook.ASCE, page: 122 }], + requirements: 'Minimum 6 units. All must be aerospace or conventional fighters. More than 50% must have an Attack or Dogfighter role.', + }, }, // @@ -1214,24 +1284,42 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'electronic-warfare-squadron', name: 'Electronic Warfare', description: 'Aerospace support formations that disrupt enemy communications while countering hostile electronic warfare.', - effectDescription: 'This squadron receives the Communications Disruption Special Command Ability, enabling it to disrupt the communications of one randomly-determined enemy lance or squadron on a 1D6 roll of 6 (persists one turn).', - effectGroups: [{ - formationWideAbilities: [{ - id: 'communications_disruption', - name: 'Communications Disruption', - summary: [ - 'Each turn roll 1D6; on a 6, one random enemy lance/Star/Level II reduces Move by [[4]] (min [[1]]) for the turn.', - 'Aerospace elements reduce base Thrust by 1 instead. Requires a 2:1 Battlefield Intelligence ratio if BI rules are in play.', - ], - rulesRef: [{ book: Rulebook.CO, page: 84 }, { book: Rulebook.ASCE, page: 103 }], + classic: { + effectDescription: 'This squadron receives the Communications Disruption Special Command Ability. At the start of the Electronic Warfare Squadron\'s turn, roll 1D6; on a 6, one randomly determined enemy lance or squadron suffers Communications Disruption for one turn. If the full Special Command Abilities rules are in use and the force already has Communications Disruption, choose the affected enemy lance or squadron instead of determining it randomly. Ground units can be affected only while at least one Electronic Warfare Squadron unit is flying over the map where they are operating.', + effectGroups: [{ + formationWideAbilities: [{ + id: 'communications_disruption', + name: 'Communications Disruption', + summary: [ + 'Before play, designate opposing lances up to the number controlled by the force commander.', + 'Each turn roll 1D6; on a 6, one random designated lance may expend only Walking, Cruising, or Safe Thrust movement that turn.', + 'Units using only Jumping, VTOL, or UMU movement are unaffected.', + ], + rulesRef: [{ book: Rulebook.CO, page: 84 }], + }], + distribution: 'formation-wide', + }], + minUnits: 6, + rulesRef: [{ book: Rulebook.CO, page: 67 }], + requirements: 'Minimum 6 units. All must be aerospace or conventional fighters. More than 50% must have ECM, BAP, or TAG equipment.', + }, + alphaStrike: { + effectDescription: 'This squadron receives the Communications Disruption Special Command Ability. At the start of the Electronic Warfare Squadron\'s turn, roll 1D6; on a 6, one randomly determined enemy lance or squadron suffers Communications Disruption for one turn. If the full Special Command Abilities rules are in use and the force already has Communications Disruption, choose the affected enemy lance or squadron instead of determining it randomly. Ground units can be affected only while at least one Electronic Warfare Squadron unit is flying over the map where they are operating.', + effectGroups: [{ + formationWideAbilities: [{ + id: 'communications_disruption', + name: 'Communications Disruption', + summary: [ + 'Each turn roll 1D6; on a 6, one random enemy lance, Star, or Level II reduces Move by [[4]] (minimum [[1]]) for the turn.', + 'Aerospace elements reduce base Thrust by 1 instead. Requires a 2:1 Battlefield Intelligence ratio if BI rules are in play.', + ], + rulesRef: [{ book: Rulebook.ASCE, page: 103 }], + }], + distribution: 'formation-wide', }], - distribution: 'formation-wide', - }], - minUnits: 6, - rulesRef: [{ book: Rulebook.CO, page: 67 }, { book: Rulebook.ASCE, page: 122 }], - requirements: (gameSystem) => { - const equipment = gameSystem === GameSystem.ALPHA_STRIKE ? 'EW specials (PRB, AECM, ECM, TAG, etc.)' : 'ECM, BAP, or TAG'; - return `Minimum 6 units. All must be aerospace units. More than 50% must have ${equipment}.`; + minUnits: 6, + rulesRef: [{ book: Rulebook.ASCE, page: 122 }], + requirements: 'Minimum 6 units. All must be aerospace or conventional fighters. More than 50% must have EW specials (PRB, AECM, ECM, TAG, etc.).', }, }, @@ -1243,150 +1331,167 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinition[] = [ id: 'transport-squadron', name: 'Transport', description: 'Cargo and troop-moving aerospace formations escorted by fighters, avoiding air battles when possible.', - effectDescription: 'Choose one SPA to apply to all Transport-role units: Dust-Off, Ride the Wash, or Wind Walker.', - effectGroups: [{ - abilityIds: ['dust_off', 'ride_the_wash', 'wind_walker'], - selection: 'choose-one', - distribution: 'role-filtered', - roleFilter: 'Transport', - }], - minUnits: 6, - rulesRef: [{ book: Rulebook.CO, page: 68 }, { book: Rulebook.ASCE, page: 123 }], - requirements: (gameSystem) => { - const aerospaceType = gameSystem === GameSystem.ALPHA_STRIKE ? 'type (AF, CF, SC, DS, SV, or DA)' : 'units'; - return `Minimum 6 units. All must be aerospace ${aerospaceType}. 50% or more must have the Transport role.`; + classic: { + effectDescription: 'Choose Dust-Off, Ride the Wash, or Wind Walker and apply it to all Transport-role units.', + effectGroups: [{ abilityIds: ['dust_off', 'ride_the_wash', 'wind_walker'], selection: 'choose-one', distribution: 'role-filtered', roleFilter: 'Transport' }], + minUnits: 6, + rulesRef: [{ book: Rulebook.CO, page: 68 }], + requirements: 'Minimum 6 units. All must be support aircraft, conventional fighters, aerospace fighters, Small Craft, or DropShips. 50% or more must have the Transport role.', + }, + alphaStrike: { + effectDescription: 'Choose Dust-Off, Ride the Wash, or Wind Walker and apply it to all Transport-role units.', + effectGroups: [{ abilityIds: ['dust_off', 'ride_the_wash', 'wind_walker'], selection: 'choose-one', distribution: 'role-filtered', roleFilter: 'Transport' }], + minUnits: 6, + rulesRef: [{ book: Rulebook.ASCE, page: 123 }], + requirements: 'Minimum 6 units. All must be AF, CF, SC, DS/DA, or airborne SV units (airships or fixed-wing support vehicles). 50% or more must have the Transport role.', }, }, ]; -const FORMATION_RUNTIME_DEFINITION_BY_ID = new Map( +const FORMATION_RUNTIME_DEFINITION_SOURCE_BY_ID = new Map( FORMATION_RUNTIME_DEFINITIONS.map((definition) => [definition.id, definition]), ); -export function getFormationDefinition(id: string): FormationTypeDefinition | null { - return FORMATION_RUNTIME_DEFINITION_BY_ID.get(id) ?? null; +const FORMATION_RUNTIME_DEFINITIONS_BY_GAME_SYSTEM: Readonly> = { + [GameSystem.CLASSIC]: FORMATION_RUNTIME_DEFINITIONS.map((definition) => ( + resolveFormationTypeDefinition(definition, GameSystem.CLASSIC) + )), + [GameSystem.ALPHA_STRIKE]: FORMATION_RUNTIME_DEFINITIONS.map((definition) => ( + resolveFormationTypeDefinition(definition, GameSystem.ALPHA_STRIKE) + )), +}; + +const FORMATION_RUNTIME_DEFINITION_BY_GAME_SYSTEM_AND_ID: Readonly>> = { + [GameSystem.CLASSIC]: new Map(FORMATION_RUNTIME_DEFINITIONS_BY_GAME_SYSTEM[GameSystem.CLASSIC].map((definition) => [definition.id, definition])), + [GameSystem.ALPHA_STRIKE]: new Map(FORMATION_RUNTIME_DEFINITIONS_BY_GAME_SYSTEM[GameSystem.ALPHA_STRIKE].map((definition) => [definition.id, definition])), +}; + +export function getFormationDefinitionSource(id: string): FormationTypeDefinitionSource | null { + return FORMATION_RUNTIME_DEFINITION_SOURCE_BY_ID.get(id) ?? null; } -export function getFormationDefinitions(): readonly FormationTypeDefinition[] { - return FORMATION_RUNTIME_DEFINITIONS; +export function getFormationDefinition(id: string, gameSystem: GameSystem): FormationTypeDefinition | null { + return FORMATION_RUNTIME_DEFINITION_BY_GAME_SYSTEM_AND_ID[gameSystem].get(id) ?? null; } -export const FORMATION_BLUEPRINTS: Readonly> = { - 'anti-mech-lance': { id: 'anti-mech-lance', constraints: [all('anti-mech-all-infantry', 'All infantry units', 'infantry-unit')] }, - 'assault-lance': { id: 'assault-lance', constraints: assaultLanceConstraints }, - 'anvil-lance': { - id: 'anvil-lance', - constraints: [ - all('anvil-medium-plus', 'All medium+/Size 2+ units', 'medium-plus-size'), - all('anvil-armor', 'All armor threshold', 'anvil-armor'), - percent('anvil-weapons', '50% AC/FLK/LRM/SRM units', 'anvil-weapon', 0.5), - ], - }, - 'fast-assault-lance': { id: 'fast-assault-lance', constraints: [...assaultLanceConstraints, all('fast-assault-move', 'All fast assault movement', 'fast-assault-move')] }, - 'hunter-lance': { id: 'hunter-lance', constraints: [percent('hunter-role-percent', '50% Ambusher/Juggernaut units', 'hunter-role', 0.5)] }, - 'battle-lance': { id: 'battle-lance', constraints: battleLanceConstraints }, - 'light-battle-lance': { - id: 'light-battle-lance', - constraints: [ - percent('light-battle-light-percent', '75% light/Size 1 units', 'light-size', 0.75), - all('light-battle-no-assault', 'No assault/Size 4+ units', 'ranger-size'), - countMin('light-battle-scout', '1 Scout', 'scout-role', 1), - matchedPairs('light-battle-vehicle-pairs', '2 matched light vehicle pairs', 'light-size', 2, 'combat-vehicle'), - ], - }, - 'medium-battle-lance': { - id: 'medium-battle-lance', - constraints: [ - percent('medium-battle-medium-percent', '50% medium/Size 2 units', 'medium-size', 0.5), - all('medium-battle-no-assault', 'No assault/Size 4+ units', 'ranger-size'), - matchedPairs('medium-battle-vehicle-pairs', '2 matched medium vehicle pairs', 'medium-size', 2, 'combat-vehicle'), - ], - }, - 'heavy-battle-lance': { - id: 'heavy-battle-lance', - constraints: [ - percent('heavy-battle-heavy-percent', '50% heavy/Size 3+ units', 'heavy-size', 0.5), - countMax('heavy-battle-no-light', 'No light/Size 1 units', 'light-size', 0), - matchedPairs('heavy-battle-vehicle-pairs', '2 matched heavy vehicle pairs', 'heavy-size', 2, 'combat-vehicle'), - ], - }, - 'rifle-lance': { - id: 'rifle-lance', - constraints: [ - percent('rifle-medium-heavy', '75% medium/heavy or Size 2-3 units', 'rifle-medium-heavy-size', 0.75), - percent('rifle-autocannon', '50% autocannon units', 'rifle-autocannon', 0.5), - all('rifle-move', 'All rifle movement threshold', 'rifle-move'), - ], - }, - 'berserker-lance': { id: 'berserker-lance', constraints: battleLanceConstraints }, - 'command-lance': { - id: 'command-lance', - constraints: [ - percent('command-heavy-roles', '50% command heavy roles', 'command-heavy-role', 0.5), - countMin('command-diverse-role', '1 Brawler/Striker/Scout', 'command-diverse-role', 1), - ], - }, - 'order-lance': { id: 'order-lance', constraints: [sameTier('order-same-tier', 'Same Size/weight class'), sameChassis('order-same-chassis', 'Same chassis')] }, - 'vehicle-command-lance': { - id: 'vehicle-command-lance', - constraints: [ - all('vehicle-command-all-vehicles', 'All combat vehicles', 'combat-vehicle'), - matchedPairs('vehicle-command-command-pair', '1 matched command-role pair', 'command-heavy-role', 1), - ], - }, - 'fire-lance': { id: 'fire-lance', constraints: fireLanceConstraints }, - 'anti-air-lance': { id: 'anti-air-lance', constraints: [...fireLanceConstraints, countMin('anti-air-equipment-count', '2 anti-air equipped units', 'anti-air-equipment', 2)] }, - 'artillery-fire-lance': { id: 'artillery-fire-lance', constraints: [countMin('artillery-count', '2 artillery units', 'artillery-equipment', 2)] }, - 'direct-fire-lance': { id: 'direct-fire-lance', constraints: [countMin('direct-fire-heavy-count', '2 heavy/Size 3+ units', 'heavy-size', 2), all('direct-fire-damage', 'All direct-fire damage threshold', 'direct-fire-damage')] }, - 'fire-support-lance': { id: 'fire-support-lance', constraints: [countMin('fire-support-equipment-count', '3 indirect-fire units', 'fire-support-equipment', 3)] }, - 'light-fire-lance': { id: 'light-fire-lance', constraints: [countMax('light-fire-no-heavy', 'No heavy/Size 3+ units', 'heavy-size', 0), percent('light-fire-role-percent', '50% Missile Boat/Sniper units', 'light-fire-role', 0.5)] }, - 'pursuit-lance': { id: 'pursuit-lance', constraints: [countMax('pursuit-no-heavy', 'All light-medium/Size <= 2 units', 'heavy-size', 0), percent('pursuit-move-percent', '75% pursuit movement threshold', 'pursuit-move', 0.75), countMin('pursuit-range', '1 medium range damage unit', 'medium-damage-positive', 1)] }, - 'probe-lance': { id: 'probe-lance', constraints: [all('probe-no-assault', 'No assault/Size 4+ units', 'ranger-size'), percent('probe-move-percent', '75% probe movement threshold', 'probe-move', 0.75), all('probe-damage', 'All medium damage threshold', 'medium-damage-2')] }, - 'sweep-lance': { id: 'sweep-lance', constraints: [countMax('sweep-no-heavy', 'All light-medium/Size <= 2 units', 'heavy-size', 0), all('sweep-move', 'All sweep movement threshold', 'sweep-move'), all('sweep-damage', 'All short damage threshold', 'short-damage-2')] }, - 'recon-lance': { id: 'recon-lance', constraints: [all('recon-move', 'All recon movement threshold', 'recon-move'), countMin('recon-role-count', '2 Scout/Striker units', 'scout-or-striker-role', 2)] }, - 'heavy-recon-lance': { id: 'heavy-recon-lance', constraints: [all('heavy-recon-move', 'All heavy recon movement threshold', 'heavy-recon-move'), countMin('heavy-recon-fast-count', '2 faster units', 'recon-move', 2), countMin('heavy-recon-heavy-count', '1 heavy/Size 3+ unit', 'heavy-size', 1), countMin('heavy-recon-scout-count', '2 Scout units', 'scout-role', 2)] }, - 'light-recon-lance': { id: 'light-recon-lance', constraints: [all('light-recon-light', 'All light/Size 1 units', 'light-size'), all('light-recon-fast', 'All very fast units', 'very-fast-move'), all('light-recon-scout', 'All Scout units', 'scout-role')] }, - 'security-lance': { id: 'security-lance', constraints: [countMax('security-assault-max', 'At most 1 assault/Size 4+ unit', 'assault-size', 1), countMin('security-light-role', '1 Scout/Striker', 'security-light-role', 1), countMin('security-heavy-role', '1 Sniper/Missile Boat', 'security-heavy-role', 1)] }, - 'striker-lance': { id: 'striker-lance', constraints: [all('striker-speed', 'All striker movement threshold', 'striker-speed'), countMax('striker-no-assault', 'No assault/Size 4+ units', 'assault-size', 0), percent('striker-role-percent', '50% Striker/Skirmisher units', 'striker-or-skirmisher-role', 0.5)] }, - 'hammer-lance': { id: 'hammer-lance', constraints: [all('hammer-move', 'All hammer movement threshold', 'recon-move')] }, - 'light-striker-lance': { id: 'light-striker-lance', constraints: [all('light-striker-move', 'All light striker movement threshold', 'recon-move'), countMax('light-striker-no-heavy', 'No heavy/Size 3+ units', 'heavy-size', 0), countMin('light-striker-long-damage', '2 long damage units', 'long-damage-positive', 2), countMin('light-striker-role-count', '2 Striker/Skirmisher units', 'striker-or-skirmisher-role', 2)] }, - 'heavy-striker-lance': { id: 'heavy-striker-lance', constraints: [all('heavy-striker-move', 'All heavy striker movement threshold', 'heavy-recon-move'), countMin('heavy-striker-heavy-count', '3 heavy/Size 3+ units', 'heavy-size', 3), countMax('heavy-striker-no-light', 'No light/Size 1 units', 'light-size', 0), countMin('heavy-striker-long-damage', '1 strong long damage unit', 'long-damage-strong', 1), countMin('heavy-striker-role-count', '2 Striker/Skirmisher units', 'striker-or-skirmisher-role', 2)] }, - horde: { id: 'horde', constraints: [all('horde-all-light', 'All light/Size 1 units', 'light-size'), all('horde-low-damage', 'All low medium-range damage units', 'low-medium-damage')] }, - swarm: { id: 'swarm', constraints: [all('swarm-all-vtol', 'All VTOL units', 'vtol-unit'), countMax('swarm-no-heavy', 'No heavy/Size 3+ units', 'heavy-size', 0)] }, - 'ranger-lance': { id: 'ranger-lance', constraints: [all('ranger-no-assault', 'No assault/Size 4+ units', 'ranger-size')] }, - 'support-lance': { id: 'support-lance', constraints: [] }, - 'urban-lance': { id: 'urban-lance', constraints: [percent('urban-jump-infantry', '50% jump or infantry units', 'jump-or-infantry', 0.5), percent('urban-slow', '50% slow urban units', 'slow-urban-move', 0.5)] }, - 'phalanx-star': { - id: 'phalanx-star', - constraints: [ - ...clanOnlyConstraints, - all('phalanx-allowed', 'All allowed phalanx unit types', 'phalanx-allowed-unit'), - anyOf('phalanx-shape', 'Phalanx combined-arms shape', [ - allOf('phalanx-bm-core', 'BM/Mek core plus support', [countMin('phalanx-bm-count', '2 BM/Mek units', 'phalanx-bm-or-mek', 2), countMin('phalanx-ba-cv-count', '1 BA/CV unit', 'phalanx-ba-or-cv', 1)]), - allOf('phalanx-cv-core', 'CV core plus support', [countMin('phalanx-cv-count', '2 CV units', 'phalanx-cv', 2), countMin('phalanx-bm-ba-count', '1 BM/BA unit', 'phalanx-bm-or-ba', 1)]), - ]), +export function getFormationDefinitions(gameSystem: GameSystem): readonly FormationTypeDefinition[] { + return FORMATION_RUNTIME_DEFINITIONS_BY_GAME_SYSTEM[gameSystem]; +} + +export const FORMATION_BLUEPRINTS: Readonly> = { + 'anti-mech-lance': sharedBlueprint('anti-mech-lance', [all('anti-mech-all-infantry', 'All infantry units', 'infantry-unit')]), + 'assault-lance': sharedBlueprint('assault-lance', assaultLanceConstraints), + 'anvil-lance': sharedBlueprint('anvil-lance', [ + all('anvil-medium-plus', 'All medium+/Size 2+ units', 'medium-plus-size'), + all('anvil-armor', 'All armor threshold', 'anvil-armor'), + percent('anvil-weapons', '50% AC/FLK/LRM/SRM units', 'anvil-weapon', 0.5), + ]), + 'fast-assault-lance': sharedBlueprint('fast-assault-lance', [...assaultLanceConstraints, all('fast-assault-move', 'All fast assault movement', 'fast-assault-move')]), + 'hunter-lance': sharedBlueprint('hunter-lance', [percent('hunter-role-percent', '50% Ambusher/Juggernaut units', 'hunter-role', 0.5)]), + 'battle-lance': { id: 'battle-lance', classic: classicBattleLanceConstraints, alphaStrike: battleLanceCoreConstraints }, + 'light-battle-lance': sharedBlueprint('light-battle-lance', [ + percent('light-battle-light-percent', '75% light/Size 1 units', 'light-size', 0.75), + all('light-battle-no-assault', 'No assault/Size 4+ units', 'ranger-size'), + countMin('light-battle-scout', '1 Scout', 'scout-role', 1), + matchedPairs('light-battle-vehicle-pairs', '2 matched light vehicle pairs', 'light-size', 2, 'combat-vehicle'), + ]), + 'medium-battle-lance': sharedBlueprint('medium-battle-lance', [ + percent('medium-battle-medium-percent', '50% medium/Size 2 units', 'medium-size', 0.5), + all('medium-battle-no-assault', 'No assault/Size 4+ units', 'ranger-size'), + matchedPairs('medium-battle-vehicle-pairs', '2 matched medium vehicle pairs', 'medium-size', 2, 'combat-vehicle'), + ]), + 'heavy-battle-lance': sharedBlueprint('heavy-battle-lance', [ + percent('heavy-battle-heavy-percent', '50% heavy/Size 3+ units', 'heavy-size', 0.5), + countMax('heavy-battle-no-light', 'No light/Size 1 units', 'light-size', 0), + matchedPairs('heavy-battle-vehicle-pairs', '2 matched heavy vehicle pairs', 'heavy-size', 2, 'combat-vehicle'), + ]), + 'rifle-lance': sharedBlueprint('rifle-lance', [ + percent('rifle-medium-heavy', '75% medium/heavy or Size 2-3 units', 'rifle-medium-heavy-size', 0.75), + percent('rifle-autocannon', '50% autocannon units', 'rifle-autocannon', 0.5), + all('rifle-move', 'All rifle movement threshold', 'rifle-move'), + ]), + 'berserker-lance': { id: 'berserker-lance', classic: classicBattleLanceConstraints, alphaStrike: battleLanceCoreConstraints }, + 'command-lance': sharedBlueprint('command-lance', [ + percent('command-heavy-roles', '50% command heavy roles', 'command-heavy-role', 0.5), + countMin('command-diverse-role', '1 Brawler/Striker/Scout', 'command-diverse-role', 1), + ]), + 'order-lance': sharedBlueprint('order-lance', [sameTier('order-same-tier', 'Same Size/weight class'), sameChassis('order-same-chassis', 'Same chassis')]), + 'vehicle-command-lance': sharedBlueprint('vehicle-command-lance', [ + all('vehicle-command-all-vehicles', 'All combat vehicles', 'combat-vehicle'), + countMin('vehicle-command-command-pair', '2 command-role vehicles', 'command-heavy-role', 2), + ]), + 'fire-lance': sharedBlueprint('fire-lance', fireLanceConstraints), + 'anti-air-lance': sharedBlueprint('anti-air-lance', [...fireLanceConstraints, countMin('anti-air-equipment-count', '2 anti-air equipped units', 'anti-air-equipment', 2)]), + 'artillery-fire-lance': sharedBlueprint('artillery-fire-lance', [countMin('artillery-count', '2 artillery units', 'artillery-equipment', 2)]), + 'direct-fire-lance': sharedBlueprint('direct-fire-lance', [countMin('direct-fire-heavy-count', '2 heavy/Size 3+ units', 'heavy-size', 2), all('direct-fire-damage', 'All direct-fire damage threshold', 'direct-fire-damage')]), + 'fire-support-lance': sharedBlueprint('fire-support-lance', [countMin('fire-support-equipment-count', '3 indirect-fire units', 'fire-support-equipment', 3)]), + 'light-fire-lance': sharedBlueprint('light-fire-lance', [countMax('light-fire-no-heavy', 'No heavy/Size 3+ units', 'heavy-size', 0), percent('light-fire-role-percent', '50% Missile Boat/Sniper units', 'light-fire-role', 0.5)]), + 'pursuit-lance': { + id: 'pursuit-lance', + classic: [ + countMax('pursuit-no-heavy', 'All light-medium units', 'heavy-size', 0), + percent('pursuit-move-percent', '75% pursuit movement threshold', 'pursuit-move', 0.75), + countMin('pursuit-range', '1 medium range damage unit', 'medium-damage-positive', 1), ], - }, - 'rogue-star': { id: 'rogue-star', constraints: [...clanOnlyConstraints, matchedPairs('rogue-model-pair', 'At least two same model/name units', 'clan-force', 1)] }, - 'strategic-command-star': { - id: 'strategic-command-star', - constraints: [ - ...clanOnlyConstraints, - all('strategic-skill', 'All skill 3 or lower', 'strategic-skill-3'), - all('strategic-allowed', 'All strategic command unit types', 'aerospace-fighter-bm-ba-unit'), - countExact('strategic-aero-count', 'Exactly 2 aerospace units', 'strategic-aero', 2), - conditional('strategic-mek-conditions', 'BM/Mek heavy and no-light conditions', 'bm-or-mek-unit', [countMin('strategic-heavy-mek-count', '2 heavy BM/Mek units', 'heavy-bm-or-mek', 2), countMax('strategic-light-mek-count', 'No light BM/Mek units', 'light-bm-or-mek', 0)]), - anyOf('strategic-core', 'BM/Mek or BA core', [countMin('strategic-bm-count', '2 BM/Mek units', 'bm-or-mek-unit', 2), countMin('strategic-ba-count', '1 BA unit', 'battle-armor-unit', 1)]), + alphaStrike: [ + countMax('pursuit-no-heavy', 'All Size 2 or smaller units', 'heavy-size', 0), + percentNormally('pursuit-move-percent', '75% pursuit movement threshold (round normally)', 'pursuit-move', 0.75), + countMin('pursuit-range', '1 medium range damage unit', 'medium-damage-positive', 1), ], }, - 'interceptor-squadron': { id: 'interceptor-squadron', constraints: [all('interceptor-all-aerospace', 'All aerospace units', 'aerospace-unit'), strictMajority('interceptor-role-majority', 'Strict majority Interceptor role', 'interceptor-role')] }, - 'aerospace-superiority-squadron': { id: 'aerospace-superiority-squadron', constraints: [all('aerospace-superiority-all-aerospace', 'All aerospace units', 'aerospace-unit'), strictMajority('aerospace-superiority-role-majority', 'Strict majority Interceptor/Fast Dogfighter role', 'aerospace-superiority-role')] }, - 'fire-support-squadron': { id: 'fire-support-squadron', constraints: [all('fire-support-squadron-all-aerospace', 'All aerospace units', 'aerospace-unit'), percent('fire-support-squadron-role', '50% Fire Support role', 'fire-support-role', 0.5), countMin('fire-support-squadron-dogfighter', '1 Dogfighter role', 'dogfighter-role', 1)] }, - 'strike-squadron': { id: 'strike-squadron', constraints: [all('strike-all-aerospace', 'All aerospace units', 'aerospace-unit'), strictMajority('strike-role-majority', 'Strict majority Attack/Dogfighter role', 'attack-or-dogfighter-role')] }, - 'electronic-warfare-squadron': { id: 'electronic-warfare-squadron', constraints: [all('ew-all-aerospace', 'All aerospace units', 'aerospace-unit'), strictMajority('ew-equipment-majority', 'Strict majority EW equipment', 'ew-equipment')] }, - 'transport-squadron': { id: 'transport-squadron', constraints: [all('transport-all-aerospace', 'All transport aerospace units', 'transport-squadron-unit'), percent('transport-role-percent', '50% Transport role', 'transport-role', 0.5)] }, + 'probe-lance': sharedBlueprint('probe-lance', [all('probe-no-assault', 'No assault/Size 4+ units', 'ranger-size'), percent('probe-move-percent', '75% probe movement threshold', 'probe-move', 0.75), all('probe-damage', 'All medium damage threshold', 'medium-damage-2')]), + 'sweep-lance': sharedBlueprint('sweep-lance', [countMax('sweep-no-heavy', 'All light-medium/Size <= 2 units', 'heavy-size', 0), all('sweep-move', 'All sweep movement threshold', 'sweep-move'), all('sweep-damage', 'All short damage threshold', 'short-damage-2')]), + 'recon-lance': sharedBlueprint('recon-lance', [all('recon-move', 'All recon movement threshold', 'recon-move'), countMin('recon-role-count', '2 Scout/Striker units', 'scout-or-striker-role', 2)]), + 'heavy-recon-lance': sharedBlueprint('heavy-recon-lance', [all('heavy-recon-move', 'All heavy recon movement threshold', 'heavy-recon-move'), countMin('heavy-recon-fast-count', '2 faster units', 'recon-move', 2), countMin('heavy-recon-heavy-count', '1 heavy/Size 3+ unit', 'heavy-size', 1), countMin('heavy-recon-scout-count', '2 Scout units', 'scout-role', 2)]), + 'light-recon-lance': sharedBlueprint('light-recon-lance', [all('light-recon-light', 'All light/Size 1 units', 'light-size'), all('light-recon-fast', 'All very fast units', 'very-fast-move'), all('light-recon-scout', 'All Scout units', 'scout-role')]), + 'security-lance': sharedBlueprint('security-lance', [countMax('security-assault-max', 'At most 1 assault/Size 4+ unit', 'assault-size', 1), countMin('security-light-role', '1 Scout/Striker', 'security-light-role', 1), countMin('security-heavy-role', '1 Sniper/Missile Boat', 'security-heavy-role', 1)]), + 'striker-lance': sharedBlueprint('striker-lance', [all('striker-speed', 'All striker movement threshold', 'striker-speed'), countMax('striker-no-assault', 'No assault/Size 4+ units', 'assault-size', 0), percent('striker-role-percent', '50% Striker/Skirmisher units', 'striker-or-skirmisher-role', 0.5)]), + 'hammer-lance': sharedBlueprint('hammer-lance', [all('hammer-move', 'All hammer movement threshold', 'recon-move')]), + 'light-striker-lance': sharedBlueprint('light-striker-lance', [all('light-striker-move', 'All light striker movement threshold', 'recon-move'), countMax('light-striker-no-heavy', 'No heavy/Size 3+ units', 'heavy-size', 0), countMin('light-striker-long-damage', '2 long damage units', 'long-damage-positive', 2), countMin('light-striker-role-count', '2 Striker/Skirmisher units', 'striker-or-skirmisher-role', 2)]), + 'heavy-striker-lance': sharedBlueprint('heavy-striker-lance', [all('heavy-striker-move', 'All heavy striker movement threshold', 'heavy-recon-move'), countMin('heavy-striker-heavy-count', '3 heavy/Size 3+ units', 'heavy-size', 3), countMax('heavy-striker-no-light', 'No light/Size 1 units', 'light-size', 0), countMin('heavy-striker-long-damage', '1 strong long damage unit', 'long-damage-strong', 1), countMin('heavy-striker-role-count', '2 Striker/Skirmisher units', 'striker-or-skirmisher-role', 2)]), + horde: sharedBlueprint('horde', [all('horde-all-light', 'All light/Size 1 units', 'light-size'), all('horde-low-damage', 'All low medium-range damage units', 'low-medium-damage')]), + swarm: sharedBlueprint('swarm', [all('swarm-all-vtol', 'All VTOL units', 'vtol-unit'), countMax('swarm-no-heavy', 'No heavy/Size 3+ units', 'heavy-size', 0)]), + 'ranger-lance': sharedBlueprint('ranger-lance', [all('ranger-no-assault', 'No assault/Size 4+ units', 'ranger-size')]), + 'support-lance': sharedBlueprint('support-lance', []), + 'urban-lance': sharedBlueprint('urban-lance', [percent('urban-jump-infantry', '50% jump or infantry units', 'jump-or-infantry', 0.5), percent('urban-slow', '50% slow urban units', 'slow-urban-move', 0.5)]), + 'phalanx-star': sharedBlueprint('phalanx-star', [ + ...clanOnlyConstraints, + all('phalanx-allowed', 'All allowed phalanx unit types', 'phalanx-allowed-unit'), + anyOf('phalanx-shape', 'Phalanx combined-arms shape', [ + allOf('phalanx-bm-core', 'BM/Mek core plus support', [countMin('phalanx-bm-count', '2 BM/Mek units', 'phalanx-bm-or-mek', 2), countMin('phalanx-ba-cv-count', '1 BA/CV unit', 'phalanx-ba-or-cv', 1)]), + allOf('phalanx-cv-core', 'CV core plus support', [countMin('phalanx-cv-count', '2 CV units', 'phalanx-cv', 2), countMin('phalanx-bm-ba-count', '1 BM/BA unit', 'phalanx-bm-or-ba', 1)]), + ]), + ]), + 'rogue-star': sharedBlueprint('rogue-star', [...clanOnlyConstraints, matchedPairs('rogue-model-pair', 'At least two same model/name units', 'clan-force', 1)]), + 'strategic-command-star': sharedBlueprint('strategic-command-star', [ + ...clanOnlyConstraints, + all('strategic-skill', 'All skill 3 or lower', 'strategic-skill-3'), + all('strategic-allowed', 'All strategic command unit types', 'aerospace-fighter-bm-ba-unit'), + countExact('strategic-aero-count', 'Exactly 2 aerospace units', 'strategic-aero', 2), + conditional('strategic-mek-conditions', 'BM/Mek heavy and no-light conditions', 'bm-or-mek-unit', [countMin('strategic-heavy-mek-count', '2 heavy BM/Mek units', 'heavy-bm-or-mek', 2), countMax('strategic-light-mek-count', 'No light BM/Mek units', 'light-bm-or-mek', 0)]), + anyOf('strategic-core', 'BM/Mek or BA core', [countMin('strategic-bm-count', '2 BM/Mek units', 'bm-or-mek-unit', 2), countMin('strategic-ba-count', '1 BA unit', 'battle-armor-unit', 1)]), + ]), + 'interceptor-squadron': sharedBlueprint('interceptor-squadron', [all('interceptor-all-aerospace', 'All aerospace or conventional fighters', 'aerospace-unit'), strictMajority('interceptor-role-majority', 'Strict majority Interceptor role', 'interceptor-role')]), + 'aerospace-superiority-squadron': sharedBlueprint('aerospace-superiority-squadron', [all('aerospace-superiority-all-aerospace', 'All aerospace or conventional fighters', 'aerospace-unit'), strictMajority('aerospace-superiority-role-majority', 'Strict majority Interceptor/Fast Dogfighter role', 'aerospace-superiority-role')]), + 'fire-support-squadron': sharedBlueprint('fire-support-squadron', [ + all('fire-support-squadron-all-aerospace', 'All aerospace or conventional fighters', 'aerospace-unit'), + all('fire-support-squadron-roles', 'All Fire Support or Dogfighter roles', 'fire-support-or-dogfighter-role'), + percent('fire-support-squadron-role', '50% Fire Support role', 'fire-support-role', 0.5), + ]), + 'strike-squadron': sharedBlueprint('strike-squadron', [all('strike-all-aerospace', 'All aerospace or conventional fighters', 'aerospace-unit'), strictMajority('strike-role-majority', 'Strict majority Attack/Dogfighter role', 'attack-or-dogfighter-role')]), + 'electronic-warfare-squadron': sharedBlueprint('electronic-warfare-squadron', [all('ew-all-aerospace', 'All aerospace or conventional fighters', 'aerospace-unit'), strictMajority('ew-equipment-majority', 'Strict majority EW equipment', 'ew-equipment')]), + 'transport-squadron': sharedBlueprint('transport-squadron', [all('transport-all-aerospace', 'All permitted transport aircraft or craft', 'transport-squadron-unit'), percent('transport-role-percent', '50% Transport role', 'transport-role', 0.5)]), }; -export function getFormationBlueprint(id: string): FormationRequirementBlueprint | null { - return FORMATION_BLUEPRINTS[id] ?? null; +export function hasFormationBlueprint(id: string): boolean { + return FORMATION_BLUEPRINTS[id] !== undefined; +} + +export function getFormationBlueprint(id: string, gameSystem: GameSystem): FormationRequirementBlueprint | null { + const blueprint = FORMATION_BLUEPRINTS[id]; + if (!blueprint) return null; + return { + id: blueprint.id, + constraints: gameSystem === GameSystem.CLASSIC ? blueprint.classic : blueprint.alphaStrike, + }; } diff --git a/src/app/utils/formation-predicates.util.ts b/src/app/utils/formation-predicates.util.ts index 47f509c15..d09972d56 100644 --- a/src/app/utils/formation-predicates.util.ts +++ b/src/app/utils/formation-predicates.util.ts @@ -5,14 +5,38 @@ import { GameSystem } from '../models/common.model'; import { isClan } from './org/org-registry.util'; import type { FormationFactKey, FormationPredicateId } from './formation-requirement.model'; -import { cbtCanDealDamage, cbtHasArtillery, cbtHasAutocannon, type FormationUnitFacts } from './formation-unit-facts.util'; +import { cbtCanDealDamage, cbtHasArtillery, cbtHasAutocannon, cbtHasIndirectFireWeapon, type FormationUnitFacts } from './formation-unit-facts.util'; type FormationPredicate = (facts: FormationUnitFacts, gameSystem: GameSystem) => boolean; -const AEROSPACE_AS_TYPES = new Set(['AF', 'CF', 'SC', 'DS', 'DA', 'WS', 'SS', 'JS']); -const TRANSPORT_AS_TYPES = new Set(['AF', 'CF', 'SC', 'DS', 'SV', 'DA']); +const FIGHTER_AS_TYPES = new Set(['AF', 'CF']); +const TRANSPORT_AS_TYPES = new Set(['AF', 'CF', 'SC', 'DS', 'DA']); const EW_SPECIALS = ['PRB', 'AECM', 'BH', 'ECM', 'LPRB', 'LECM', 'LTAG', 'TAG', 'WAT']; +function isClassicFighter(facts: FormationUnitFacts): boolean { + return facts.unit.type === 'Aero' + && (facts.unit.subtype.includes('Aerospace Fighter') + || facts.unit.subtype.includes('Conventional Fighter')); +} + +function isSupportAircraft(facts: FormationUnitFacts): boolean { + return facts.unit.subtype.includes('Fixed Wing Support Vehicle'); +} + +function isTransportSquadronUnit(facts: FormationUnitFacts, gameSystem: GameSystem): boolean { + if (gameSystem === GameSystem.ALPHA_STRIKE) { + const asType = facts.asType ?? ''; + return TRANSPORT_AS_TYPES.has(asType) + || (asType === 'SV' && isSupportAircraft(facts)); + } + + return isClassicFighter(facts) + || isSupportAircraft(facts) + || (facts.unit.type === 'Aero' + && (facts.unit.subtype.includes('Small Craft') + || facts.unit.subtype.includes('DropShip'))); +} + function hasAsSpecialPrefix(facts: FormationUnitFacts, prefix: string): boolean { return facts.asSpecials.some(special => special.startsWith(prefix)); } @@ -46,6 +70,7 @@ export const FORMATION_PREDICATES: Readonly gameSystem === GameSystem.ALPHA_STRIKE ? hasAnyAsSpecialPrefix(facts, ['AC', 'FLK', 'LRM', 'SRM']) : cbtHasAutocannon(facts.unit) + || facts.unit.comp?.some(component => component.eq?.hasAnyFlag(['F_LRM', 'F_SRM']) === true) === true || facts.unit.comp?.some(component => component.n?.includes('LRM')) === true || facts.unit.comp?.some(component => component.n?.includes('SRM')) === true, 'artillery-equipment': (facts, gameSystem) => gameSystem === GameSystem.ALPHA_STRIKE @@ -60,10 +85,10 @@ export const FORMATION_PREDICATES: Readonly gameSystem === GameSystem.ALPHA_STRIKE - ? AEROSPACE_AS_TYPES.has(facts.asType ?? '') - : facts.unit.type === 'Aero', + ? FIGHTER_AS_TYPES.has(facts.asType ?? '') + : isClassicFighter(facts), 'aerospace-superiority-role': (facts) => roleIn(facts, ['Interceptor', 'Fast Dogfighter']), - 'attack-or-dogfighter-role': (facts) => roleIncludes(facts, ['Attack', 'Dogfighter']), + 'attack-or-dogfighter-role': (facts) => roleIn(facts, ['Attack', 'Attack Fighter', 'Dogfighter']), 'battle-armor-unit': (facts, gameSystem) => gameSystem === GameSystem.ALPHA_STRIKE ? facts.asType === 'BA' : facts.unit.subtype === 'Battle Armor', @@ -83,9 +108,10 @@ export const FORMATION_PREDICATES: Readonly component.eq?.hasAnyFlag(['F_ECM', 'F_BAP', 'F_TAG'])) === true, 'fast-assault-move': (facts, gameSystem) => asOrCbt(gameSystem, facts.asGroundMove >= 10 || facts.asJumpMove > 0, facts.cbtWalk >= 5 || facts.cbtJump > 0), + 'fire-support-or-dogfighter-role': (facts) => roleIn(facts, ['Fire Support', 'Dogfighter']), 'fire-support-equipment': (facts, gameSystem) => gameSystem === GameSystem.ALPHA_STRIKE ? hasAsSpecialPrefix(facts, 'IF') - : facts.unit.comp?.some(component => component.n?.includes('LRM')) === true || cbtHasArtillery(facts.unit), + : cbtHasIndirectFireWeapon(facts.unit), 'fire-support-role': (facts) => facts.role === 'Fire Support', 'fire-role': (facts) => roleIn(facts, ['Missile Boat', 'Sniper']), 'heavy-bm-or-mek': (facts, gameSystem) => gameSystem === GameSystem.ALPHA_STRIKE @@ -99,7 +125,7 @@ export const FORMATION_PREDICATES: Readonly gameSystem === GameSystem.ALPHA_STRIKE ? hasAsSpecialPrefix(facts, 'IF') - : facts.unit.comp?.some(component => component.n?.includes('LRM')) === true || cbtHasArtillery(facts.unit), + : cbtHasIndirectFireWeapon(facts.unit), 'interceptor-role': (facts) => facts.role === 'Interceptor', 'jump-or-infantry': (facts, gameSystem) => gameSystem === GameSystem.ALPHA_STRIKE ? facts.asJumpMove > 0 || FORMATION_PREDICATES['infantry-unit'](facts, gameSystem) @@ -156,9 +182,7 @@ export const FORMATION_PREDICATES: Readonly asOrCbt(gameSystem, facts.asGroundMove >= 10 || facts.asJumpMove >= 8, facts.cbtWalk >= 5 || facts.cbtJump >= 4), 'sweep-move': (facts, gameSystem) => asOrCbt(gameSystem, facts.asAnyGroundOrJumpMove >= 10, facts.cbtWalk >= 5), 'transport-role': (facts) => roleIncludes(facts, ['Transport']), - 'transport-squadron-unit': (facts, gameSystem) => gameSystem === GameSystem.ALPHA_STRIKE - ? TRANSPORT_AS_TYPES.has(facts.asType ?? '') - : facts.unit.type === 'Aero', + 'transport-squadron-unit': (facts, gameSystem) => isTransportSquadronUnit(facts, gameSystem), 'very-fast-move': (facts, gameSystem) => asOrCbt(gameSystem, facts.asAnyGroundOrJumpMove >= 12, facts.cbtWalk >= 6), 'vtol-unit': (facts) => facts.unit.type === 'VTOL', }; diff --git a/src/app/utils/formation-requirement-engine.util.spec.ts b/src/app/utils/formation-requirement-engine.util.spec.ts index e73fe74d0..9a537a3d7 100644 --- a/src/app/utils/formation-requirement-engine.util.spec.ts +++ b/src/app/utils/formation-requirement-engine.util.spec.ts @@ -75,7 +75,8 @@ describe('FormationRequirementEngine', () => { }); it('has a blueprint for every current formation definition', () => { - const missingBlueprintIds = getFormationDefinitions() + const missingBlueprintIds = [GameSystem.CLASSIC, GameSystem.ALPHA_STRIKE] + .flatMap(gameSystem => getFormationDefinitions(gameSystem)) .filter((formationDefinition) => !FormationRequirementEngine.hasBlueprint(formationDefinition.id)) .map((formationDefinition) => formationDefinition.id); @@ -143,25 +144,151 @@ describe('FormationRequirementEngine', () => { expect(LanceTypeIdentifierUtil.isValid(definition('battle-lance'), lightBrawlers, GameSystem.ALPHA_STRIKE)).toBeTrue(); }); - it('enforces vehicle matched pairs for Battle Lance when idealRole does not short-circuit', () => { + it('enforces Battle Lance vehicle pairs only in Classic', () => { const validVehiclePairs = [ - createForceUnit(createUnit(1, 'Vehicle-A', { type: 'Tank', subtype: 'Combat Vehicle', role: 'Brawler', as: { TP: 'CV', SZ: 3 } })), - createForceUnit(createUnit(2, 'Vehicle-A', { type: 'Tank', subtype: 'Combat Vehicle', role: 'Sniper', as: { TP: 'CV', SZ: 3 } })), - createForceUnit(createUnit(3, 'Vehicle-B', { type: 'Tank', subtype: 'Combat Vehicle', role: 'Skirmisher', as: { TP: 'CV', SZ: 3 } })), - createForceUnit(createUnit(4, 'Vehicle-B', { type: 'Tank', subtype: 'Combat Vehicle', role: 'Scout', as: { TP: 'CV', SZ: 3 } })), + createForceUnit(createUnit(1, 'Vehicle-A', { type: 'Tank', subtype: 'Combat Vehicle', weightClass: 'Heavy', role: 'Brawler', as: { TP: 'CV', SZ: 3 } })), + createForceUnit(createUnit(2, 'Vehicle-A', { type: 'Tank', subtype: 'Combat Vehicle', weightClass: 'Heavy', role: 'Sniper', as: { TP: 'CV', SZ: 3 } })), + createForceUnit(createUnit(3, 'Vehicle-B', { type: 'Tank', subtype: 'Combat Vehicle', weightClass: 'Heavy', role: 'Skirmisher', as: { TP: 'CV', SZ: 3 } })), + createForceUnit(createUnit(4, 'Vehicle-B', { type: 'Tank', subtype: 'Combat Vehicle', weightClass: 'Heavy', role: 'Scout', as: { TP: 'CV', SZ: 3 } })), ]; const unmatchedVehicles = validVehiclePairs.map((forceUnit, index) => { const unit = forceUnit.getUnit(); return createForceUnit(createUnit(index + 10, `${unit.name}-${index}`, { type: 'Tank', subtype: 'Combat Vehicle', + weightClass: 'Heavy', role: unit.role, as: { TP: 'CV', SZ: 3 }, })); }); - expect(LanceTypeIdentifierUtil.isValid(definition('battle-lance'), validVehiclePairs, GameSystem.ALPHA_STRIKE)).toBeTrue(); - expect(LanceTypeIdentifierUtil.isValid(definition('battle-lance'), unmatchedVehicles, GameSystem.ALPHA_STRIKE)).toBeFalse(); + expect(LanceTypeIdentifierUtil.isValid(definition('battle-lance', GameSystem.CLASSIC), validVehiclePairs, GameSystem.CLASSIC)).toBeTrue(); + expect(LanceTypeIdentifierUtil.isValid(definition('battle-lance', GameSystem.CLASSIC), unmatchedVehicles, GameSystem.CLASSIC)).toBeFalse(); + expect(LanceTypeIdentifierUtil.isValid(definition('battle-lance'), unmatchedVehicles, GameSystem.ALPHA_STRIKE)).toBeTrue(); + }); + + it('requires every non-Fire-Support fighter in a Fire Support Squadron to be a Dogfighter', () => { + const validUnits = Array.from({ length: 6 }, (_, index) => createForceUnit(createUnit(index + 1, `Fighter-${index}`, { + type: 'Aero', + subtype: 'Aerospace Fighter', + role: index < 3 ? 'Fire Support' : 'Dogfighter', + as: { TP: 'AF' }, + }))); + const invalidUnits = [ + ...validUnits.slice(0, 5), + createForceUnit(createUnit(10, 'Interceptor', { + type: 'Aero', + subtype: 'Aerospace Fighter', + role: 'Interceptor', + as: { TP: 'AF' }, + })), + ]; + + expect(LanceTypeIdentifierUtil.isValid(definition('fire-support-squadron'), validUnits, GameSystem.ALPHA_STRIKE)).toBeTrue(); + expect(LanceTypeIdentifierUtil.isValid(definition('fire-support-squadron'), invalidUnits, GameSystem.ALPHA_STRIKE)).toBeFalse(); + }); + + it('does not count the distinct Fast Dogfighter role toward a Strike Squadron majority', () => { + const units = Array.from({ length: 6 }, (_, index) => createForceUnit(createUnit(index + 1, `Strike-${index}`, { + type: 'Aero', + subtype: 'Aerospace Fighter', + role: index < 4 ? 'Fast Dogfighter' : 'Interceptor', + as: { TP: 'AF' }, + }))); + + expect(LanceTypeIdentifierUtil.isValid(definition('strike-squadron'), units, GameSystem.ALPHA_STRIKE)).toBeFalse(); + }); + + it('limits standard aerospace squadrons to aerospace and conventional fighters', () => { + const alphaStrikeFighters = Array.from({ length: 6 }, (_, index) => createForceUnit(createUnit(index + 1, `AS-Fighter-${index}`, { + type: 'Aero', + subtype: index === 5 ? 'Conventional Fighter' : 'Aerospace Fighter', + role: index < 4 ? 'Interceptor' : 'Fast Dogfighter', + as: { TP: index === 5 ? 'CF' : 'AF' }, + }))); + const alphaStrikeWithWarShip = [ + ...alphaStrikeFighters.slice(0, 5), + createForceUnit(createUnit(10, 'WarShip', { + type: 'Aero', + subtype: 'WarShip', + role: 'Fast Dogfighter', + as: { TP: 'WS' }, + })), + ]; + const classicFighters = Array.from({ length: 6 }, (_, index) => createForceUnit(createUnit(index + 20, `CBT-Fighter-${index}`, { + type: 'Aero', + subtype: index === 5 ? 'Conventional Fighter' : 'Aerospace Fighter', + role: index < 4 ? 'Interceptor' : 'Fast Dogfighter', + as: { TP: index === 5 ? 'CF' : 'AF' }, + }), GameSystem.CLASSIC)); + const classicWithDropShip = [ + ...classicFighters.slice(0, 5), + createForceUnit(createUnit(30, 'DropShip', { + type: 'Aero', + subtype: 'Spheroid DropShip', + role: 'Fast Dogfighter', + as: { TP: 'DS' }, + }), GameSystem.CLASSIC), + ]; + + expect(LanceTypeIdentifierUtil.isValid(definition('interceptor-squadron'), alphaStrikeFighters, GameSystem.ALPHA_STRIKE)).toBeTrue(); + expect(LanceTypeIdentifierUtil.isValid(definition('interceptor-squadron'), alphaStrikeWithWarShip, GameSystem.ALPHA_STRIKE)).toBeFalse(); + expect(LanceTypeIdentifierUtil.isValid(definition('interceptor-squadron', GameSystem.CLASSIC), classicFighters, GameSystem.CLASSIC)).toBeTrue(); + expect(LanceTypeIdentifierUtil.isValid(definition('interceptor-squadron', GameSystem.CLASSIC), classicWithDropShip, GameSystem.CLASSIC)).toBeFalse(); + }); + + it('allows only the listed Transport Squadron craft and airborne support vehicles', () => { + const permittedTransportUnits: Array<{ name: string; overrides: TestUnitOverrides }> = [ + { name: 'Aerospace Fighter', overrides: { type: 'Aero', subtype: 'Aerospace Fighter', as: { TP: 'AF' } } }, + { name: 'Conventional Fighter', overrides: { type: 'Aero', subtype: 'Conventional Fighter', as: { TP: 'CF' } } }, + { name: 'Small Craft', overrides: { type: 'Aero', subtype: 'Spheroid Small Craft', as: { TP: 'SC' } } }, + { name: 'Spheroid DropShip', overrides: { type: 'Aero', subtype: 'Spheroid DropShip', as: { TP: 'DS' } } }, + { name: 'Aerodyne DropShip', overrides: { type: 'Aero', subtype: 'Aerodyne DropShip', as: { TP: 'DA' } } }, + { name: 'Fixed-Wing Support Vehicle', overrides: { type: 'Aero', subtype: 'Fixed Wing Support Vehicle', as: { TP: 'SV' } } }, + ]; + const alphaStrikeUnits = permittedTransportUnits.map(({ name, overrides }, index) => createForceUnit(createUnit(index + 40, name, { + ...overrides, + role: 'Transport', + }))); + const classicUnits = permittedTransportUnits.map(({ name, overrides }, index) => createForceUnit(createUnit(index + 50, name, { + ...overrides, + role: 'Transport', + }), GameSystem.CLASSIC)); + const alphaStrikeWithGroundSupportVehicle = [ + ...alphaStrikeUnits.slice(0, 6), + createForceUnit(createUnit(60, 'Ground Support Vehicle', { + type: 'Tank', + subtype: 'Support Vehicle', + moveType: 'Wheeled', + role: 'Transport', + as: { TP: 'SV' }, + })), + ]; + const classicWithJumpShip = [ + ...classicUnits.slice(0, 6), + createForceUnit(createUnit(61, 'JumpShip', { + type: 'Aero', + subtype: 'JumpShip', + role: 'Transport', + as: { TP: 'JS' }, + }), GameSystem.CLASSIC), + ]; + + expect(LanceTypeIdentifierUtil.isValid(definition('transport-squadron'), alphaStrikeUnits, GameSystem.ALPHA_STRIKE)).toBeTrue(); + expect(LanceTypeIdentifierUtil.isValid(definition('transport-squadron'), alphaStrikeWithGroundSupportVehicle, GameSystem.ALPHA_STRIKE)).toBeFalse(); + expect(LanceTypeIdentifierUtil.isValid(definition('transport-squadron', GameSystem.CLASSIC), classicUnits, GameSystem.CLASSIC)).toBeTrue(); + expect(LanceTypeIdentifierUtil.isValid(definition('transport-squadron', GameSystem.CLASSIC), classicWithJumpShip, GameSystem.CLASSIC)).toBeFalse(); + }); + + it('does not require a same-model pair in a Vehicle Command Lance', () => { + const units = [ + createForceUnit(createUnit(1, 'Command Vehicle A', { type: 'Tank', subtype: 'Combat Vehicle', role: 'Sniper', as: { TP: 'CV' } })), + createForceUnit(createUnit(2, 'Command Vehicle B', { type: 'Tank', subtype: 'Combat Vehicle', role: 'Juggernaut', as: { TP: 'CV' } })), + createForceUnit(createUnit(3, 'Escort Vehicle', { type: 'Tank', subtype: 'Combat Vehicle', role: 'Scout', as: { TP: 'CV' } })), + ]; + + expect(LanceTypeIdentifierUtil.isValid(definition('vehicle-command-lance'), units, GameSystem.ALPHA_STRIKE)).toBeTrue(); + expect(LanceTypeIdentifierUtil.isValid(definition('vehicle-command-lance', GameSystem.CLASSIC), units, GameSystem.CLASSIC)).toBeTrue(); }); it('validates Order Lance same tier and same chassis constraints', () => { diff --git a/src/app/utils/formation-requirement-engine.util.ts b/src/app/utils/formation-requirement-engine.util.ts index 1f9e46b27..8542c29ea 100644 --- a/src/app/utils/formation-requirement-engine.util.ts +++ b/src/app/utils/formation-requirement-engine.util.ts @@ -4,20 +4,21 @@ import { GameSystem } from '../models/common.model'; import type { FormationTypeDefinition } from './formation-type.model'; -import { getFormationBlueprint } from './formation-blueprints'; +import { getFormationBlueprint, hasFormationBlueprint } from './formation-blueprints'; import type { FormationCandidatePredicateFilter, FormationConditionalForbiddenPredicate, FormationConstraint, FormationConstraintEvaluation, FormationDeficit, FormationEvaluation, FormationPredicateId, FormationRequirementBlueprint, FormationSearchDecision } from './formation-requirement.model'; import { evaluateFormationPredicate, getFormationFactValue } from './formation-predicates.util'; import { compileFormationUnitFacts, type FormationUnitFacts, type FormationUnitLike } from './formation-unit-facts.util'; export class FormationRequirementEngine { public static hasBlueprint(formationId: string): boolean { - return getFormationBlueprint(formationId) !== null; + return hasFormationBlueprint(formationId); } public static getBaseCandidatePredicateFilter( definition: Pick, + gameSystem: GameSystem, ): FormationCandidatePredicateFilter { - const blueprint = getFormationBlueprint(definition.id); + const blueprint = getFormationBlueprint(definition.id, gameSystem); if (!blueprint) { return this.createCandidatePredicateFilter(); } @@ -32,7 +33,7 @@ export class FormationRequirementEngine { units: readonly FormationUnitLike[], gameSystem: GameSystem, ): FormationCandidatePredicateFilter { - const blueprint = getFormationBlueprint(definition.id); + const blueprint = getFormationBlueprint(definition.id, gameSystem); if (!blueprint) { return this.createCandidatePredicateFilter(); } @@ -51,7 +52,7 @@ export class FormationRequirementEngine { units: readonly FormationUnitLike[], gameSystem: GameSystem, ): FormationEvaluation | null { - const blueprint = getFormationBlueprint(definition.id); + const blueprint = getFormationBlueprint(definition.id, gameSystem); if (!blueprint) { return null; } @@ -178,7 +179,7 @@ export class FormationRequirementEngine { units: readonly FormationUnitLike[], gameSystem: GameSystem, ): FormationEvaluation | null { - const blueprint = getFormationBlueprint(definition.id); + const blueprint = getFormationBlueprint(definition.id, gameSystem); if (!blueprint) { return null; } @@ -585,9 +586,14 @@ export class FormationRequirementEngine { constraint: Extract, unitCount: number, ): number { - return constraint.rounding === 'strict-majority' - ? Math.floor(unitCount / 2) + 1 - : Math.ceil(unitCount * constraint.ratio); + switch (constraint.rounding) { + case 'normal': + return Math.round(unitCount * constraint.ratio); + case 'strict-majority': + return Math.floor(unitCount / 2) + 1; + case 'ceil': + return Math.ceil(unitCount * constraint.ratio); + } } private static countMatchedPairs( @@ -778,12 +784,8 @@ export class FormationRequirementEngine { gameSystem: GameSystem, ): FormationConstraintEvaluation { const matchingCount = facts.filter(unitFacts => evaluateFormationPredicate(constraint.predicate, unitFacts, gameSystem)).length; - const required = constraint.rounding === 'strict-majority' - ? Math.floor(facts.length / 2) + 1 - : Math.ceil(facts.length * constraint.ratio); - const satisfied = constraint.rounding === 'strict-majority' - ? matchingCount * 2 > facts.length - : matchingCount >= required; + const required = this.getPercentRequiredCount(constraint, facts.length); + const satisfied = matchingCount >= required; return { constraintId: constraint.id, diff --git a/src/app/utils/formation-requirement.model.ts b/src/app/utils/formation-requirement.model.ts index a877b3811..9cf6bde3a 100644 --- a/src/app/utils/formation-requirement.model.ts +++ b/src/app/utils/formation-requirement.model.ts @@ -30,6 +30,7 @@ export type FormationPredicateId = | 'dogfighter-role' | 'ew-equipment' | 'fast-assault-move' + | 'fire-support-or-dogfighter-role' | 'fire-support-role' | 'fire-role' | 'fire-support-equipment' @@ -97,6 +98,13 @@ export interface FormationRequirementBlueprint { readonly constraints: readonly FormationConstraint[]; } +/** Authored requirement constraints, separated by game system. */ +export interface FormationRequirementBlueprintSource { + readonly id: string; + readonly classic: readonly FormationConstraint[]; + readonly alphaStrike: readonly FormationConstraint[]; +} + export interface FormationConstraintBase { readonly id: string; readonly label: string; @@ -135,7 +143,7 @@ export interface FormationPercentConstraint extends FormationConstraintBase { readonly kind: 'percent-min'; readonly predicate: FormationPredicateId; readonly ratio: number; - readonly rounding: 'ceil' | 'strict-majority'; + readonly rounding: 'ceil' | 'normal' | 'strict-majority'; } export interface FormationSameValueConstraint extends FormationConstraintBase { diff --git a/src/app/utils/formation-target.util.ts b/src/app/utils/formation-target.util.ts new file mode 100644 index 000000000..4e93bcef2 --- /dev/null +++ b/src/app/utils/formation-target.util.ts @@ -0,0 +1,75 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { ForceUnit } from '../models/force-unit.model'; +import type { UnitGroup } from '../models/force.model'; +import { formationHasTargetCopyEffect } from './formation-type.model'; + +/** A target must be a different, non-empty formation that does not itself copy another formation. */ +export function isValidFormationTarget( + owner: UnitGroup, + candidate: UnitGroup | null | undefined, +): candidate is UnitGroup { + return !!candidate + && candidate.force === owner.force + && candidate.id !== owner.id + && candidate.units().length > 0 + && !!candidate.activeFormation() + && !formationHasTargetCopyEffect(candidate.activeFormation()); +} + +export function getFormationTargetCandidates( + owner: UnitGroup, +): UnitGroup[] { + return owner.force.groups() + .filter((candidate): candidate is UnitGroup => ( + isValidFormationTarget(owner, candidate as UnitGroup) + )); +} + +export function resolveFormationTargetGroup( + owner: UnitGroup, +): UnitGroup | null { + if (!formationHasTargetCopyEffect(owner.activeFormation())) { + return null; + } + + const targetId = owner.formationTargetGroupId(); + if (!targetId) { + return null; + } + + const target = owner.force.groups().find((candidate) => candidate.id === targetId) as UnitGroup | undefined; + return isValidFormationTarget(owner, target) ? target : null; +} + +/** + * Drop a persisted target when the owning formation no longer copies another + * formation, or when the referenced group is no longer a legal target. + */ +export function clearInvalidFormationTargetSelection( + owner: UnitGroup, +): boolean { + if (!owner.formationTargetGroupId() || resolveFormationTargetGroup(owner)) { + return false; + } + + owner.formationTargetGroupId.set(null); + return true; +} + +/** + * Alpha Strike Support Formation copied SPAs remain chosen at setup, but are + * only active while at least three units in the Support Formation are active. + * Ordinary formations are unaffected by this predicate. + */ +export function isFormationTargetCopyBonusActive( + group: UnitGroup, +): boolean { + if (!formationHasTargetCopyEffect(group.activeFormation())) { + return true; + } + return resolveFormationTargetGroup(group) !== null + && group.units().filter((unit) => !unit.destroyed).length >= 3; +} diff --git a/src/app/utils/formation-type.model.spec.ts b/src/app/utils/formation-type.model.spec.ts index 5477bbd6c..ec970afd0 100644 --- a/src/app/utils/formation-type.model.spec.ts +++ b/src/app/utils/formation-type.model.spec.ts @@ -7,10 +7,8 @@ import { formationNameMatchesGroupName, getFormationDropdownDisplayName, getFormationNameMatchStrings, - resolveFormationGameSystemText, type FormationTypeDefinition, } from './formation-type.model'; -import { GameSystem } from '../models/common.model'; function createFormation(overrides: Partial = {}): FormationTypeDefinition { return { @@ -56,7 +54,6 @@ describe('formationNameMatchesGroupName', () => { expect(formationNameMatchesGroupName(formation, 'Urban anti-\'mech company')).toBeTrue(); }); }); - describe('getFormationNameMatchStrings', () => { it('includes the primary name and deduplicated aliases', () => { const formation = createFormation({ @@ -70,7 +67,6 @@ describe('getFormationNameMatchStrings', () => { ]); }); }); - describe('getFormationDropdownDisplayName', () => { it('adds an Aero suffix for squadron dropdown options', () => { expect(getFormationDropdownDisplayName(createFormation({ id: 'fire-support-squadron', name: 'Fire Support' }))) @@ -84,7 +80,6 @@ describe('getFormationDropdownDisplayName', () => { .toBe('Fire Support'); }); }); - describe('formationInheritsParentEffects', () => { it('defaults to false when inheritParentEffects is omitted', () => { expect(formationInheritsParentEffects(createFormation())).toBeFalse(); @@ -94,19 +89,3 @@ describe('formationInheritsParentEffects', () => { expect(formationInheritsParentEffects(createFormation({ inheritParentEffects: true }))).toBeTrue(); }); }); - -describe('resolveFormationGameSystemText', () => { - it('returns static text unchanged', () => { - expect(resolveFormationGameSystemText('Static bonus text.', GameSystem.ALPHA_STRIKE)) - .toBe('Static bonus text.'); - }); - - it('resolves callback text using the provided game system', () => { - const text = resolveFormationGameSystemText( - gameSystem => gameSystem === GameSystem.ALPHA_STRIKE ? 'Alpha Strike bonus.' : 'Classic bonus.', - GameSystem.CLASSIC, - ); - - expect(text).toBe('Classic bonus.'); - }); -}); \ No newline at end of file diff --git a/src/app/utils/formation-type.model.ts b/src/app/utils/formation-type.model.ts index 552eecc24..13a2bb454 100644 --- a/src/app/utils/formation-type.model.ts +++ b/src/app/utils/formation-type.model.ts @@ -2,8 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import type { GameSystem, Rulebook, RulesReference } from '../models/common.model'; -import type { ForceUnit } from '../models/force-unit.model'; +import { GameSystem, type RulesReference } from '../models/common.model'; export interface FormationWideAbility { readonly id: string; @@ -100,39 +99,95 @@ export interface FormationWideEffectGroup extends FormationEffectGroupBase { distribution: 'formation-wide'; } -export type FormationEffectGroup = FormationAssignmentEffectGroup | FormationSharedPoolEffectGroup | FormationWideEffectGroup; +/** + * Copies the SPAs actually granted by another formation in the same force. + * The target formation is selected on the owning {@code UnitGroup}; keeping the + * target out of the static rule definition lets one definition serve every force. + */ +export interface FormationTargetCopyEffectGroup { + selection: 'copy'; + distribution: 'formation-target'; + /** How many units in the copying formation may receive copied SPAs. */ + recipientLimit: 'one-per-two-target-recipients' | 'half-self-round-down'; +} -export type FormationGameSystemText = string | ((gameSystem: GameSystem) => string); +export type FormationEffectGroup = FormationAssignmentEffectGroup + | FormationSharedPoolEffectGroup + | FormationWideEffectGroup + | FormationTargetCopyEffectGroup; +export function isFormationTargetCopyEffectGroup( + group: FormationEffectGroup, +): group is FormationTargetCopyEffectGroup { + return group.distribution === 'formation-target'; +} -export interface FormationTypeDefinition { +export function formationHasTargetCopyEffect( + definition: FormationTypeDefinition | null | undefined, +): boolean { + return definition?.effectGroups?.some(isFormationTargetCopyEffectGroup) ?? false; +} + +export interface FormationTypeDefinitionCommon { id: string; parent?: string; name: string; /** Alternative formation names that should count as a whole-phrase match in custom group names. */ nameAliases?: string[]; description: string; - /** Human-readable formation bonus text, optionally specialized per game system. */ - effectDescription?: FormationGameSystemText; /** Whether this formation explicitly inherits parent effect groups and parent requirement display. Defaults to false. */ inheritParentEffects?: boolean; + exclusiveFaction?: string[]; + techBase?: 'Inner Sphere' | 'Clan' | 'Special'; +} + +/** Rules and metadata that belong to exactly one game system. */ +export interface FormationTypeGameSystemDefinition { + /** Human-readable formation bonus text for this game system. */ + effectDescription?: string; /** Structured SPA distribution rules for this formation's bonus ability. */ effectGroups?: FormationEffectGroup[]; - validator?: (units: ForceUnit[], gameSystem: GameSystem) => boolean; - /** - * Returns a human-readable description of what units/roles/weight classes - * are needed to qualify for this formation. - */ - requirements?: (gameSystem: GameSystem) => string; + /** Human-readable description of what is needed to qualify for this formation. */ + requirements?: string; idealRole?: string; - techBase?: 'Inner Sphere' | 'Clan' | 'Special'; minUnits: number; maxUnits?: number; - exclusiveFaction?: string[]; - /** Multiple rulebook references (e.g. CO p.62, AS:CE p.117). */ + /** Rulebook references that apply to this game system only. */ rulesRef?: RulesReference[]; } +/** Authored formation data: common identity plus explicit rules for both games. */ +export interface FormationTypeDefinitionSource extends FormationTypeDefinitionCommon { + classic: FormationTypeGameSystemDefinition; + alphaStrike: FormationTypeGameSystemDefinition; +} + +/** A formation definition resolved for one game system. */ +export interface FormationTypeDefinition extends FormationTypeDefinitionCommon, FormationTypeGameSystemDefinition { + readonly gameSystem?: GameSystem; +} + +export function getFormationTypeGameSystemDefinition( + definition: FormationTypeDefinitionSource, + gameSystem: GameSystem, +): FormationTypeGameSystemDefinition { + return gameSystem === GameSystem.CLASSIC + ? definition.classic + : definition.alphaStrike; +} + +export function resolveFormationTypeDefinition( + definition: FormationTypeDefinitionSource, + gameSystem: GameSystem, +): FormationTypeDefinition { + const { classic: _classic, alphaStrike: _alphaStrike, ...common } = definition; + return { + ...common, + ...getFormationTypeGameSystemDefinition(definition, gameSystem), + gameSystem, + }; +} + /** * Well-known ID for the "No Formation" sentinel. * When a group's formation is set to this value the user has explicitly @@ -199,15 +254,6 @@ export function formationInheritsParentEffects(def: FormationTypeDefinition | nu return def?.inheritParentEffects === true; } -export function resolveFormationGameSystemText( - text: FormationGameSystemText | null | undefined, - gameSystem: GameSystem, -): string | null { - if (!text) return null; - const resolvedText = typeof text === 'function' ? text(gameSystem) : text; - return resolvedText || null; -} - /** * A formation definition paired with context about how it was matched. */ diff --git a/src/app/utils/formation-unit-facts.util.ts b/src/app/utils/formation-unit-facts.util.ts index 76b3280b8..2ccdc7453 100644 --- a/src/app/utils/formation-unit-facts.util.ts +++ b/src/app/utils/formation-unit-facts.util.ts @@ -97,7 +97,8 @@ export function cbtCanDealDamage(unit: Unit, minDamage: number, atRange: number) export function cbtHasAutocannon(unit: Unit): boolean { return unit.comp?.some(component => ( - component.n?.includes('AC/') + component.eq?.hasFlag('F_AC') === true + || component.n?.includes('AC/') || component.n?.includes('LB ') || component.n?.includes('LB-') )) || false; @@ -107,6 +108,13 @@ export function cbtHasArtillery(unit: Unit): boolean { return unit.comp?.some(component => component.t === 'A') || false; } +export function cbtHasIndirectFireWeapon(unit: Unit): boolean { + return unit.comp?.some(component => ( + component.eq?.hasAnyFlag(['F_INDIRECT_FIRE', 'F_MORTAR_TYPE_INDIRECT']) === true + || component.n?.includes('LRM') + )) || cbtHasArtillery(unit); +} + export function compileFormationUnitFacts(forceUnit: FormationUnitLike): FormationUnitFacts { const unit = forceUnit.getUnit(); const cbtWeightClass = CBT_WEIGHT_CLASS_ORDINALS.get(unit.weightClass) ?? -1; diff --git a/src/app/utils/lance-type-identifier.util.ts b/src/app/utils/lance-type-identifier.util.ts index 4da29a082..da6a74b69 100644 --- a/src/app/utils/lance-type-identifier.util.ts +++ b/src/app/utils/lance-type-identifier.util.ts @@ -6,7 +6,7 @@ import { GameSystem } from '../models/common.model'; import { type Faction } from '../models/factions.model'; import type { Unit } from '../models/units.model'; import { type FormationTypeDefinition, type FormationMatch, getFormationNameMatchStrings, NO_FORMATION, NO_FORMATION_ID } from './formation-type.model'; -import { getFormationDefinition, getFormationDefinitions } from './formation-blueprints'; +import { getFormationDefinition, getFormationDefinitionSource, getFormationDefinitions } from './formation-blueprints'; import { FormationRequirementEngine } from './formation-requirement-engine.util'; import { normalizeLooseText } from './string.util'; import type { Era } from '../models/eras.model'; @@ -183,22 +183,22 @@ export class LanceTypeIdentifierUtil { return this.validateDefinition(definition, units, gameSystem); } - public static getDefinitionById(id: string, gameSystem?: GameSystem): FormationTypeDefinition | null { + public static getDefinitionById(id: string, gameSystem: GameSystem): FormationTypeDefinition | null { if (id === NO_FORMATION_ID) { return NO_FORMATION; } - const definition = getFormationDefinition(id); + const definition = getFormationDefinition(id, gameSystem); if (!definition) { return null; } - if (gameSystem !== undefined && !FormationRequirementEngine.hasBlueprint(definition.id)) { + if (!FormationRequirementEngine.hasBlueprint(definition.id)) { return null; } return definition; } - public static resolveDefinition(value: string, gameSystem?: GameSystem): FormationTypeDefinition | null { + public static resolveDefinition(value: string, gameSystem: GameSystem): FormationTypeDefinition | null { const normalizedValue = value.trim().toLowerCase(); if (!normalizedValue) { return null; @@ -208,9 +208,8 @@ export class LanceTypeIdentifierUtil { return NO_FORMATION; } - const definitions = getFormationDefinitions().filter((definition) => ( - gameSystem === undefined || FormationRequirementEngine.hasBlueprint(definition.id) - )); + const definitions = getFormationDefinitions(gameSystem) + .filter((definition) => FormationRequirementEngine.hasBlueprint(definition.id)); for (const definition of definitions) { if (definition.id.toLowerCase() === normalizedValue) { @@ -242,7 +241,7 @@ export class LanceTypeIdentifierUtil { if (!formationId || formationId === NO_FORMATION_ID) { return null; } - return getFormationDefinition(formationId)?.name ?? null; + return getFormationDefinitionSource(formationId)?.name ?? null; } public static getFormationPriorityWeight( @@ -304,7 +303,7 @@ export class LanceTypeIdentifierUtil { const matches: FormationTypeDefinition[] = []; const unitCount = units.length; - for (const definition of getFormationDefinitions()) { + for (const definition of getFormationDefinitions(gameSystem)) { try { if (!FormationRequirementEngine.hasBlueprint(definition.id)) { continue; diff --git a/src/app/utils/rules-ref.util.ts b/src/app/utils/rules-ref.util.ts new file mode 100644 index 000000000..261806ed6 --- /dev/null +++ b/src/app/utils/rules-ref.util.ts @@ -0,0 +1,9 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +export const BASE_RULES_REFS: ReadonlySet = new Set(['BMM', 'Core', 'TM', 'TW']); + +export function isBaseRulesRef(rulesRef: string): boolean { + return BASE_RULES_REFS.has(rulesRef); +} diff --git a/src/app/utils/semantic-filter-ast.util.ts b/src/app/utils/semantic-filter-ast.util.ts index 6a5639cd2..996f7b706 100644 --- a/src/app/utils/semantic-filter-ast.util.ts +++ b/src/app/utils/semantic-filter-ast.util.ts @@ -33,7 +33,7 @@ import { ADVANCED_FILTERS, type AdvFilterConfig, AdvFilterType, type Availabilit import { type SemanticOperator, type SemanticToken, buildSemanticKeyMap, VIRTUAL_SEMANTIC_KEYS, parseValues, parseValueWithQuantity, type QuantityConstraint } from './semantic-filter.util'; import { normalizeLooseText, wildcardToRegex } from './string.util'; import { usesIndexedDropdownUniverse } from './unit-search-filter-config.util'; -import { checkQuantityConstraint as checkQuantityConstraintCore, isEmbeddedApostrophe } from './unit-search-shared.util'; +import { checkQuantityConstraint as checkQuantityConstraintCore, isEmbeddedApostrophe, unitMatchesRulesRefsSelection } from './unit-search-shared.util'; import { isASDamageSemanticKey, parseASDamageValue } from './as-damage.util'; // ============================================================================ @@ -2751,6 +2751,14 @@ function evaluateDropdownFilter( return evaluateASSpecialsFilter(unitValue, operator, values); } + if (conf.key === 'rulesRefs' && (operator === '=' || operator === '==')) { + const selectedRulesRefs = Array.from(new Set(values.flatMap(value => { + const indexedMatches = matchIndexedStoredValues(conf.key, value, context); + return indexedMatches.length > 0 ? indexedMatches : [value]; + }))); + return unitMatchesRulesRefsSelection(unitValue, selectedRulesRefs); + } + if (unitValue == null) return operator === '!='; // Normalize unit value(s) to array diff --git a/src/app/utils/unit-filter-kernel.util.ts b/src/app/utils/unit-filter-kernel.util.ts index ebdd380e8..e564a6aa3 100644 --- a/src/app/utils/unit-filter-kernel.util.ts +++ b/src/app/utils/unit-filter-kernel.util.ts @@ -21,6 +21,7 @@ import { getSelectedPositiveDropdownNames, getUnitCountableFilterData, normalizeMultiStateSelection, + unitMatchesRulesRefsSelection, } from './unit-search-shared.util'; import { getUnitVariantGroupKey } from './unit-variant.util'; import { isCountableBackedDropdown } from './unit-search-filter-config.util'; @@ -284,6 +285,19 @@ export function applyFilterStateToUnits(request: ApplyUnitFilterStateRequest): U continue; } + if (conf.type === AdvFilterType.DROPDOWN && conf.key === 'rulesRefs') { + const selectedRulesRefs = Array.isArray(val) + ? val.filter((value): value is string => typeof value === 'string') + : []; + if (selectedRulesRefs.length > 0) { + results = results.filter(unit => unitMatchesRulesRefsSelection( + dependencies.getProperty(unit, conf.key), + selectedRulesRefs, + )); + } + continue; + } + if (conf.type === AdvFilterType.DROPDOWN && conf.multistate) { results = filterUnitsByMultiState( results, @@ -465,4 +479,4 @@ export function applyFilterStateToUnits(request: ApplyUnitFilterStateRequest): U } return results; -} \ No newline at end of file +} diff --git a/src/app/utils/unit-search-executor.util.spec.ts b/src/app/utils/unit-search-executor.util.spec.ts index c7501c5a0..785ad39fa 100644 --- a/src/app/utils/unit-search-executor.util.spec.ts +++ b/src/app/utils/unit-search-executor.util.spec.ts @@ -210,4 +210,30 @@ describe('unit-search-executor', () => { expect(executeQuery([dualTyped, areaEffectOnly], 'weaponType&="AI:>=2" weaponType&="AE:>=2"').map(unit => unit.name)) .toEqual(['Dual Typed']); }); -}); \ No newline at end of file + + it('matches when the selected rulebooks cover one complete bucket', () => { + const unitA = createEmptyUnit({ name: 'Unit A', rulesRefs: [['Core'], ['TW', 'IO:AE']] }); + const unitB = createEmptyUnit({ + name: 'Unit B', + rulesRefs: [['TW', 'Shrap01', 'AAA'], ['TM', 'Shrap01']], + }); + const units = [unitA, unitB]; + + expect(executeQuery(units, 'rulesRefs=Core').map(unit => unit.name)) + .toEqual(['Unit A']); + expect(executeQuery(units, 'rulesRefs=TW').map(unit => unit.name)) + .toEqual([]); + expect(executeQuery(units, 'rulesRefs=TW,IO:AE').map(unit => unit.name)) + .toEqual(['Unit A']); + expect(executeQuery(units, 'rulesRefs=TW,Shrap01').map(unit => unit.name)) + .toEqual([]); + expect(executeQuery(units, 'rulesRefs=TW,Shrap01,AAA').map(unit => unit.name)) + .toEqual(['Unit B']); + expect(executeQuery(units, 'rulesRefs=IO:AE').map(unit => unit.name)) + .toEqual(['Unit A']); + expect(executeQuery(units, 'rulesRefs=Shrap01').map(unit => unit.name)) + .toEqual(['Unit B']); + expect(executeQuery(units, 'rulesRefs=AAA').map(unit => unit.name)) + .toEqual([]); + }); +}); diff --git a/src/app/utils/unit-search-shared.util.ts b/src/app/utils/unit-search-shared.util.ts index 8827fd84e..b39d97d7f 100644 --- a/src/app/utils/unit-search-shared.util.ts +++ b/src/app/utils/unit-search-shared.util.ts @@ -66,6 +66,60 @@ export function getUnitSourceFilterValues(unit: Pick typeof value === 'string') + .map(value => value.trim().toLowerCase()) + .filter(value => value.length > 0), + )); +} + +const BASE_RULE_BOOK_KEYS = new Set(['tw', 'tm', 'bmm', 'core']); + +function normalizeUnitRulesRefBuckets(values: unknown): string[][] { + if (!Array.isArray(values) || values.length === 0) { + return []; + } + + // Accept the old flat form during the data-format transition. + if (values.every(value => typeof value === 'string')) { + const bucket = normalizeRulesRefBucket(values); + return bucket.length > 0 ? [bucket] : []; + } + + return values + .map(normalizeRulesRefBucket) + .filter(bucket => bucket.length > 0); +} + +/** + * A selection covers a unit when it contains every book from at least one of the + * unit's alternative rules-reference buckets. Extra selected books are harmless. + * When no base rulebook is selected, base books are ignored so expansion-only + * searches do not need to name the compatible base book as well. + */ +export function unitMatchesRulesRefsSelection(unitRulesRefs: unknown, selectedRulesRefs: readonly string[]): boolean { + const selectedRefs = new Set(normalizeRulesRefBucket(selectedRulesRefs)); + if (selectedRefs.size === 0) { + return true; + } + + const buckets = normalizeUnitRulesRefBuckets(unitRulesRefs); + if (Array.from(selectedRefs).some(rulesRef => BASE_RULE_BOOK_KEYS.has(rulesRef))) { + return buckets.some(bucket => bucket.every(rulesRef => selectedRefs.has(rulesRef))); + } + + return buckets.some(bucket => { + const nonBaseBooks = bucket.filter(rulesRef => !BASE_RULE_BOOK_KEYS.has(rulesRef)); + return nonBaseBooks.length > 0 && nonBaseBooks.every(rulesRef => selectedRefs.has(rulesRef)); + }); +} + export function getProperty(obj: any, key?: string) { if (!obj || !key) return undefined; if (key === '_tags') { @@ -363,4 +417,4 @@ export function measureStage( stages.push(stage); return value; -} \ No newline at end of file +} diff --git a/src/app/utils/unit-search-worker-request.util.spec.ts b/src/app/utils/unit-search-worker-request.util.spec.ts index efb79b3db..08a52be42 100644 --- a/src/app/utils/unit-search-worker-request.util.spec.ts +++ b/src/app/utils/unit-search-worker-request.util.spec.ts @@ -97,6 +97,29 @@ describe('buildWorkerExecutionQuery', () => { expect(executionQuery).toContain('weaponType&=AE'); expect(parseSemanticQueryAST(executionQuery, GameSystem.CLASSIC).errors).toEqual([]); }); + + it('serializes plain rulebook selections for worker execution', () => { + const executionQuery = buildWorkerExecutionQuery({ + effectiveFilterState: { + rulesRefs: { + value: ['TW', 'Shrap01', 'AAA'], + interactedWith: true, + }, + }, + effectiveTextSearch: '', + gameSystem: GameSystem.CLASSIC, + totalRangesCache: {}, + }); + + expect(executionQuery).toBe('rulesRefs=TW,Shrap01,AAA'); + expect(parseSemanticQueryAST(executionQuery, GameSystem.CLASSIC).tokens).toEqual([ + jasmine.objectContaining({ + field: 'rulesrefs', + operator: '=', + values: ['TW', 'Shrap01', 'AAA'], + }), + ]); + }); }); describe('getWorkerCorpusSnapshot', () => { @@ -113,4 +136,4 @@ describe('getWorkerCorpusSnapshot', () => { expect(second.snapshot).toBe(first.snapshot); }); -}); \ No newline at end of file +}); From bb132816d8d31d7a1e72687ff1c91515d22fc5dd Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 21 Aug 2026 14:26:55 +0200 Subject: [PATCH 04/87] . --- karma.codex.conf.cjs | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 karma.codex.conf.cjs diff --git a/karma.codex.conf.cjs b/karma.codex.conf.cjs deleted file mode 100644 index 91ea16451..000000000 --- a/karma.codex.conf.cjs +++ /dev/null @@ -1,16 +0,0 @@ -module.exports = function configureKarma(config) { - config.set({ - frameworks: ['jasmine'], - customLaunchers: { - ChromeHeadlessCodex: { - base: 'ChromeHeadless', - flags: [ - '--no-sandbox', - '--disable-gpu', - '--disable-software-rasterizer', - '--disable-dev-shm-usage', - ], - }, - }, - }); -}; From 61310d5cc82d434f4c62deb31e09bbac28ccd359 Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 21 Aug 2026 14:28:03 +0200 Subject: [PATCH 05/87] merge --- ...search-force-generator-dialog.component.ts | 2 +- .../unit-details-general-tab.component.css | 56 ++++++ .../unit-details-general-tab.component.html | 23 +++ ...unit-details-general-tab.component.spec.ts | 75 +++++++- .../unit-details-general-tab.component.ts | 176 +++++++++++++++++- .../unit-search-advanced-filters.component.ts | 4 +- .../unit-search/unit-search.component.scss | 4 +- 7 files changed, 333 insertions(+), 7 deletions(-) diff --git a/src/app/components/search-force-generator-dialog/search-force-generator-dialog.component.ts b/src/app/components/search-force-generator-dialog/search-force-generator-dialog.component.ts index 6b6117e23..76335c190 100644 --- a/src/app/components/search-force-generator-dialog/search-force-generator-dialog.component.ts +++ b/src/app/components/search-force-generator-dialog/search-force-generator-dialog.component.ts @@ -314,7 +314,7 @@ export class SearchForceGeneratorDialogComponent { readonly targetFormationSelection = signal({}); readonly targetFormationStateCycle = ['or'] as const; readonly targetFormationOptions = computed(() => { - const definitions = getFormationDefinitions() + const definitions = getFormationDefinitions(this.gameSystem()) .filter((definition) => FormationRequirementEngine.hasBlueprint(definition.id)) .filter((definition) => LanceTypeIdentifierUtil.getDefinitionById(definition.id, this.gameSystem()) !== null) .filter((definition) => this.isTargetFormationAvailableForSelectedFactions(definition)); diff --git a/src/app/components/unit-details-dialog/tabs/unit-details-general-tab.component.css b/src/app/components/unit-details-dialog/tabs/unit-details-general-tab.component.css index 0157e1e01..2cc0d3e20 100644 --- a/src/app/components/unit-details-dialog/tabs/unit-details-general-tab.component.css +++ b/src/app/components/unit-details-dialog/tabs/unit-details-general-tab.component.css @@ -432,6 +432,62 @@ white-space: nowrap; } +.rules-ref-badges { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + margin-top: 2px; +} + +.rules-ref-bucket { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.rules-ref-operator { + color: var(--text-color-secondary); +} + +.rules-ref-more { + padding: 0; + border: 0; + background: transparent; + color: var(--text-color-secondary); + cursor: pointer; + font: inherit; + font-size: 0.8em; + text-decoration: underline dotted; + text-underline-offset: 2px; +} + +.rules-ref-more:hover, +.rules-ref-more:focus-visible { + color: var(--text-color); + text-decoration-style: solid; +} + +.rules-ref-badge { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border: 1px solid var(--semantic-color); + border-radius: 4px; + background: color-mix(in srgb, var(--semantic-color) 18%, transparent); + color: var(--semantic-color-highlight); + font-size: 0.8em; + font-weight: bold; + line-height: 1.4; + white-space: nowrap; +} + +.rules-ref-badge.base-rules-ref { + border-color: var(--bt-yellow); + background: var(--bt-yellow-background); + color: var(--bt-yellow); +} + .bottom-info .sarna-link { display: flex; margin-top: 6px; diff --git a/src/app/components/unit-details-dialog/tabs/unit-details-general-tab.component.html b/src/app/components/unit-details-dialog/tabs/unit-details-general-tab.component.html index 855e06c6b..978496076 100644 --- a/src/app/components/unit-details-dialog/tabs/unit-details-general-tab.component.html +++ b/src/app/components/unit-details-dialog/tabs/unit-details-general-tab.component.html @@ -247,6 +247,29 @@ {{ pack }} } } +@if (rulesRefBadgeGroups().length > 0) { +Rulebooks: +
+@for (badgeGroup of visibleRulesRefBadgeGroups(); let groupIndex = $index; track groupIndex) { + @if (groupIndex > 0) { + || + } + + @for (badge of badgeGroup; let badgeIndex = $index; track badge.label) { + @if (badgeIndex > 0) { + + + } + {{ badge.label }} + } + +} +@if (hasHiddenRulesRefBadgeGroups()) { + || + +} +
+}
@if (sarnaWikiUrl(); as wikiUrl) { diff --git a/src/app/components/unit-details-dialog/tabs/unit-details-general-tab.component.spec.ts b/src/app/components/unit-details-dialog/tabs/unit-details-general-tab.component.spec.ts index 07dbf0083..92f8e2bfc 100644 --- a/src/app/components/unit-details-dialog/tabs/unit-details-general-tab.component.spec.ts +++ b/src/app/components/unit-details-dialog/tabs/unit-details-general-tab.component.spec.ts @@ -2,9 +2,80 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { shouldShowAdjustedPilotSkills } from './unit-details-general-tab.component'; +import { + getRulesRefBadgeGroups, + getRulesRefBuckets, + shouldShowAdjustedPilotSkills +} from './unit-details-general-tab.component'; describe('UnitDetailsGeneralTabComponent', () => { + describe('getRulesRefBuckets', () => { + it('preserves alternative buckets and their book order', () => { + expect(getRulesRefBuckets([['Core'], ['TW', 'IO:AUE']])) + .toEqual([['Core'], ['TW', 'IO:AUE']]); + }); + + it('removes duplicate and empty references and buckets', () => { + expect(getRulesRefBuckets([['Core', 'Core', ''], [], ['TW']])) + .toEqual([['Core'], ['TW']]); + }); + + it('accepts the previous flat data form as one bucket', () => { + expect(getRulesRefBuckets(['Core', 'IO:AUE'])) + .toEqual([['Core', 'IO:AUE']]); + }); + }); + + describe('getRulesRefBadgeGroups', () => { + it('groups base alternatives with identical non-base requirements', () => { + expect(getRulesRefBadgeGroups([ + ['TO:AUE', 'TW'], + ['Core'], + ['BMM'], + ['TM', 'TO:AUE'], + ])).toEqual([ + [{ label: 'BMM/Core', isBase: true }], + [ + { label: 'TM/TW', isBase: true }, + { label: 'TO:AUE', isBase: false }, + ], + ]); + }); + + it('sorts alternatives by book count and badges by type then name', () => { + expect(getRulesRefBadgeGroups([ + ['ZZ', 'TW', 'AA'], + ['IO:AE'], + ])).toEqual([ + [{ label: 'IO:AE', isBase: false }], + [ + { label: 'TW', isBase: true }, + { label: 'AA', isBase: false }, + { label: 'ZZ', isBase: false }, + ], + ]); + }); + + it('keeps base books joined by plus when the same bucket requires them together', () => { + expect(getRulesRefBadgeGroups([['TW', 'TO:AUE', 'TM']])).toEqual([[ + { label: 'TM', isBase: true }, + { label: 'TW', isBase: true }, + { label: 'TO:AUE', isBase: false }, + ]]); + }); + + it('factors shared base books before merging the remaining alternatives', () => { + expect(getRulesRefBadgeGroups([ + ['BMM', 'TM', 'IO:AE'], + ['BMM', 'TW', 'IO:AE'], + ])).toEqual([[ + { label: 'BMM', isBase: true }, + { label: 'TM/TW', isBase: true }, + { label: 'IO:AE', isBase: false }, + ]]); + }); + }); + describe('shouldShowAdjustedPilotSkills', () => { it('shows skills when adjusted BV differs from base BV', () => { expect(shouldShowAdjustedPilotSkills(1200, 1000, 3, 4)).toBeTrue(); @@ -21,4 +92,4 @@ describe('UnitDetailsGeneralTabComponent', () => { expect(shouldShowAdjustedPilotSkills(1200, 1000, 3, undefined)).toBeFalse(); }); }); -}); \ No newline at end of file +}); diff --git a/src/app/components/unit-details-dialog/tabs/unit-details-general-tab.component.ts b/src/app/components/unit-details-dialog/tabs/unit-details-general-tab.component.ts index 158308f09..7a0d25091 100644 --- a/src/app/components/unit-details-dialog/tabs/unit-details-general-tab.component.ts +++ b/src/app/components/unit-details-dialog/tabs/unit-details-general-tab.component.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { Component, ChangeDetectionStrategy, input, inject, computed } from '@angular/core'; +import { Component, ChangeDetectionStrategy, input, inject, computed, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import type { Unit, UnitComponent } from '../../../models/units.model'; import { weaponTypes } from '../../../utils/equipment.util'; @@ -34,6 +34,7 @@ import { import { naturalCompare } from '../../../utils/sort.util'; import { EquipmentFlag } from '../../../models/equipment-flags.type'; import { formatBvPv } from '../../../utils/force-viewer-bv-pv-display.util'; +import { BASE_RULES_REFS } from '../../../utils/rules-ref.util'; import { adjustPointValueForSkill } from '../../../utils/pv-skill-adjustment.util'; import { GameService } from '../../../services/game.service'; @@ -52,6 +53,164 @@ type ComponentLayoutState = { const ADDITIONAL_COMPONENT_FLAGS: EquipmentFlag[] = ['F_HEAT_SINK', 'F_DOUBLE_HEAT_SINK', 'F_JUMP_JET']; const CASE_COMPONENT_FLAGS: EquipmentFlag[] = ['F_CASE', 'F_CASE_II']; const WEAPON_MODE_MISC_COMPONENT_FLAGS: EquipmentFlag[] = ['F_CLUB', 'F_HAND_WEAPON']; +const RULES_REF_COLLAPSED_GROUP_LIMIT = 2; + +export interface RulesRefBadge { + label: string; + isBase: boolean; +} + +type BaseRulesRefExpression = string[][]; + +const compareRulesRefNames = (left: string, right: string): number => left.localeCompare(right); + +function normalizeBaseRulesRefExpression(expression: BaseRulesRefExpression): BaseRulesRefExpression { + return expression + .map(choice => [...new Set(choice)].sort(compareRulesRefNames)) + .sort((left, right) => compareRulesRefNames(left.join('/'), right.join('/'))); +} + +function getBaseRulesRefExpressionKey(expression: BaseRulesRefExpression): string { + return expression.map(choice => choice.join('/')).join('\u0000'); +} + +function tryMergeBaseRulesRefExpressions( + left: BaseRulesRefExpression, + right: BaseRulesRefExpression, +): BaseRulesRefExpression | null { + if (left.length !== right.length) return null; + + const unmatchedRight = [...right]; + const sharedChoices: string[][] = []; + const unmatchedLeft: string[][] = []; + + for (const leftChoice of left) { + const leftChoiceKey = leftChoice.join('/'); + const matchingIndex = unmatchedRight.findIndex(rightChoice => rightChoice.join('/') === leftChoiceKey); + if (matchingIndex < 0) { + unmatchedLeft.push(leftChoice); + } else { + sharedChoices.push(leftChoice); + unmatchedRight.splice(matchingIndex, 1); + } + } + + if (unmatchedLeft.length !== 1 || unmatchedRight.length !== 1) return null; + + return normalizeBaseRulesRefExpression([ + ...sharedChoices, + [...unmatchedLeft[0], ...unmatchedRight[0]], + ]); +} + +function factorBaseRulesRefExpressions(baseRefSets: string[][]): BaseRulesRefExpression[] { + let expressions = baseRefSets.map(baseRefs => normalizeBaseRulesRefExpression( + baseRefs.map(rulesRef => [rulesRef]), + )); + + while (true) { + expressions = [...new Map( + expressions.map(expression => [getBaseRulesRefExpressionKey(expression), expression]), + ).values()].sort((left, right) => compareRulesRefNames( + getBaseRulesRefExpressionKey(left), + getBaseRulesRefExpressionKey(right), + )); + + let mergedPair: [number, number, BaseRulesRefExpression] | null = null; + for (let leftIndex = 0; leftIndex < expressions.length && !mergedPair; leftIndex++) { + for (let rightIndex = leftIndex + 1; rightIndex < expressions.length; rightIndex++) { + const merged = tryMergeBaseRulesRefExpressions(expressions[leftIndex], expressions[rightIndex]); + if (merged) { + mergedPair = [leftIndex, rightIndex, merged]; + break; + } + } + } + + if (!mergedPair) return expressions; + + const [leftIndex, rightIndex, merged] = mergedPair; + expressions = expressions.filter((_, index) => index !== leftIndex && index !== rightIndex); + expressions.push(merged); + } +} + +export function getRulesRefBuckets( + rulesRefs: readonly (readonly string[])[] | readonly string[] | null | undefined, +): string[][] { + if (!rulesRefs?.length) return []; + + const rawBuckets: readonly (readonly string[])[] = rulesRefs.every(rulesRef => typeof rulesRef === 'string') + ? [rulesRefs as readonly string[]] + : rulesRefs as readonly (readonly string[])[]; + + return rawBuckets + .map(bucket => [...new Set(bucket.map(rulesRef => rulesRef.trim()).filter(Boolean))]) + .filter(bucket => bucket.length > 0); +} + +export function getRulesRefBadgeGroups( + rulesRefs: readonly (readonly string[])[] | readonly string[] | null | undefined, +): RulesRefBadge[][] { + const groupedByNonBaseRefs = new Map(); + const displayGroups: Array<{ + badges: RulesRefBadge[]; + bookCount: number; + hasBaseRefs: boolean; + }> = []; + + for (const bucket of getRulesRefBuckets(rulesRefs)) { + const baseRefs = bucket.filter(rulesRef => BASE_RULES_REFS.has(rulesRef)).sort(compareRulesRefNames); + const nonBaseRefs = bucket.filter(rulesRef => !BASE_RULES_REFS.has(rulesRef)).sort(compareRulesRefNames); + + if (baseRefs.length > 0) { + const groupKey = JSON.stringify(nonBaseRefs); + const existingGroup = groupedByNonBaseRefs.get(groupKey); + if (existingGroup) { + existingGroup.baseRefSets.push(baseRefs); + } else { + groupedByNonBaseRefs.set(groupKey, { + baseRefSets: [baseRefs], + nonBaseRefs, + }); + } + continue; + } + + displayGroups.push({ + badges: nonBaseRefs.map(label => ({ label, isBase: false })), + bookCount: nonBaseRefs.length, + hasBaseRefs: false, + }); + } + + for (const group of groupedByNonBaseRefs.values()) { + for (const baseExpression of factorBaseRulesRefExpressions(group.baseRefSets)) { + displayGroups.push({ + badges: [ + ...baseExpression.map(choice => ({ label: choice.join('/'), isBase: true })), + ...group.nonBaseRefs.map(label => ({ label, isBase: false })), + ], + bookCount: baseExpression.length + group.nonBaseRefs.length, + hasBaseRefs: true, + }); + } + } + + return displayGroups + .sort((left, right) => { + const countOrder = left.bookCount - right.bookCount; + if (countOrder !== 0) return countOrder; + + const typeOrder = Number(right.hasBaseRefs) - Number(left.hasBaseRefs); + if (typeOrder !== 0) return typeOrder; + + const leftKey = left.badges.map(badge => badge.label).join('\u0000'); + const rightKey = right.badges.map(badge => badge.label).join('\u0000'); + return compareRulesRefNames(leftKey, rightKey); + }) + .map(group => group.badges); +} export function shouldShowAdjustedPilotSkills( adjustedBv: number | null, @@ -115,6 +274,21 @@ export class UnitDetailsGeneralTabComponent { additionalComponentSummary = computed(() => this.getAdditionalComponentSummary()); additionalComponentSummaryInteractive = computed(() => !this.showFilteredComponents()); componentViewModeAvailable = computed(() => this.hasDetailOnlyComponents()); + rulesRefBadgeGroups = computed(() => getRulesRefBadgeGroups(this.unit().rulesRefs)); + private expandedRulesRefUnit = signal(null); + rulesRefBadgeGroupsExpanded = computed(() => this.expandedRulesRefUnit() === this.unit()); + visibleRulesRefBadgeGroups = computed(() => { + const groups = this.rulesRefBadgeGroups(); + return this.rulesRefBadgeGroupsExpanded() + ? groups + : groups.slice(0, RULES_REF_COLLAPSED_GROUP_LIMIT); + }); + hasHiddenRulesRefBadgeGroups = computed(() => !this.rulesRefBadgeGroupsExpanded() + && this.rulesRefBadgeGroups().length > RULES_REF_COLLAPSED_GROUP_LIMIT); + + showAllRulesRefBadgeGroups(): void { + this.expandedRulesRefUnit.set(this.unit()); + } setComponentViewMode(showDetails: boolean): void { if (this.showFilteredComponents() === showDetails) return; diff --git a/src/app/components/unit-search-advanced-filters/unit-search-advanced-filters.component.ts b/src/app/components/unit-search-advanced-filters/unit-search-advanced-filters.component.ts index a756dd394..9d8d798de 100644 --- a/src/app/components/unit-search-advanced-filters/unit-search-advanced-filters.component.ts +++ b/src/app/components/unit-search-advanced-filters/unit-search-advanced-filters.component.ts @@ -17,6 +17,7 @@ import type { FormationSearchTarget } from '../../utils/formation-requirement.mo import { LanceTypeIdentifierUtil } from '../../utils/lance-type-identifier.util'; import { isFilterAvailableForAvailabilitySource } from '../../utils/unit-search-filter-config.util'; import { normalizeUnitSearchRange, rangeFilterAllowsFloatingValues } from '../../utils/unit-search-range-dialog.util'; +import { isBaseRulesRef } from '../../utils/rules-ref.util'; import { MultiSelectDropdownComponent, type DropdownOption, type MultiStateSelection } from '../multi-select-dropdown/multi-select-dropdown.component'; import { RangeSliderComponent } from '../range-slider/range-slider.component'; import { SemanticGuideComponent } from '../semantic-guide/semantic-guide.component'; @@ -56,6 +57,7 @@ export class UnitSearchAdvancedFiltersComponent { readonly megaMekAvailabilitySourceSelected = computed(() => this.optionsService.options().availabilitySource === 'megamek'); readonly gridTemplateColumns = computed(() => this.columnsCount() === 2 ? '1fr 1fr' : '1fr'); readonly formationTargetOptions = computed(() => this.filtersService.getFormationTargetOptions(this.filterGameSystem())); + readonly rulesRefOptionSection = (option: DropdownOption): string => isBaseRulesRef(option.name) ? 'base' : 'non-base'; readonly selectedFormationTarget = computed(() => { const options = this.formationTargetOptions(); const semanticTargetId = this.filtersService.semanticFormationTargetId(); @@ -239,4 +241,4 @@ export class UnitSearchAdvancedFiltersComponent { return conf.formatValue?.(value) ?? FormatNumberPipe.formatValue(value, false, true); } -} \ No newline at end of file +} diff --git a/src/app/components/unit-search/unit-search.component.scss b/src/app/components/unit-search/unit-search.component.scss index 403763c48..74969f902 100644 --- a/src/app/components/unit-search/unit-search.component.scss +++ b/src/app/components/unit-search/unit-search.component.scss @@ -582,7 +582,7 @@ .results-sort .sort-select { height: 32px; padding: 0 4px; - background: #222; + background-color: #222; } .results-footer .bt-button { @@ -591,7 +591,7 @@ width: 32px; padding: 0; color: #bbb; - background: #222; + background-color: #222; } .results-footer .bt-button:hover { From 961c3fecf27fb9bb6b2549a0636e1f1b84a834e7 Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 21 Aug 2026 16:23:08 +0200 Subject: [PATCH 06/87] options --- .../options-dialog.component.html | 41 +++++++++---- .../options-dialog.component.scss | 58 ++++++++++++++++++ .../options-dialog.component.spec.ts | 33 +++++++--- .../options-dialog.component.ts | 60 +++++++++++++++++-- src/app/models/options.model.ts | 1 + src/app/services/options.service.spec.ts | 2 + src/app/services/options.service.ts | 6 ++ 7 files changed, 177 insertions(+), 24 deletions(-) diff --git a/src/app/components/options-dialog/options-dialog.component.html b/src/app/components/options-dialog/options-dialog.component.html index 0eec9e469..844fe82e4 100644 --- a/src/app/components/options-dialog/options-dialog.component.html +++ b/src/app/components/options-dialog/options-dialog.component.html @@ -532,20 +532,35 @@
Automations
-
-
- - -
-
-

Shows the calculated heat projection and applies it automatically when the turn ends.

-

APPLY HEAT appears only after selecting a heat value and sets the current heat - directly as a manual correction. When disabled, heat must be entered and applied manually.

+
+

Yes starts the workflow when it triggers, Ask first requests + permission, and No leaves it for manual tracking.

+
+
+ @for (automation of cbtAutomationOptions; track automation.key) { +
+
+ + {{ automation.label }}: + +
+ @for (mode of cbtAutomationModes; track mode.value) { + + } +
+
+
+

{{ automation.description }}

+
+ }
Optional rules
diff --git a/src/app/components/options-dialog/options-dialog.component.scss b/src/app/components/options-dialog/options-dialog.component.scss index f1f1d9401..bfc5cfa9c 100644 --- a/src/app/components/options-dialog/options-dialog.component.scss +++ b/src/app/components/options-dialog/options-dialog.component.scss @@ -361,6 +361,64 @@ width: min(360px, 48%); } +.automation-options { + display: flex; + min-width: 0; + margin: 0; + padding: 0; + border: 0; + flex-direction: column; + gap: 12px; +} + +.automation-option-label { + flex: 1 1 auto; +} + +.automation-mode-switch { + display: grid; + width: 100%; + grid-template-columns: repeat(3, minmax(0, 1fr)); + border: 1px solid var(--border-color); + overflow: hidden; +} + +.automation-mode-choice { + padding: 5px; + border: 0; + border-right: 1px solid var(--border-color); + background: var(--background-color-menu); + color: var(--text-color-secondary); + cursor: pointer; + text-transform: uppercase; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.automation-mode-choice:last-child { + border-right: 0; +} + +.automation-mode-choice:hover:not(.selected), +.automation-mode-choice:focus-visible:not(.selected) { + background: var(--background-highlight); + color: var(--text-color); +} + +.automation-mode-choice.selected { + background: var(--bt-yellow); + color: #111; + font-weight: 600; +} + +.options-desktop-shell .automation-mode-switch { + width: min(360px, 30%); + flex: 0 0 auto; +} + +.options-mobile-detail .automation-mode-switch { + width: 100%; +} + .account-summary-row { display: flex; align-items: baseline; diff --git a/src/app/components/options-dialog/options-dialog.component.spec.ts b/src/app/components/options-dialog/options-dialog.component.spec.ts index 2fe76955f..17f6b862b 100644 --- a/src/app/components/options-dialog/options-dialog.component.spec.ts +++ b/src/app/components/options-dialog/options-dialog.component.spec.ts @@ -73,16 +73,35 @@ describe('OptionsDialogComponent', () => { expect(setOption).toHaveBeenCalledOnceWith('forceViewerBVPVDisplay', 'both'); }); - it('persists the CBT automations selection as a boolean', () => { + it('updates one CBT automation mode without changing the others', () => { const setOption = jasmine.createSpy('setOption'); - const component = configureComponent({ options: () => ({ unitServers: [] }), setOption }); - const select = document.createElement('select'); - select.innerHTML = ''; - select.value = 'false'; + const component = configureComponent({ + options: () => ({ + unitServers: [], + cbtAutomationOptions: { + heatAndDissipation: 'yes', + heatEffects: 'ask', + pilotHitsAndConsciousness: 'ask', + internalExplosions: 'yes', + criticalHitChance: 'no', + breachAndFlood: 'ask', + falling: 'yes', + }, + }), + setOption, + }); - component.onCbtAutomationsChange({ target: select } as unknown as Event); + component.onCbtAutomationModeChange('heatAndDissipation', 'ask'); - expect(setOption).toHaveBeenCalledOnceWith('cbtAutomations', false); + expect(setOption).toHaveBeenCalledOnceWith('cbtAutomationOptions', { + heatAndDissipation: 'ask', + heatEffects: 'ask', + pilotHitsAndConsciousness: 'ask', + internalExplosions: 'yes', + criticalHitChance: 'no', + breachAndFlood: 'ask', + falling: 'yes', + }); }); it('updates one CBT optional rule without changing the other', () => { diff --git a/src/app/components/options-dialog/options-dialog.component.ts b/src/app/components/options-dialog/options-dialog.component.ts index 4e336a3b2..53ec54c92 100644 --- a/src/app/components/options-dialog/options-dialog.component.ts +++ b/src/app/components/options-dialog/options-dialog.component.ts @@ -16,7 +16,7 @@ import { LoggerService } from '../../services/logger.service'; import { GameService } from '../../services/game.service'; import type { GameSystem } from '../../models/common.model'; import { normalizeUnitServerUrl } from '../../models/common.model'; -import type { AvailabilitySource, ForceViewerBVPVDisplay, RecordSheetDoubleTapZoomResetMode } from '../../models/options.model'; +import type { AutomationMode, AvailabilitySource, CBTAutomationKey, ForceViewerBVPVDisplay, RecordSheetDoubleTapZoomResetMode } from '../../models/options.model'; import { SpriteStorageService } from '../../services/sprite-storage.service'; import { DataService } from '../../services/data.service'; import { PublicTagsService } from '../../services/public-tags.service'; @@ -40,6 +40,12 @@ interface OptionsViewDefinition { parentId?: OptionsSectionId; } +interface CBTAutomationOptionDefinition { + key: CBTAutomationKey; + label: string; + description: string; +} + const WIDE_LAYOUT_QUERY = '(min-width: 760px) and (min-height: 560px)'; const OPTIONS_VIEW_DEFINITIONS: readonly OptionsViewDefinition[] = [ @@ -93,6 +99,48 @@ const TOP_LEVEL_OPTIONS_VIEWS = OPTIONS_VIEW_DEFINITIONS.filter(view => !view.pa const FORCE_GEN_FAILURE_SEARCH_WINDOW_MIN_MS = 300; const FORCE_GEN_FAILURE_SEARCH_WINDOW_MAX_MS = 10_000; const FORCE_GEN_FAILURE_SEARCH_WINDOW_STEP_MS = 100; +const CBT_AUTOMATION_MODES: ReadonlyArray<{ value: AutomationMode; label: string }> = [ + { value: 'yes', label: 'Yes' }, + { value: 'ask', label: 'Ask' }, + { value: 'no', label: 'No' }, +]; +const CBT_AUTOMATION_OPTIONS: readonly CBTAutomationOptionDefinition[] = [ + { + key: 'heatAndDissipation', + label: 'Heat and dissipation', + description: 'Calculate and apply heat and cooling at end of turn.', + }, + { + key: 'heatEffects', + label: 'Heat effects', + description: 'Resolve shutdown, ammunition explosion, life support, and aerospace heat checks after end-turn heat is applied. Pilot damage also follows its own setting.', + }, + { + key: 'pilotHitsAndConsciousness', + label: 'Pilot hits and consciousness', + description: 'Apply pilot injuries from head and heat effects, then resolve consciousness and recovery rolls.', + }, + { + key: 'internalExplosions', + label: 'Internal explosions', + description: 'Resolve explosion effects caused by internal equipment and ammunition.', + }, + { + key: 'criticalHitChance', + label: 'Critical hit chance', + description: 'Resolve checks that can cause critical hits.', + }, + { + key: 'breachAndFlood', + label: 'Breach and flood', + description: 'Resolve armor breaches and flooding effects.', + }, + { + key: 'falling', + label: 'Falling', + description: 'Resolve fall orientation, hit locations, and damage before the seatbelt check.', + }, +]; @Component({ @@ -136,6 +184,8 @@ export class OptionsDialogComponent { forceGenFailureSearchWindowMinMs = FORCE_GEN_FAILURE_SEARCH_WINDOW_MIN_MS; forceGenFailureSearchWindowMaxMs = FORCE_GEN_FAILURE_SEARCH_WINDOW_MAX_MS; forceGenFailureSearchWindowStepMs = FORCE_GEN_FAILURE_SEARCH_WINDOW_STEP_MS; + cbtAutomationModes = CBT_AUTOMATION_MODES; + cbtAutomationOptions = CBT_AUTOMATION_OPTIONS; forceGenFailureSearchWindowMs = computed(() => this.normalizeForceGenFailureSearchWindowMs(this.optionsService.options().forceGenerator.failureSearchWindowMs)); uuidInput = viewChild>('uuidInput'); @@ -512,9 +562,11 @@ export class OptionsDialogComponent { this.optionsService.setOption('trackPhaseAndTurn', value); } - onCbtAutomationsChange(event: Event) { - const value = (event.target as HTMLSelectElement).value === 'true'; - this.optionsService.setOption('cbtAutomations', value); + onCbtAutomationModeChange(key: CBTAutomationKey, value: AutomationMode) { + this.optionsService.setOption('cbtAutomationOptions', { + ...this.optionsService.options().cbtAutomationOptions, + [key]: value, + }); } onCBTOptionalRuleChange(key: 'forcedWithdrawal' | 'extremeRange', event: Event) { diff --git a/src/app/models/options.model.ts b/src/app/models/options.model.ts index e8795be7b..e4df498c3 100644 --- a/src/app/models/options.model.ts +++ b/src/app/models/options.model.ts @@ -37,6 +37,7 @@ export interface CBTAutomationOptions { internalExplosions: AutomationMode; criticalHitChance: AutomationMode; breachAndFlood: AutomationMode; + falling: AutomationMode; } export type CBTAutomationKey = keyof CBTAutomationOptions; diff --git a/src/app/services/options.service.spec.ts b/src/app/services/options.service.spec.ts index 1d996ca61..e8f04f8aa 100644 --- a/src/app/services/options.service.spec.ts +++ b/src/app/services/options.service.spec.ts @@ -57,6 +57,7 @@ describe('OptionsService', () => { internalExplosions: 'ask', criticalHitChance: 'ask', breachAndFlood: 'ask', + falling: 'ask', }); }); @@ -74,6 +75,7 @@ describe('OptionsService', () => { expect(service.cbtAutomationMode('heatEffects')).toBe('no'); expect(service.cbtAutomationMode('pilotHitsAndConsciousness')).toBe('ask'); expect(service.cbtAutomationMode('criticalHitChance')).toBe('ask'); + expect(service.cbtAutomationMode('falling')).toBe('ask'); }); it('restores the force sync conflict dialog preference', async () => { diff --git a/src/app/services/options.service.ts b/src/app/services/options.service.ts index 1c04dac82..d373065d2 100644 --- a/src/app/services/options.service.ts +++ b/src/app/services/options.service.ts @@ -49,6 +49,7 @@ const DEFAULT_OPTIONS: Options = { internalExplosions: 'ask', criticalHitChance: 'ask', breachAndFlood: 'ask', + falling: 'ask', }, CBTOptionalRules: { forcedWithdrawal: true, @@ -224,6 +225,11 @@ function resolveCBTAutomationOptions(saved: Options | null | undefined): CBTAuto defaults.breachAndFlood, OPTION_VALUES.automationMode, ), + falling: resolveSavedValue( + saved?.cbtAutomationOptions?.falling, + defaults.falling, + OPTION_VALUES.automationMode, + ), }; } From 016b682cb6d18c5028b1feedac4bc42d6090af61 Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 21 Aug 2026 16:24:22 +0200 Subject: [PATCH 07/87] merge from main --- .../advancement-timeline.component.spec.ts | 2 +- .../set-ammo.dialog.component.spec.ts | 4 +- ...nit-search-advanced-filters.component.html | 3 +- src/app/models/equipment.model.spec.ts | 30 +++++ src/app/models/equipment.model.ts | 19 +++- src/app/models/force.model.spec.ts | 104 +++++++++++++++++- src/app/models/force.model.ts | 34 +++++- 7 files changed, 185 insertions(+), 11 deletions(-) diff --git a/src/app/components/set-ammo-dialog/advancement-timeline.component.spec.ts b/src/app/components/set-ammo-dialog/advancement-timeline.component.spec.ts index 4a8f402e4..5e5c45760 100644 --- a/src/app/components/set-ammo-dialog/advancement-timeline.component.spec.ts +++ b/src/app/components/set-ammo-dialog/advancement-timeline.component.spec.ts @@ -13,7 +13,7 @@ describe('AdvancementTimelineComponent', () => { id, name: id, type: 'misc', - rulesRefs: 'Test Rules', + rulesRefs: [{ book: 'Test Rules', page: null }], tech, }); } diff --git a/src/app/components/set-ammo-dialog/set-ammo.dialog.component.spec.ts b/src/app/components/set-ammo-dialog/set-ammo.dialog.component.spec.ts index 7bb357647..ba0d4bf80 100644 --- a/src/app/components/set-ammo-dialog/set-ammo.dialog.component.spec.ts +++ b/src/app/components/set-ammo-dialog/set-ammo.dialog.component.spec.ts @@ -26,7 +26,7 @@ function createAmmo(id: string, kgPerShot = 100, ammo: Partial { expect(getAmmoInfoItems(ammo).find(item => item.label === 'Damage')?.value).toBe(40); }); -}); \ No newline at end of file +}); diff --git a/src/app/components/unit-search-advanced-filters/unit-search-advanced-filters.component.html b/src/app/components/unit-search-advanced-filters/unit-search-advanced-filters.component.html index bf9ea0f31..f466983ad 100644 --- a/src/app/components/unit-search-advanced-filters/unit-search-advanced-filters.component.html +++ b/src/app/components/unit-search-advanced-filters/unit-search-advanced-filters.component.html @@ -32,6 +32,7 @@ } } -
\ No newline at end of file +
diff --git a/src/app/models/equipment.model.spec.ts b/src/app/models/equipment.model.spec.ts index 00e4fd68b..40ac4e0a6 100644 --- a/src/app/models/equipment.model.spec.ts +++ b/src/app/models/equipment.model.spec.ts @@ -13,6 +13,7 @@ import { EquipmentMap, findStandardAmmoForWeapon, findIntrinsicAmmoForWeapon, + formatEquipmentRulesRefs, isBombEquipment, MiscEquipment, resolveWeaponDamage, @@ -29,6 +30,35 @@ function catalog(equipment: EquipmentMap = {}): EquipmentRegistry { } describe('equipment model', () => { + it('formats structured equipment rules references', () => { + expect(formatEquipmentRulesRefs([ + { book: 'TO:AUE', page: 181 }, + { book: 'TM', page: null }, + { book: 'BMM' }, + ])).toBe('TO:AUE, 181; TM; BMM'); + expect(formatEquipmentRulesRefs([])).toBe(''); + }); + + it('defaults missing equipment rules references to an empty array', () => { + const equipment = createEquipment({ id: 'test', name: 'Test', type: 'misc' }); + + expect(equipment.rulesRefs).toEqual([]); + }); + + it('hydrates structured equipment rules references', () => { + const equipment = createEquipment({ + id: 'test', + name: 'Test', + type: 'misc', + rulesRefs: [{ book: 'TO:AUE', page: 181 }, { book: 'TM', page: null }], + }); + + expect(equipment.rulesRefs).toEqual([ + { book: 'TO:AUE', page: 181 }, + { book: 'TM', page: null }, + ]); + }); + it('identifies fixed and variable equipment stats in one place', () => { const fixed = createEquipment({ id: 'fixed', name: 'Fixed', type: 'misc', diff --git a/src/app/models/equipment.model.ts b/src/app/models/equipment.model.ts index 87f5714b2..36dd1ea79 100644 --- a/src/app/models/equipment.model.ts +++ b/src/app/models/equipment.model.ts @@ -41,6 +41,19 @@ export type WeaponCategory = 'energy' | 'missile' | 'ballistic' | 'artillery' | export type WeaponDamageUnit = 'missile' | 'shot' | 'artillery'; +/** A rulebook containing rules for an equipment entry, optionally at a specific page. */ +export interface EquipmentRulesReference { + readonly book: string; + readonly page?: number | null; +} + +/** Formats equipment rule references for display. */ +export function formatEquipmentRulesRefs(references: readonly EquipmentRulesReference[]): string { + return references + .map(reference => reference.page == null ? reference.book : `${reference.book}, ${reference.page}`) + .join('; '); +} + /** Resolved damage values, using zero when the source has no intrinsic numeric damage. */ export interface WeaponDamage { readonly values: readonly number[]; @@ -306,7 +319,7 @@ export interface EquipmentRawData { name: string; shortName?: string; sortingName?: string; - rulesRefs?: string; + rulesRefs?: EquipmentRulesReference[]; aliases?: string[]; stats?: Partial; tech?: Partial; @@ -432,7 +445,7 @@ export class Equipment { readonly name: string; readonly shortName: string; readonly sortingName: string; - readonly rulesRefs: string; + readonly rulesRefs: EquipmentRulesReference[]; readonly aliases: string[]; protected readonly stats: EquipmentStats; readonly tech: TechData; @@ -446,7 +459,7 @@ export class Equipment { this.name = data.name; this.shortName = data.shortName ?? data.name; this.sortingName = data.sortingName ?? data.name; - this.rulesRefs = data.rulesRefs ?? ''; + this.rulesRefs = Array.isArray(data.rulesRefs) ? data.rulesRefs : []; this.aliases = data.aliases ?? []; this.type = data.type; this.modes = data.modes ?? []; diff --git a/src/app/models/force.model.spec.ts b/src/app/models/force.model.spec.ts index adc1d1831..58077ef47 100644 --- a/src/app/models/force.model.spec.ts +++ b/src/app/models/force.model.spec.ts @@ -74,6 +74,7 @@ function createStubDeserializedUnit(data: SerializedUnit): ForceUnit { update: () => undefined, getUnit: () => unit, getDisplayName: () => unit.name, + getBv: () => 0, serialize: () => data, } as unknown as ForceUnit; } @@ -113,8 +114,10 @@ class TestForce extends Force { return data; } - protected override deserializeFrom(_serialized: SerializedForce): Force { - throw new Error('Not used in TestForce'); + protected override deserializeFrom(serialized: SerializedForce): Force { + const force = new TestForce(); + force.loadSerialized(serialized); + return force; } loadSerialized(data: SerializedForce): void { @@ -237,6 +240,101 @@ describe('Force formation deserialization', () => { expect(force.groups()[0].formation()).toBe(NO_FORMATION); expect(force.groups()[0].formationLock).toBeTrue(); }); + + it('round-trips the one optional formation target group id', () => { + const force = new TestForce(); + force.loadSerialized(createSerializedForce([ + { + id: 'support', + formationId: 'support-lance', + formationTargetGroupId: 'recon', + units: [createSerializedUnit('support-unit')], + }, + { + id: 'recon', + formationId: 'recon-lance', + units: [createSerializedUnit('recon-unit')], + }, + ])); + + expect(force.groups()[0].formationTargetGroupId()).toBe('recon'); + expect(force.serialize().groups?.[0].formationTargetGroupId).toBe('recon'); + + force.update(createSerializedForce([ + { + id: 'support', + formationId: 'support-lance', + units: [createSerializedUnit('support-unit')], + }, + { + id: 'recon', + formationId: 'recon-lance', + units: [createSerializedUnit('recon-unit')], + }, + ])); + expect(force.groups()[0].formationTargetGroupId()).toBeNull(); + }); + + it('remaps formation target group ids when cloning a force', () => { + const force = new TestForce(); + force.loadSerialized(createSerializedForce([ + { + id: 'support', + formationId: 'support-lance', + formationTargetGroupId: 'recon', + units: [createSerializedUnit('support-unit')], + }, + { + id: 'recon', + formationId: 'recon-lance', + units: [createSerializedUnit('recon-unit')], + }, + ])); + + const clone = force.clone(); + const clonedSupport = clone.groups().find(group => group.activeFormation()?.id === 'support-lance')!; + const clonedRecon = clone.groups().find(group => group.activeFormation()?.id === 'recon-lance')!; + + expect(clonedSupport.id).not.toBe('support'); + expect(clonedRecon.id).not.toBe('recon'); + expect(clonedSupport.formationTargetGroupId()).toBe(clonedRecon.id); + }); + + it('clears formation target references when the target group is removed', () => { + const force = new TestForce(); + force.loadSerialized(createSerializedForce([ + { + id: 'support', + formationId: 'support-lance', + formationTargetGroupId: 'recon', + units: [createSerializedUnit('support-unit')], + }, + { + id: 'recon', + formationId: 'recon-lance', + units: [createSerializedUnit('recon-unit')], + }, + ])); + + force.removeGroup(force.groups()[1]); + + expect(force.groups()[0].formationTargetGroupId()).toBeNull(); + }); + + it('drops an invalid formation target while deserializing', () => { + const force = new TestForce(); + force.loadSerialized(createSerializedForce([ + { + id: 'support', + formationId: 'support-lance', + formationTargetGroupId: 'missing', + units: [createSerializedUnit('support-unit')], + }, + ])); + + expect(force.groups()[0].formationTargetGroupId()).toBeNull(); + expect(force.serialize().groups?.[0].formationTargetGroupId).toBeUndefined(); + }); }); describe('Force C3 cleanup', () => { @@ -277,4 +375,4 @@ describe('Force C3 cleanup', () => { expect(force.c3Networks()).toEqual([]); }); -}); \ No newline at end of file +}); diff --git a/src/app/models/force.model.ts b/src/app/models/force.model.ts index 7a7b7059f..e1ccd9822 100644 --- a/src/app/models/force.model.ts +++ b/src/app/models/force.model.ts @@ -16,6 +16,7 @@ import { LoggerService } from '../services/logger.service'; import { type Faction } from './factions.model'; import type { Era } from './eras.model'; import { type FormationTypeDefinition, type FormationMatch, formationNameMatchesGroupName, isNoFormation, NO_FORMATION } from '../utils/formation-type.model'; +import { clearInvalidFormationTargetSelection, resolveFormationTargetGroup } from '../utils/formation-target.util'; import { LanceTypeIdentifierUtil } from '../utils/lance-type-identifier.util'; import { FormationNamerUtil } from '../utils/formation-namer.util'; import type { OrgSizeResult } from '../utils/org/org-types'; @@ -200,6 +201,8 @@ export class UnitGroup { color?: string; formation = signal(null); formationLock?: boolean; // If true, the formation name will not be upgraded by the random generator (this is unset when we have automatic formation) + /** The concrete group whose formation bonus this group copies, when its formation requires one. */ + formationTargetGroupId = signal(null); formationHistory = new Set(); // Temporarily stores previously assigned formation IDs for this group units: WritableSignal = signal([]); @@ -561,6 +564,8 @@ export abstract class Force { const groups = [...this.groups()]; if (index < 0 || index >= groups.length) return null; const [removed] = groups.splice(index, 1); + this.clearFormationTargetReferences(groups, new Set([removed.id])); + removed.formationTargetGroupId.set(null); this.groups.set(groups); return removed; } @@ -607,6 +612,7 @@ export abstract class Force { } this._c3Networks.set(networks); } + this.clearFormationTargetReferences(groups, new Set([removed.id])); this.groups.set(groups); if (this.instanceId()) this.emitChanged(); } @@ -639,12 +645,23 @@ export abstract class Force { const groups = this.groups(); const nonEmptyGroups = groups.filter(g => g.units().length > 0); if (nonEmptyGroups.length === groups.length) return; // No change + const removedGroupIds = new Set(groups.filter(g => g.units().length === 0).map(g => g.id)); + this.clearFormationTargetReferences(nonEmptyGroups, removedGroupIds); this.groups.set(nonEmptyGroups); if (this.instanceId()) { this.emitChanged(); } } + private clearFormationTargetReferences(groups: readonly UnitGroup[], removedGroupIds: ReadonlySet): void { + for (const group of groups) { + const targetId = group.formationTargetGroupId(); + if (targetId && removedGroupIds.has(targetId)) { + group.formationTargetGroupId.set(null); + } + } + } + /** * Ensures no duplicate group or unit IDs exist within this force. * If duplicates are found, regenerates them with fresh UUIDs. @@ -770,12 +787,14 @@ export abstract class Force { } const serializedGroups: SerializedGroup[] = this.groups().filter(g => g.units().length > 0).map(g => { const formation = g.activeFormation(); + const formationTarget = resolveFormationTargetGroup(g); return { id: g.id, name: g.name() || undefined, color: g.color, formationId: formation?.id, formationLock: g.formationLock || undefined, + formationTargetGroupId: formationTarget?.id, units: g.units().map(u => u.serialize()) }; }); @@ -917,10 +936,12 @@ export abstract class Force { group.color = g.color || ''; group.formationLock = g.formationLock || undefined; group.formation.set(resolveSerializedFormation(g.formationId, group.formationLock, this.gameSystem)); + group.formationTargetGroupId.set(g.formationTargetGroupId ?? null); group.units.set(groupUnits); parsedGroups.push(group); } this.groups.set(parsedGroups); + parsedGroups.forEach(clearInvalidFormationTargetSelection); this.timestamp = sanitizedData.timestamp ?? null; if (sanitizedData.c3Networks) { const sanitizedNetworks = Sanitizer.sanitizeArray(sanitizedData.c3Networks, C3_NETWORK_GROUP_SCHEMA); @@ -988,6 +1009,7 @@ export abstract class Force { group.color = groupData.color; group.formationLock = groupData.formationLock || undefined; group.formation.set(resolveSerializedFormation(groupData.formationId, group.formationLock, this.gameSystem)); + group.formationTargetGroupId.set(groupData.formationTargetGroupId ?? null); if (!group.formationLock && groupData.formationId) { group.formationHistory.add(groupData.formationId); } @@ -1001,6 +1023,7 @@ export abstract class Force { group.color = groupData.color; group.formationLock = groupData.formationLock || undefined; group.formation.set(resolveSerializedFormation(groupData.formationId, group.formationLock, this.gameSystem)); + group.formationTargetGroupId.set(groupData.formationTargetGroupId ?? null); if (groupData.formationId && !group.formationLock) { group.formationHistory.add(groupData.formationId); } @@ -1023,6 +1046,7 @@ export abstract class Force { this.groups.set(updatedGroups); this.removeEmptyGroups(); + this.groups().forEach(clearInvalidFormationTargetSelection); // Update C3 networks with sanitization and validation if (sanitizedData.c3Networks) { @@ -1058,18 +1082,26 @@ export abstract class Force { public clone(): Force { const serialized = this.serialize(); - // Build old→new unit ID map + // Build old→new unit and group ID maps const unitIdMap = new Map(); + const groupIdMap = new Map(); serialized.instanceId = uuidv7(); if (serialized.groups) { for (const group of serialized.groups) { + const previousGroupId = group.id; group.id = uuidv7(); + groupIdMap.set(previousGroupId, group.id); for (const unit of group.units) { const newId = uuidv7(); unitIdMap.set(unit.id, newId); unit.id = newId; } } + for (const group of serialized.groups) { + if (group.formationTargetGroupId) { + group.formationTargetGroupId = groupIdMap.get(group.formationTargetGroupId); + } + } } // Remap C3 network references From 7cb6bf4ff47b88134f7b29451f5abf140df8a445 Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 21 Aug 2026 16:25:11 +0200 Subject: [PATCH 08/87] merge from main --- src/app/models/as-force-unit.model.spec.ts | 49 +++++++++++++++++++++- src/app/models/as-force-unit.model.ts | 23 +++++++++- src/app/models/as-force.model.ts | 4 +- src/app/models/units.model.ts | 1 + 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/app/models/as-force-unit.model.spec.ts b/src/app/models/as-force-unit.model.spec.ts index b60b3f7ae..a6c1ce917 100644 --- a/src/app/models/as-force-unit.model.spec.ts +++ b/src/app/models/as-force-unit.model.spec.ts @@ -2,14 +2,17 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { Injector, provideZonelessChangeDetection } from '@angular/core'; +import { Injector, provideZonelessChangeDetection, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import type { DataService } from '../services/data.service'; import type { UnitInitializerService } from '../services/unit-initializer.service'; import { createEmptyUnit } from '../testing/unit-test-helpers'; import type { ASForce } from './as-force.model'; import { ASForceUnit } from './as-force-unit.model'; +import { GameSystem } from './common.model'; +import type { UnitGroup } from './force.model'; import type { Unit } from './units.model'; +import { getFormationDefinition } from '../utils/formation-blueprints'; describe('ASForceUnit ability effects', () => { let injector: Injector; @@ -132,6 +135,48 @@ describe('ASForceUnit ability effects', () => { expect(forceUnit.isShutdown()).toBeFalse(); }); + it('keeps a Support setup snapshot but deactivates it below three active units', () => { + let groups: UnitGroup[] = []; + const force = { + owned: () => true, + emitChanged: jasmine.createSpy('emitChanged'), + gameSystem: GameSystem.ALPHA_STRIKE, + groups: () => groups, + } as unknown as ASForce; + const createOwnedUnit = (): ASForceUnit => new ASForceUnit( + createTestUnit(), + force, + {} as DataService, + {} as UnitInitializerService, + injector, + ); + const supportUnits = [createOwnedUnit(), createOwnedUnit(), createOwnedUnit()]; + const targetUnit = createOwnedUnit(); + const supportGroup = { + id: 'support', + force, + units: () => supportUnits, + activeFormation: () => getFormationDefinition('support-lance', GameSystem.ALPHA_STRIKE), + formationTargetGroupId: signal('target'), + } as unknown as UnitGroup; + const targetGroup = { + id: 'target', + force, + units: () => [targetUnit], + activeFormation: () => getFormationDefinition('striker-lance', GameSystem.ALPHA_STRIKE), + formationTargetGroupId: signal(null), + } as unknown as UnitGroup; + groups = [supportGroup, targetGroup]; + supportUnits[0].setFormationAbilities(['hot_dog'], false); + + expect(supportUnits[0].activeFormationAbilities()).toEqual(['hot_dog']); + supportUnits[2].setDestroyed(true); + expect(supportUnits[0].formationAbilities()).toEqual(['hot_dog']); + expect(supportUnits[0].activeFormationAbilities()).toEqual([]); + supportUnits[2].setDestroyed(false); + expect(supportUnits[0].activeFormationAbilities()).toEqual(['hot_dog']); + }); + it('uses preview heat for pending Hot Dog shutdown and movement', () => { const forceUnit = createForceUnit(); forceUnit.setPilotAbilities(['hot_dog']); @@ -336,4 +381,4 @@ describe('ASForceUnit ability effects', () => { expect(forceUnit.effectiveMovement()).toEqual({ '': 10, j: 8 }); }); -}); \ No newline at end of file +}); diff --git a/src/app/models/as-force-unit.model.ts b/src/app/models/as-force-unit.model.ts index aa4389c47..6857b4e6b 100644 --- a/src/app/models/as-force-unit.model.ts +++ b/src/app/models/as-force-unit.model.ts @@ -31,6 +31,7 @@ import { resolveASAbilityEffects, } from '../utils/as-ability-effect-engine.util'; import { isAerospace, isAerospaceMovementMode, isGroundMovementMode } from '../utils/as-common.util'; +import { isFormationTargetCopyBonusActive } from '../utils/formation-target.util'; /** Represents either a standard ability (by ID) or a custom ability (object) */ export type AbilitySelection = string | ASCustomPilotAbility; @@ -51,6 +52,24 @@ export class ASForceUnit extends ForceUnit { readonly pilotSkill = this._pilotSkill.asReadonly(); readonly manualPilotAbilities = this._pilotAbilities.asReadonly(); readonly formationAbilities = this._formationAbilities.asReadonly(); + /** + * Formation choices remain serialized as the setup snapshot, but a Support + * Formation's copied bonus is inactive while fewer than three of its units + * remain active. Keeping this runtime-only also restores the same choices + * if the formation later returns to three active units. + */ + readonly activeFormationAbilities = computed(() => { + const abilities = this._formationAbilities(); + if (abilities.length === 0) return abilities; + + const owner = this.force.groups().find((group) => + group.units().some((unit) => unit.id === this.id) + ); + if (!owner || isFormationTargetCopyBonusActive(owner)) { + return abilities; + } + return []; + }); readonly pilotAbilities = computed(() => { const manualAbilities = this._pilotAbilities(); const mergedAbilities: AbilitySelection[] = [...manualAbilities]; @@ -59,7 +78,7 @@ export class ASForceUnit extends ForceUnit { .filter((ability): ability is string => typeof ability === 'string') ); - for (const abilityId of this._formationAbilities()) { + for (const abilityId of this.activeFormationAbilities()) { if (seenAbilityIds.has(abilityId)) { continue; } @@ -222,7 +241,7 @@ export class ASForceUnit extends ForceUnit { } } - for (const abilityId of this._formationAbilities()) { + for (const abilityId of this.activeFormationAbilities()) { const pilotRef: ASAbilityEffectRef = { source: 'pilot', id: abilityId }; if (hasRegisteredASAbilityEffect(pilotRef)) { refs.push(pilotRef); diff --git a/src/app/models/as-force.model.ts b/src/app/models/as-force.model.ts index a689c7020..0b63a0bd2 100644 --- a/src/app/models/as-force.model.ts +++ b/src/app/models/as-force.model.ts @@ -63,13 +63,13 @@ export class ASForce extends Force { ): ASForce { const force = new ASForce(data.name ?? 'Unnamed Force', dataService, unitInitializer, injector); force.populateFromSerialized(data); - force.groups().forEach((group) => FormationAbilityAssignmentUtil.reconcileGroupFormationAssignments(group, { markModified: false })); + FormationAbilityAssignmentUtil.reconcileForceFormationAssignments(force, { markModified: false }); return force; } public override update(data: SerializedForce): void { super.update(data); - this.groups().forEach((group) => FormationAbilityAssignmentUtil.reconcileGroupFormationAssignments(group, { markModified: false })); + FormationAbilityAssignmentUtil.reconcileForceFormationAssignments(this, { markModified: false }); } protected override deserializeFrom(serialized: SerializedForce): ASForce { diff --git a/src/app/models/units.model.ts b/src/app/models/units.model.ts index 4e1b8c6ba..e381365d0 100644 --- a/src/app/models/units.model.ts +++ b/src/app/models/units.model.ts @@ -139,6 +139,7 @@ export interface Unit { engineHSType: string | null; // Type of HeatSinks on the engine: "Heat Sink", "Double Heat Sink", "Laser Heat Sink", etc... source: string[]; // Sourcebook abbreviations exported from units.json. published: string[]; // Record sheet source(s), e.g. "RS:AS". + rulesRefs: string[][]; // Alternative rulebook combinations that fully cover the unit, e.g. [["Core"], ["TW", "IO:AUE"]]. canon: boolean; // True if the unit is canon, false if is not (e.g. alt-universe or april fools units) canAntiMech: boolean; // Whether the unit's Anti-Mech skill can be assigned below its restricted default role: string; From 540b83d5acfe4c72f5175e4a8ae9c27684df1ceb Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 21 Aug 2026 16:33:07 +0200 Subject: [PATCH 09/87] merge from main --- .../equipment-handlers/escalating-equipment.handler.spec.ts | 4 +++- src/app/models/rules/game-rules.ts | 2 +- src/app/models/rules/vehicle-rules.spec.ts | 1 + src/app/utils/inventory-control-opfor-target.util.spec.ts | 1 + src/app/utils/inventory-control-opfor-target.util.ts | 2 +- 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/app/equipment-handlers/escalating-equipment.handler.spec.ts b/src/app/equipment-handlers/escalating-equipment.handler.spec.ts index 72159af39..934f999b7 100644 --- a/src/app/equipment-handlers/escalating-equipment.handler.spec.ts +++ b/src/app/equipment-handlers/escalating-equipment.handler.spec.ts @@ -38,8 +38,10 @@ function equipmentFixture( unitType: 'Mek' | 'Aero' = 'Mek', turnStateOverrides: Partial unknown>> = {}, ): EquipmentFixture { + const moveMode = turnStateOverrides.moveMode ?? (() => null); const turnState = { - moveMode: () => null, + moveMode, + effectiveMoveMode: moveMode, weaponsHeat: () => 0, ...turnStateOverrides, } as unknown as TurnState; diff --git a/src/app/models/rules/game-rules.ts b/src/app/models/rules/game-rules.ts index 512bf2cc6..b8458552f 100644 --- a/src/app/models/rules/game-rules.ts +++ b/src/app/models/rules/game-rules.ts @@ -564,7 +564,7 @@ export class GameRules extends CBTGameRules { return 'Caps internal damage at 10; if the location survives, up to 10 excess damage vents through its armor. Damage never transfers.'; } if (protection === 'case-ii') { - return 'Caps internal damage at 1; if the location survives, up to 10 excess damage vents through its armor. Damage never transfers; each resulting critical hit is ignored on 8+.'; + return 'Caps internal damage at 1; if the location survives, up to 10 excess damage vents through its armor. Damage never transfers; the resulting critical hit check has a −1 modifier.'; } return null; } diff --git a/src/app/models/rules/vehicle-rules.spec.ts b/src/app/models/rules/vehicle-rules.spec.ts index 2d3bf1567..8dc73e0b7 100644 --- a/src/app/models/rules/vehicle-rules.spec.ts +++ b/src/app/models/rules/vehicle-rules.spec.ts @@ -169,6 +169,7 @@ function createRulesHarness(options: { gunnerySkill: () => options.gunnery ?? 4, turnState: () => ({ moveMode: () => options.moveMode ?? null, + effectiveMoveMode: () => options.moveMode ?? null, moveDistance: () => options.moveDistance ?? 0, spotting: () => false, getAttackMovementModifier: () => rules.getAttackMovementModifier(options.moveMode ?? null), diff --git a/src/app/utils/inventory-control-opfor-target.util.spec.ts b/src/app/utils/inventory-control-opfor-target.util.spec.ts index 2593bf3a8..21980033e 100644 --- a/src/app/utils/inventory-control-opfor-target.util.spec.ts +++ b/src/app/utils/inventory-control-opfor-target.util.spec.ts @@ -51,6 +51,7 @@ function forceUnit(options: { moveDistance, airborne, moveMode, + effectiveMoveMode: moveMode, cover, isDepth1: () => cover() === 'underwater-depth-1', }) diff --git a/src/app/utils/inventory-control-opfor-target.util.ts b/src/app/utils/inventory-control-opfor-target.util.ts index 09b4ff45f..08ab4c240 100644 --- a/src/app/utils/inventory-control-opfor-target.util.ts +++ b/src/app/utils/inventory-control-opfor-target.util.ts @@ -44,7 +44,7 @@ export function deriveOpforTargetCalculatorState( const immobile = unit.getCondition('immobile'); const prone = unit.getCondition('prone'); const moveDistance = unit.turnState().moveDistance(); - const isAirborne = unit.turnState().moveMode() === 'jump' || unit.turnState().airborne() === true; + const isAirborne = unit.turnState().effectiveMoveMode() === 'jump' || unit.turnState().airborne() === true; const cover = unit.turnState().cover(); const narcWaterLayers = unit.getActiveNarcWaterLayers(); const targetMovementBracket = moveDistance !== null From ff292d8cbe48d02f2d0f5c4285e24eab6e03e23f Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 21 Aug 2026 17:31:30 +0200 Subject: [PATCH 10/87] merge main --- .../coolant-system-failure.util.ts | 2 +- src/app/models/rules/mek-rules.spec.ts | 1197 ++++++++++++++++- src/app/models/rules/tw-rules.spec.ts | 244 +++- src/app/models/rules/tw-rules.ts | 189 ++- src/app/models/rules/unit-type-rules.ts | 58 +- 5 files changed, 1607 insertions(+), 83 deletions(-) diff --git a/src/app/equipment-handlers/coolant-system-failure.util.ts b/src/app/equipment-handlers/coolant-system-failure.util.ts index bd7b6c871..d6fd2ee9a 100644 --- a/src/app/equipment-handlers/coolant-system-failure.util.ts +++ b/src/app/equipment-handlers/coolant-system-failure.util.ts @@ -20,7 +20,7 @@ export function getFailedCoolantSystemHeatSources( if (!isEquipmentDisabledByFailure(equipment) && !committedDestroyed) return []; const sources: UnitHeatSource[] = []; - const moveMode = turnState.moveMode(); + const moveMode = turnState.effectiveMoveMode(); if (moveMode !== null && moveMode !== 'stationary') { sources.push({ id: `${sourceId}:movement`, label, value: 1 }); } diff --git a/src/app/models/rules/mek-rules.spec.ts b/src/app/models/rules/mek-rules.spec.ts index 2cc2acd24..edebc3fed 100644 --- a/src/app/models/rules/mek-rules.spec.ts +++ b/src/app/models/rules/mek-rules.spec.ts @@ -11,7 +11,7 @@ import { MountedEquipment, MountedWeapon } from '../mounted-equipment.model'; import { type CriticalSlot, type LocationData } from '../force-serialization'; import { AmmoEquipment, Equipment, WeaponEquipment, type AmmoType } from '../equipment.model'; import { EquipmentRegistry } from '../equipment-lookup'; -import type { Unit, UnitSubtype } from '../units.model'; +import type { Unit, UnitComponent, UnitSubtype } from '../units.model'; import { DataService } from '../../services/data.service'; import { EquipmentInteractionRegistryService } from '../../services/equipment-interaction-registry.service'; import { UnitInitializerService } from '../../services/unit-initializer.service'; @@ -27,6 +27,7 @@ import { VIBROBLADE_MODE_STATE, VIBROBLADE_ON_MODE, VibrobladeHandler } from '.. import { PPC_CAPACITOR_CHARGED_STATE, PPC_CAPACITOR_CHARGING_STATE, PPC_CAPACITOR_STATE_KEY, PpcCapacitorHandler } from '../../equipment-handlers/ppc-capacitor.handler'; import { EquipmentFlag } from '../equipment-flags.type'; import { isInventoryControlSelectableEntry, syncSvgMode } from '../../utils/inventory-control.util'; +import { MEK_LOCATIONS, MEK_QUAD_LOCATIONS, MEK_TRIPOD_LOCATIONS } from '../entity/types'; class TestCBTForce extends CBTForce { override emitChanged(): void { @@ -64,6 +65,7 @@ function createRulesHarness(options: { subtype?: UnitSubtype; rulesId?: 'core2026' | 'tw'; forcedWithdrawal?: boolean; + components?: UnitComponent[]; } = {}): MekRules { return createForceUnitHarness(options).rules as MekRules; } @@ -107,6 +109,18 @@ function normalizeGeneratedCriticalSlots(criticalSlots: readonly CriticalSlot[]) }); } +function canonicalMekInternalLocations(subtype: UnitSubtype, requested: readonly string[] = []): string[] { + const isQuad = subtype.startsWith('Quad') + || requested.some(loc => ['FLL', 'FRL', 'RLL', 'RRL'].includes(loc)); + const isTripod = subtype.startsWith('Tripod') || requested.includes('CL'); + const canonical: readonly string[] = isQuad + ? MEK_QUAD_LOCATIONS + : isTripod + ? MEK_TRIPOD_LOCATIONS + : MEK_LOCATIONS; + return [...new Set([...canonical, ...requested])]; +} + function createForceUnitHarness(options: { crewStates?: Exclude[]; crewHits?: number[]; @@ -125,6 +139,7 @@ function createForceUnitHarness(options: { subtype?: UnitSubtype; rulesId?: 'core2026' | 'tw'; forcedWithdrawal?: boolean; + components?: UnitComponent[]; } = {}): CBTForceUnit { optionsService.options.update(current => ({ ...current, @@ -146,12 +161,13 @@ function createForceUnitHarness(options: { umu: options.umu ?? 2, tons: options.tons ?? 50, engine: options.engine ?? 'Fusion', + comp: options.components ?? [], }); dataService.getUnitByName.and.callFake((name: string): Unit | undefined => name === baseUnit.name ? baseUnit : undefined); const force = new TestCBTForce('Test Force', dataService, unitInitializer, injector); const forceUnit = new CBTForceUnit(baseUnit, force, dataService, unitInitializer, injector); - const internalLocations = options.internalLocations ?? ['LL', 'RL']; + const internalLocations = canonicalMekInternalLocations(baseUnit.subtype, options.internalLocations); const locationPoints = options.locationPoints ?? 1; forceUnit.locations = { internal: new Map(internalLocations.map(loc => [loc, { loc, points: locationPoints }])), @@ -343,6 +359,8 @@ function createShieldHarness( { ...crit('Triple Strength Myomer', false), loc: 'RT', slot: 0, eq: tsm }, ], }); + forceUnit.locations!.armor.set('DALA', { loc: 'DALA', rear: false, points: 5 }); + forceUnit.locations!.armor.set('DCLA', { loc: 'DCLA', rear: false, points: 18 }); const currentShieldCriticals = forceUnit.getCritSlots().filter(slot => slot.eq === shieldEquipment); forceUnit.setInventory([new MountedEquipment({ owner: forceUnit, @@ -355,6 +373,88 @@ function createShieldHarness( return { forceUnit, shield: forceUnit.getInventory()[0] }; } +function unitComponent(equipment: Equipment, quantity: number, location: string): UnitComponent { + return { + id: equipment.id, + q: quantity, + n: equipment.name, + t: 'C', + p: 0, + l: location, + eq: equipment, + }; +} + +function createShieldPropulsionHarness( + rulesId: 'core2026' | 'tw', + size: 'medium' | 'large', + destroyedShieldCriticals = 0, +): { forceUnit: CBTForceUnit; shield: MountedEquipment } { + const large = size === 'large'; + const shieldEquipment = miscEquipment( + large ? 'ISLargeShield' : 'ISMediumShield', + large ? 'Shield (Large)' : 'Shield (Medium)', + ['F_SHIELD', large ? 'S_SHIELD_LARGE' : 'S_SHIELD_MEDIUM'], + ); + const jumpJet = miscEquipment('ISJumpJet', 'Jump Jet', ['F_JUMP_JET']); + const umu = miscEquipment('ISUMU', 'UMU', ['F_UMU']); + const shieldCriticalCount = large ? 7 : 5; + const shieldCriticals: CriticalSlot[] = Array.from({ length: shieldCriticalCount }, (_, index) => ({ + ...crit(shieldEquipment.name, index < destroyedShieldCriticals), + id: `${shieldEquipment.id}@LA#${index + 4}`, + loc: 'LA', + slot: index + 4, + eq: shieldEquipment, + })); + const forceUnit = createForceUnitHarness({ + rulesId, + walk: 4, + run: 6, + jump: large ? 0 : 2, + umu: large ? 0 : 2, + components: [ + unitComponent(jumpJet, 3, 'LT'), + unitComponent(umu, 2, 'RT'), + ], + internalLocations: ['LA', 'RA', 'LT', 'RT', 'LL', 'RL'], + critSlots: [ + ...armCritSlots('LA'), + ...armCritSlots('RA'), + ...shieldCriticals, + ...Array.from({ length: 3 }, (_, index) => ({ + ...crit('Jump Jet', false), + id: `jump-jet-${index}`, + loc: 'LT', + slot: index, + eq: jumpJet, + })), + ...Array.from({ length: 2 }, (_, index) => ({ + ...crit('UMU', false), + id: `umu-${index}`, + loc: 'RT', + slot: index, + eq: umu, + })), + ], + }); + forceUnit.locations!.armor.set('DALA', { + loc: 'DALA', rear: false, points: large ? 7 : 5, + }); + forceUnit.locations!.armor.set('DCLA', { + loc: 'DCLA', rear: false, points: large ? 25 : 18, + }); + const currentShieldCriticals = forceUnit.getCritSlots().filter(slot => slot.eq === shieldEquipment); + forceUnit.setInventory([new MountedEquipment({ + owner: forceUnit, + id: `${shieldEquipment.id}@LA`, + name: shieldEquipment.name, + equipment: shieldEquipment, + locations: new Set(['LA']), + critSlots: currentShieldCriticals, + })]); + return { forceUnit, shield: forceUnit.getInventory()[0] }; +} + function directFireWeaponEntry(forceUnit: CBTForceUnit, flags: EquipmentFlag[] = []): MountedEquipment { const equipment = new WeaponEquipment({ id: 'DirectFireWeapon', @@ -1065,6 +1165,160 @@ describe('MekRules', () => { expect(depletedCore.forceUnit.isEquipmentOperational(depletedCore.shield)).toBeFalse(); }); + it('subtracts 1 DA and 5 DC for every destroyed shield critical in both rulesets', () => { + for (const rulesId of ['core2026', 'tw'] as const) { + const { forceUnit } = createShieldHarness(rulesId, 1); + const rules = forceUnit.rules as MekRules; + + expect(rules.getShieldTrackHits('DALA')).withContext(`${rulesId} DA`).toBe(1); + expect(rules.getShieldTrackHits('DCLA')).withContext(`${rulesId} DC`).toBe(5); + } + }); + + it('uses exhausted DA or DC for Core shield mobility but retains the TW modifier', () => { + for (const track of ['DALA', 'DCLA'] as const) { + const core = createShieldHarness('core2026'); + const tw = createShieldHarness('tw'); + const hits = core.forceUnit.getArmorPoints(track); + core.forceUnit.setArmorHits(track, hits); + tw.forceUnit.setArmorHits(track, hits); + + expect((core.forceUnit.rules as MekRules).movementState()) + .withContext(`Core ${track}`) + .toEqual(jasmine.objectContaining({ walk: 6, run: 9 })); + expect(core.forceUnit.isEquipmentOperational(core.shield)) + .withContext(`Core ${track}`) + .toBeFalse(); + expect((tw.forceUnit.rules as MekRules).movementState()) + .withContext(`TW ${track}`) + .toEqual(jasmine.objectContaining({ walk: 5, run: 8 })); + expect(tw.forceUnit.isEquipmentOperational(tw.shield)) + .withContext(`TW ${track}`) + .toBeFalse(); + } + }); + + it('removes the shield mobility modifier at the ruleset-specific destruction threshold', () => { + const coreZeroCapacity = createShieldHarness('core2026', 4); + const twOneCriticalRemaining = createShieldHarness('tw', 4); + const twAllCriticalsDestroyed = createShieldHarness('tw', 5); + + expect((coreZeroCapacity.forceUnit.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 6, run: 9 })); + expect((twOneCriticalRemaining.forceUnit.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 5, run: 8 })); + expect((twAllCriticalsDestroyed.forceUnit.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 6, run: 9 })); + }); + + it('restores shield-suppressed Jump and UMU from installed propulsion', () => { + for (const rulesId of ['core2026', 'tw'] as const) { + const activeMedium = createShieldPropulsionHarness(rulesId, 'medium'); + const destroyedMedium = createShieldPropulsionHarness(rulesId, 'medium', 5); + const activeLarge = createShieldPropulsionHarness(rulesId, 'large'); + const destroyedLarge = createShieldPropulsionHarness(rulesId, 'large', 7); + + expect((activeMedium.forceUnit.rules as MekRules).movementState()) + .withContext(`${rulesId} active medium shield`) + .toEqual(jasmine.objectContaining({ walk: 4, jump: 2, UMU: 2 })); + expect((destroyedMedium.forceUnit.rules as MekRules).movementState()) + .withContext(`${rulesId} destroyed medium shield`) + .toEqual(jasmine.objectContaining({ walk: 5, jump: 3, UMU: 2 })); + expect((activeLarge.forceUnit.rules as MekRules).movementState()) + .withContext(`${rulesId} active large shield`) + .toEqual(jasmine.objectContaining({ walk: 4, jump: 0, UMU: 0 })); + expect((destroyedLarge.forceUnit.rules as MekRules).movementState()) + .withContext(`${rulesId} destroyed large shield`) + .toEqual(jasmine.objectContaining({ walk: 5, jump: 3, UMU: 2 })); + + const activeModes = activeLarge.forceUnit.getAvailableMotiveModes(false).map(option => option.mode); + const restoredModes = destroyedLarge.forceUnit.getAvailableMotiveModes(false).map(option => option.mode); + expect(activeModes).withContext(`${rulesId} active large shield modes`).not.toContain('jump'); + expect(activeModes).withContext(`${rulesId} active large shield modes`).not.toContain('UMU'); + expect(restoredModes).withContext(`${rulesId} destroyed large shield modes`).toContain('jump'); + expect(restoredModes).withContext(`${rulesId} destroyed large shield modes`).toContain('UMU'); + } + }); + + it('uses Core DA exhaustion but TW critical destruction to restore shield-suppressed Jump', () => { + const core = createShieldPropulsionHarness('core2026', 'medium'); + const tw = createShieldPropulsionHarness('tw', 'medium'); + core.forceUnit.setArmorHits('DALA', 5); + tw.forceUnit.setArmorHits('DALA', 5); + + expect((core.forceUnit.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ jump: 3 })); + expect((tw.forceUnit.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ jump: 2 })); + }); + + it('uses DA zero in Core but all unavailable shield criticals in TW for the mobility modifier', () => { + const core = createShieldHarness('core2026'); + const tw = createShieldHarness('tw'); + for (const forceUnit of [core.forceUnit, tw.forceUnit]) { + for (const slot of forceUnit.getCritSlots().filter(candidate => + candidate.loc === 'LA' && !candidate.eq?.hasFlag('F_SHIELD'))) { + forceUnit.setCritLoc({ ...slot, destroyed: 1 }); + } + } + + expect((core.forceUnit.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 6, run: 9 })); + expect((tw.forceUnit.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 5, run: 8 })); + + tw.forceUnit.setLocations(createCommittedLocationState(['LA']), true); + + expect((tw.forceUnit.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 6, run: 9 })); + }); + + it('restores ended equipment penalties before applying movement damage', () => { + const shieldEquipment = miscEquipment( + 'ISMediumShield', + 'Shield (Medium)', + ['F_SHIELD', 'S_SHIELD_MEDIUM'], + ); + const modularArmor = miscEquipment( + 'ISModularArmor', + 'Modular Armor', + ['F_MODULAR_ARMOR'], + ); + const shieldCriticals: CriticalSlot[] = Array.from({ length: 5 }, (_, index) => ({ + ...crit('Shield (Medium)'), + id: `ISMediumShield@LA#${index + 4}`, + loc: 'LA', + slot: index + 4, + eq: shieldEquipment, + })); + const forceUnit = createForceUnitHarness({ + rulesId: 'tw', + walk: 2, + run: 3, + internalLocations: ['LA', 'RA', 'LT', 'LL', 'RL'], + critSlots: [ + ...shieldCriticals, + { ...crit('Modular Armor', false), id: 'modular-armor', loc: 'LT', slot: 0, eq: modularArmor }, + { ...crit('Hip'), id: 'left-hip', loc: 'LL', slot: 0 }, + ], + }); + const currentShieldCriticals = forceUnit.getCritSlots().filter(slot => slot.eq === shieldEquipment); + forceUnit.setInventory([new MountedEquipment({ + owner: forceUnit, + id: 'ISMediumShield@LA', + name: 'Shield (Medium)', + equipment: shieldEquipment, + locations: new Set(['LA']), + critSlots: currentShieldCriticals, + })]); + + // Stored Walk 2 includes both -1 penalties. The destroyed shield first + // restores the live base to 3; the surviving modular armor remains baked + // in, then the TW hip hit halves 3 to 2. + expect((forceUnit.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 2, run: 3 })); + }); + it('identifies shoulder and paired AES modifiers for push attacks', () => { const forceUnit = createForceUnitHarness({ critSlots: [ @@ -1576,7 +1830,8 @@ describe('MekRules', () => { const punchModifiers = rules.getEquipmentToHitModifiers(punchEntry(forceUnit)); expect(toHitModifierTotal(punchModifiers)).toBe(-1); expect(punchModifiers).toEqual([{ label: 'Dedicated Pilot', modifier: -1 }]); - expect(rules.PSRTargetRoll()).toBe(4); + expect(rules.PSRModifiers().modifier).toBe(-2); + expect(rules.PSRTargetRoll()).toBe(3); }); it('uses the first active alternate gunner with a modifier when the Tripod dedicated gunnery officer is disabled', () => { @@ -1634,7 +1889,8 @@ describe('MekRules', () => { expect(punchModifiers).toEqual([ { label: 'Dedicated Pilot disabled', modifier: 2, weakened: true }, ]); - expect(rules.PSRTargetRoll()).toBe(8); + expect(rules.PSRModifiers().modifier).toBe(1); + expect(rules.PSRTargetRoll()).toBe(7); }); it('applies the Tripod dedicated pilot modifier to physical attacks', () => { @@ -2483,29 +2739,333 @@ describe('MekRules', () => { }).hasComputedCondition('crippled')).toBeFalse(); }); - it('keeps core2026 Meks mobile while a damage-available movement mode remains', () => { + it('restores the modular armor Walk penalty only after every panel is unavailable', () => { + for (const rulesId of ['core2026', 'tw'] as const) { + const createUnit = (lastPanelConsumed: number) => { + const modularArmor = miscEquipment( + 'ISModularArmor', + 'Modular Armor', + ['F_MODULAR_ARMOR'], + ); + return createForceUnitHarness({ + rulesId, + walk: 0, + run: 0, + internalLocations: ['LT', 'RT', 'LL', 'RL'], + critSlots: [ + { ...crit('Modular Armor'), id: 'modular-armor-lt', loc: 'LT', slot: 0, eq: modularArmor }, + { + ...crit('Modular Armor', false), + id: 'modular-armor-rt', + loc: 'RT', + slot: 0, + consumed: lastPanelConsumed, + eq: modularArmor, + }, + ], + }); + }; + const onePanelRemaining = createUnit(9); + const allPanelsUnavailable = createUnit(10); + + expect((onePanelRemaining.rules as MekRules).movementState()) + .withContext(rulesId) + .toEqual(jasmine.objectContaining({ walk: 0, run: 0 })); + expect((onePanelRemaining.rules as MekRules).PSRModifiers().modifier) + .withContext(rulesId) + .toBe(1); + expect((allPanelsUnavailable.rules as MekRules).movementState()) + .withContext(rulesId) + .toEqual(jasmine.objectContaining({ walk: 1, run: 2 })); + expect((allPanelsUnavailable.rules as MekRules).PSRModifiers().modifier) + .withContext(rulesId) + .toBe(0); + } + }); + + it('restores modular-armor Jump MP before applying Jump Jet damage', () => { + for (const rulesId of ['core2026', 'tw'] as const) { + const modularArmor = miscEquipment( + 'ISModularArmor', + 'Modular Armor', + ['F_MODULAR_ARMOR'], + ); + const jumpJet = miscEquipment('ISJumpJet', 'Jump Jet', ['F_JUMP_JET']); + const forceUnit = createForceUnitHarness({ + rulesId, + jump: 2, + umu: 0, + components: [unitComponent(jumpJet, 3, 'LT')], + internalLocations: ['LT', 'RT', 'LL', 'RL'], + critSlots: [ + { + ...crit('Modular Armor', false), + id: 'modular-armor', + loc: 'RT', + slot: 0, + consumed: 10, + eq: modularArmor, + }, + ...Array.from({ length: 3 }, (_, index) => ({ + ...crit('Jump Jet', index === 0), + id: `jump-jet-${index}`, + loc: 'LT', + slot: index, + eq: jumpJet, + })), + ], + }); + + expect((forceUnit.rules as MekRules).movementState()) + .withContext(rulesId) + .toEqual(jasmine.objectContaining({ jump: 2, jumpImpaired: true })); + expect(forceUnit.getAvailableMotiveModes(false).map(option => option.mode)) + .withContext(rulesId) + .toContain('jump'); + } + }); + + it('restores modular-armor Jump from mounted Improved Jump Jets rather than occupied slots', () => { + for (const rulesId of ['core2026', 'tw'] as const) { + const modularArmor = miscEquipment( + 'ISModularArmor', + 'Modular Armor', + ['F_MODULAR_ARMOR'], + ); + const improvedJumpJet = miscEquipment( + 'ISImprovedJumpJet', + 'Improved Jump Jet', + ['F_JUMP_JET', 'S_IMPROVED'], + ); + const forceUnit = createForceUnitHarness({ + rulesId, + jump: 1, + umu: 0, + components: [unitComponent(improvedJumpJet, 2, 'LT')], + internalLocations: ['LT', 'RT', 'LL', 'RL'], + critSlots: [ + { + ...crit('Modular Armor', false), + id: 'modular-armor', + loc: 'RT', + slot: 0, + consumed: 10, + eq: modularArmor, + }, + ...Array.from({ length: 4 }, (_, index) => ({ + ...crit('Improved Jump Jet', false), + id: `improved-jump-jet-${Math.floor(index / 2)}`, + loc: 'LT', + slot: index, + eq: improvedJumpJet, + })), + ], + }); + + expect((forceUnit.rules as MekRules).movementState()) + .withContext(rulesId) + .toEqual(jasmine.objectContaining({ jump: 2, jumpImpaired: false })); + + forceUnit.applyHitToCritSlot(forceUnit.getCritSlot('LT', 0)!); + forceUnit.endPhase(); + expect((forceUnit.rules as MekRules).movementState()) + .withContext(`${rulesId} first occupied slot`) + .toEqual(jasmine.objectContaining({ jump: 1, jumpImpaired: true })); + + forceUnit.applyHitToCritSlot(forceUnit.getCritSlot('LT', 1)!); + forceUnit.endPhase(); + expect((forceUnit.rules as MekRules).movementState()) + .withContext(`${rulesId} second occupied slot of the same mount`) + .toEqual(jasmine.objectContaining({ jump: 1, jumpImpaired: true })); + } + }); + + it('uses restored equipment movement as the Core pre-damage immobility baseline', () => { + const modularArmor = miscEquipment( + 'ISModularArmor', + 'Modular Armor', + ['F_MODULAR_ARMOR'], + ); + const createUnit = (consumed: number) => createForceUnitHarness({ + rulesId: 'core2026', + walk: 0, + run: 0, + jump: 0, + umu: 0, + internalLocations: ['LT', 'LL', 'RL'], + critSlots: [ + { + ...crit('Modular Armor', false), + id: 'modular-armor', + loc: 'LT', + slot: 0, + consumed, + eq: modularArmor, + }, + { ...crit('Hip'), id: 'left-hip', loc: 'LL', slot: 0 }, + ], + }); + const activeArmor = createUnit(9); + const destroyedArmor = createUnit(10); + + expect((activeArmor.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 0, run: 0 })); + expect(activeArmor.rules.hasComputedCondition('immobile')).toBeFalse(); + expect((destroyedArmor.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 0, run: 0, moveImpaired: true })); + expect(destroyedArmor.rules.hasComputedCondition('immobile')).toBeTrue(); + }); + + it('marks movement impaired when damage consumes Walk MP restored from destroyed equipment', () => { + const modularArmor = miscEquipment( + 'ISModularArmor', + 'Modular Armor', + ['F_MODULAR_ARMOR'], + ); const forceUnit = createForceUnitHarness({ - internalLocations: ['LL', 'RA', 'RT'], + rulesId: 'core2026', + walk: 0, + run: 0, + internalLocations: ['LT', 'RT', 'LL', 'RL'], + critSlots: [ + { + ...crit('Modular Armor', false), + id: 'modular-armor', + loc: 'LT', + slot: 0, + consumed: 10, + eq: modularArmor, + }, + { ...crit('Hip'), id: 'left-hip', loc: 'LL', slot: 0 }, + ], + }); + + expect((forceUnit.rules as MekRules).movementState()).toEqual(jasmine.objectContaining({ + walk: 0, + run: 0, + moveImpaired: true, + })); + + const shieldUnit = createShieldHarness('core2026', 4).forceUnit; + shieldUnit.writeCrits([ + ...shieldUnit.getCritSlots(), + { ...crit('Hip'), id: 'left-hip', loc: 'LL', slot: 0 }, + ]); + + expect((shieldUnit.rules as MekRules).movementState()).toEqual(jasmine.objectContaining({ + walk: 5, + run: 8, + moveImpaired: true, + })); + }); + + it('marks TW biped, tripod, and quad Meks immobile after the required four limbs are destroyed', () => { + const scenarios = [ + { + context: 'Biped', + internalLocations: ['LT', 'RT', 'CT', 'LA', 'RA', 'LL', 'RL'], + mobileDestroyedLocationSets: [['LA', 'RA', 'LL']], + immobileDestroyedLocations: ['LA', 'RA', 'LL', 'RL'], + }, + { + context: 'Tripod', + internalLocations: ['LT', 'RT', 'CT', 'LA', 'RA', 'LL', 'CL', 'RL'], + mobileDestroyedLocationSets: [ + ['LA', 'RA', 'LL'], + ['LA', 'LL', 'CL', 'RL'], + ], + immobileDestroyedLocations: ['LA', 'RA', 'LL', 'CL'], + }, + { + context: 'Quad', + internalLocations: ['LT', 'RT', 'CT', 'FLL', 'FRL', 'RLL', 'RRL'], + mobileDestroyedLocationSets: [['FLL', 'FRL', 'RLL']], + immobileDestroyedLocations: ['FLL', 'FRL', 'RLL', 'RRL'], + }, + ]; + + for (const scenario of scenarios) { + const immobileUnit = createForceUnitHarness({ + internalLocations: scenario.internalLocations, + committedDestroyedLocations: scenario.immobileDestroyedLocations, + rulesId: 'tw', + }); + + for (const destroyedLocations of scenario.mobileDestroyedLocationSets) { + const mobileUnit = createForceUnitHarness({ + internalLocations: scenario.internalLocations, + committedDestroyedLocations: destroyedLocations, + rulesId: 'tw', + }); + expect(mobileUnit.rules.hasComputedCondition('immobile')) + .withContext(`${scenario.context} with ${destroyedLocations.join(', ')} destroyed`) + .toBeFalse(); + } + expect(immobileUnit.rules.hasComputedCondition('immobile')) + .withContext(`${scenario.context} after the fourth required limb is destroyed`) + .toBeTrue(); + } + }); + + it('marks Core biped and tripod Meks immobile when two destroyed legs reduce ground MP to zero', () => { + const scenarios = [ + { + context: 'Biped', + internalLocations: ['LT', 'RT', 'CT', 'LA', 'RA', 'LL', 'RL'], + twoDestroyedLegs: ['LL', 'RL'], + }, + { + context: 'Tripod', + internalLocations: ['LT', 'RT', 'CT', 'LA', 'RA', 'LL', 'CL', 'RL'], + twoDestroyedLegs: ['LL', 'CL'], + }, + ]; + + for (const scenario of scenarios) { + const mobileUnit = createForceUnitHarness({ + internalLocations: scenario.internalLocations, + committedDestroyedLocations: ['LL'], + jump: 0, + umu: 0, + }); + const immobileUnit = createForceUnitHarness({ + internalLocations: scenario.internalLocations, + committedDestroyedLocations: scenario.twoDestroyedLegs, + jump: 0, + umu: 0, + }); + + expect((mobileUnit.rules as MekRules).movementState()) + .withContext(`${scenario.context} with one destroyed leg`) + .toEqual(jasmine.objectContaining({ walk: 1, run: 2 })); + expect(mobileUnit.rules.hasComputedCondition('immobile')) + .withContext(`${scenario.context} with one destroyed leg`) + .toBeFalse(); + expect((immobileUnit.rules as MekRules).movementState()) + .withContext(`${scenario.context} with two destroyed legs`) + .toEqual(jasmine.objectContaining({ walk: 0, run: 0 })); + expect(immobileUnit.rules.hasComputedCondition('immobile')) + .withContext(`${scenario.context} with two destroyed legs`) + .toBeTrue(); + } + }); + + it('keeps a Core Mek mobile until damage reduces its canonical ground movement to zero', () => { + const forceUnit = createForceUnitHarness({ + internalLocations: ['HD', 'CT', 'LT', 'RT', 'LA', 'RA', 'LL', 'RL'], committedDestroyedLocations: ['LL'], + jump: 0, + umu: 0, }); - expect(forceUnit.isInternalLocCommittedDestroyed('RA')).toBeFalse(); expect(forceUnit.rules.hasComputedCondition('immobile')).toBeFalse(); - forceUnit.setLocations(createCommittedLocationState(['LL', 'RT']), true); + forceUnit.setLocations(createCommittedLocationState(['LL', 'RL']), true); - expect(forceUnit.isInternalLocCommittedDestroyed('RA')).toBeTrue(); expect(forceUnit.rules.hasComputedCondition('immobile')).toBeTrue(); - - const twForceUnit = createForceUnitHarness({ - internalLocations: ['LL', 'RA', 'RT'], - committedDestroyedLocations: ['LL', 'RA'], - rulesId: 'tw', - }); - expect(twForceUnit.rules.hasComputedCondition('immobile')).toBeTrue(); }); - it('treats surviving Jump MP as mobile only while a zero-ground-MP Core Mek is standing', () => { + it('only lets surviving Jump MP prevent Core damage immobility while standing', () => { const forceUnit = createForceUnitHarness({ internalLocations: ['LA', 'RA', 'LL', 'RL'], committedDestroyedLocations: ['LL', 'RL'], @@ -2525,6 +3085,73 @@ describe('MekRules', () => { forceUnit.setCondition('prone', true); expect(rules.hasComputedCondition('immobile')).toBeTrue(); + + const twForceUnit = createForceUnitHarness({ + internalLocations: ['LA', 'RA', 'LL', 'RL'], + committedDestroyedLocations: ['LL', 'RL'], + critSlots: [crit('Jump Jet', false)], + jump: 1, + umu: 0, + rulesId: 'tw', + }); + expect(twForceUnit.rules.hasComputedCondition('immobile')).toBeFalse(); + + twForceUnit.setCondition('prone', true); + + // TW explicitly says that a biped with both legs destroyed is not Immobile. + expect(twForceUnit.rules.hasComputedCondition('immobile')).toBeFalse(); + }); + + it('does not mark a prone Core Mek immobile when heat, rather than damage, reduced ground MP to zero', () => { + const forceUnit = createForceUnitHarness({ + critSlots: [crit('Jump Jet', false)], + jump: 1, + umu: 0, + }); + forceUnit.setHeat(30, true); + forceUnit.setCondition('prone', true); + + expect((forceUnit.rules as MekRules).movementState()).toEqual(jasmine.objectContaining({ + walk: 0, + run: 0, + jump: 1, + })); + expect(forceUnit.rules.hasComputedCondition('immobile')).toBeFalse(); + }); + + it('does not mark a prone Core 0/0/1 Mek immobile when its unit profile already includes equipment penalties', () => { + const forceUnit = createForceUnitHarness({ + critSlots: [crit('Jump Jet', false)], + walk: 0, + run: 0, + jump: 1, + umu: 0, + }); + forceUnit.setCondition('prone', true); + + expect((forceUnit.rules as MekRules).movementState()).toEqual(jasmine.objectContaining({ + walk: 0, + run: 0, + jump: 1, + })); + expect(forceUnit.rules.hasComputedCondition('immobile')).toBeFalse(); + }); + + it('does not offer depleted Jump or UMU modes', () => { + const forceUnit = createForceUnitHarness({ + critSlots: [ + { ...crit('Jump Jet'), loc: 'LT' }, + { ...crit('UMU'), loc: 'RT' }, + ], + jump: 1, + umu: 1, + }); + + const modes = forceUnit.getAvailableMotiveModes(false).map(option => option.mode); + expect(forceUnit.rules.isMotiveModeAvailable('jump')).toBeFalse(); + expect(forceUnit.rules.isMotiveModeAvailable('UMU')).toBeFalse(); + expect(modes).not.toContain('jump'); + expect(modes).not.toContain('UMU'); }); it('does not let UMU MP keep a prone zero-ground-MP Core Mek mobile', () => { @@ -2578,8 +3205,22 @@ describe('MekRules', () => { } }); - it('offers Run only when the selected rules permit it after leg destruction', () => { + it('offers ordinary or minimum Run movement when the selected rules permit it after leg destruction', () => { const coreBiped = createForceUnitHarness({ committedDestroyedLocations: ['LL'] }); + const coreTripod = createForceUnitHarness({ + internalLocations: ['LL', 'CL', 'RL'], + committedDestroyedLocations: ['LL'], + }); + const coreQuadWithTwoDestroyedLegs = createForceUnitHarness({ + internalLocations: ['RLL', 'FLL', 'RRL', 'FRL'], + committedDestroyedLocations: ['RLL', 'FLL'], + walk: 3, + run: 5, + }); + const coreQuadWithThreeDestroyedLegs = createForceUnitHarness({ + internalLocations: ['RLL', 'FLL', 'RRL', 'FRL'], + committedDestroyedLocations: ['RLL', 'FLL', 'RRL'], + }); const twBiped = createForceUnitHarness({ committedDestroyedLocations: ['LL'], rulesId: 'tw' }); const twTripod = createForceUnitHarness({ internalLocations: ['LL', 'CL', 'RL'], @@ -2596,14 +3237,300 @@ describe('MekRules', () => { committedDestroyedLocations: ['RLL', 'FLL'], rulesId: 'tw', }); + const twQuadWithThreeDestroyedLegs = createForceUnitHarness({ + internalLocations: ['RLL', 'FLL', 'RRL', 'FRL'], + committedDestroyedLocations: ['RLL', 'FLL', 'RRL'], + rulesId: 'tw', + }); const offersRun = (unit: CBTForceUnit) => unit.getAvailableMotiveModes(false) .some(option => option.mode === 'run'); + const runAvailability = (unit: CBTForceUnit) => ({ + rules: unit.rules.isMotiveModeAvailable('run'), + offered: offersRun(unit), + }); + + expect((coreBiped.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 1, run: 2 })); + expect((coreTripod.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 1, run: 2 })); + expect((coreQuadWithTwoDestroyedLegs.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 1, run: 2 })); + expect((coreQuadWithThreeDestroyedLegs.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 1, run: 2 })); + expect((twBiped.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 1, run: 0 })); + expect((twTripod.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 1, run: 0 })); + expect((twQuadWithOneDestroyedLeg.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 4, run: 6 })); + expect((twQuadWithTwoDestroyedLegs.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 1, run: 0 })); + expect((twQuadWithThreeDestroyedLegs.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 0, run: 0 })); + expect(runAvailability(coreBiped)).toEqual({ rules: true, offered: true }); + expect(runAvailability(coreTripod)).toEqual({ rules: true, offered: true }); + expect(runAvailability(coreQuadWithTwoDestroyedLegs)) + .toEqual({ rules: true, offered: true }); + expect(runAvailability(coreQuadWithThreeDestroyedLegs)) + .toEqual({ rules: true, offered: true }); + expect(runAvailability(twBiped)).toEqual({ rules: true, offered: true }); + expect(runAvailability(twTripod)).toEqual({ rules: true, offered: true }); + expect(runAvailability(twQuadWithOneDestroyedLeg)) + .toEqual({ rules: true, offered: true }); + expect(runAvailability(twQuadWithTwoDestroyedLegs)) + .toEqual({ rules: true, offered: true }); + expect(runAvailability(twQuadWithThreeDestroyedLegs)) + .toEqual({ rules: false, offered: false }); + }); + + it('offers TW Running Minimum Movement and spends it on a one-legged stand attempt', () => { + const forceUnit = createForceUnitHarness({ + committedDestroyedLocations: ['LL'], + rulesId: 'tw', + }); + const turnState = forceUnit.turnState(); + const offersRun = () => forceUnit.getAvailableMotiveModes(false) + .some(option => option.mode === 'run'); + + expect(forceUnit.rules.isMotiveModeAvailable('run')).toBeTrue(); + expect(offersRun()).toBeTrue(); + expect(forceUnit.rules.getEffectiveMaxDistanceForMoveMode('run', turnState)).toBe(1); + + turnState.moveMode.set('run'); + turnState.moveDistance.set(1); + + expect(turnState.movementCapacityCurrentMoveMode()).toBe(1); + expect(turnState.maxDistanceCurrentMoveMode()).toBe(1); + expect(forceUnit.getAvailableMotiveModes(false).find(option => option.mode === 'run')?.psr).toBeFalse(); + + forceUnit.setCondition('prone', true); + + expect(forceUnit.rules.isMotiveModeAvailable('run')).toBeTrue(); + expect(offersRun()).toBeTrue(); + expect(turnState.movementCapacityCurrentMoveMode()).toBe(1); + expect(turnState.maxDistanceCurrentMoveMode()).toBe(1); + + expect(turnState.resolveStandAttempt('success')).toBeTrue(); + + expect(turnState.moveMode()).toBe('run'); + expect(turnState.standAttempts()).toBe(1); + expect(forceUnit.rules.getMovementPointsSpent(turnState)).toBe(2); + expect(turnState.movementCapacityCurrentMoveMode()).toBe(1); + expect(turnState.maxDistanceCurrentMoveMode()).toBe(0); + expect(offersRun()).toBeTrue(); + + turnState.moveMode.set(null); + + expect(offersRun()).toBeTrue(); + }); + + it('does not grant TW Running Minimum Movement without usable Walking MP', () => { + const forceUnit = createForceUnitHarness({ + committedDestroyedLocations: ['LL'], + rulesId: 'tw', + walk: 0, + run: 0, + }); + + expect((forceUnit.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 0, run: 0 })); + expect(forceUnit.rules.isMotiveModeAvailable('run')).toBeFalse(); + expect(forceUnit.getAvailableMotiveModes(false).some(option => option.mode === 'run')).toBeFalse(); + expect(forceUnit.rules.getEffectiveMaxDistanceForMoveMode('run', forceUnit.turnState())).toBe(0); + }); + + it('requires heat-adjusted Walking MP for ground movement and standing', () => { + const forceUnit = createForceUnitHarness({ walk: 5, run: 8 }); + const turnState = forceUnit.turnState(); + forceUnit.setCondition('prone', true); + + forceUnit.setHeatData({ current: 24, previous: 24 }); + + expect((forceUnit.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 1, run: 2 })); + expect(forceUnit.rules.isMotiveModeAvailable('walk')).toBeTrue(); + expect(forceUnit.rules.isMotiveModeAvailable('run')).toBeTrue(); + expect(turnState.canStandUp()).toBeTrue(); + + forceUnit.setHeatData({ current: 30, previous: 30 }); + + expect((forceUnit.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 0, run: 0 })); + expect(forceUnit.rules.isMotiveModeAvailable('walk')).toBeFalse(); + expect(forceUnit.rules.isMotiveModeAvailable('run')).toBeFalse(); + expect(forceUnit.getAvailableMotiveModes(false).map(option => option.mode)) + .not.toContain('walk'); + expect(forceUnit.getAvailableMotiveModes(false).map(option => option.mode)) + .not.toContain('run'); + expect(turnState.canStandUp()).toBeFalse(); + }); + + it('does not offer Walk when leg destruction leaves no ground MP or facing change', () => { + const scenarios = [ + { + context: 'Core biped with both legs destroyed', + internalLocations: ['LL', 'RL'], + committedDestroyedLocations: ['LL', 'RL'], + rulesId: 'core2026' as const, + }, + { + context: 'Core quad with all legs destroyed', + internalLocations: ['RLL', 'FLL', 'RRL', 'FRL'], + committedDestroyedLocations: ['RLL', 'FLL', 'RRL', 'FRL'], + rulesId: 'core2026' as const, + }, + { + context: 'TW biped with both legs destroyed', + internalLocations: ['LL', 'RL'], + committedDestroyedLocations: ['LL', 'RL'], + rulesId: 'tw' as const, + }, + { + context: 'TW quad with three legs destroyed', + internalLocations: ['RLL', 'FLL', 'RRL', 'FRL'], + committedDestroyedLocations: ['RLL', 'FLL', 'RRL'], + rulesId: 'tw' as const, + }, + ]; + + for (const scenario of scenarios) { + const forceUnit = createForceUnitHarness(scenario); + + expect((forceUnit.rules as MekRules).movementState()?.walk) + .withContext(scenario.context) + .toBe(0); + expect(forceUnit.rules.isMotiveModeAvailable('walk')) + .withContext(scenario.context) + .toBeFalse(); + expect(forceUnit.getAvailableMotiveModes(false).some(option => option.mode === 'walk')) + .withContext(scenario.context) + .toBeFalse(); + } + }); + + it('offers only Stationary when a TW Mek is actually immobile', () => { + const forceUnit = createForceUnitHarness({ + internalLocations: ['LA', 'RA', 'LL', 'RL'], + committedDestroyedLocations: ['LA', 'RA', 'LL', 'RL'], + rulesId: 'tw', + jump: 4, + umu: 2, + }); - expect(offersRun(coreBiped)).toBeTrue(); - expect(offersRun(twBiped)).toBeFalse(); - expect(offersRun(twTripod)).toBeFalse(); - expect(offersRun(twQuadWithOneDestroyedLeg)).toBeTrue(); - expect(offersRun(twQuadWithTwoDestroyedLegs)).toBeFalse(); + expect(forceUnit.getCondition('immobile')).toBeTrue(); + expect(forceUnit.getAvailableMotiveModes(false).map(option => option.mode)).toEqual(['stationary']); + }); + + it('colors Run for the current action rather than a potential destroyed-leg check', () => { + const forceUnit = createForceUnitHarness({ + internalLocations: ['RLL', 'FLL', 'RRL', 'FRL'], + committedDestroyedLocations: ['RLL', 'FLL', 'RRL'], + walk: 4, + run: 6, + }); + const turnState = forceUnit.turnState(); + const runOption = () => forceUnit.getAvailableMotiveModes(false) + .find(option => option.mode === 'run'); + turnState.moveMode.set('run'); + turnState.moveDistance.set(0); + forceUnit.setCondition('prone', true); + + expect(runOption()?.psr).toBeFalse(); + + forceUnit.setCondition('prone', false); + turnState.moveDistance.set(1); + + expect(runOption()?.psr).toBeTrue(); + }); + + it('treats two destroyed Core Quad legs as a hip hit when using Running MP', () => { + const forceUnit = createForceUnitHarness({ + internalLocations: ['RLL', 'FLL', 'RRL', 'FRL'], + committedDestroyedLocations: ['RLL', 'FLL'], + walk: 4, + run: 6, + }); + const turnState = forceUnit.turnState(); + turnState.moveMode.set('run'); + turnState.moveDistance.set(0); + + const runOption = forceUnit.getAvailableMotiveModes(false) + .find(option => option.mode === 'run'); + + expect(runOption?.psr).toBeFalse(); + expect(turnState.getPSRChecks()).not.toContain(jasmine.objectContaining({ + reason: 'Running with damaged hip', + })); + + turnState.moveDistance.set(1); + + expect(forceUnit.getAvailableMotiveModes(false).find(option => option.mode === 'run')?.psr).toBeTrue(); + expect(turnState.getPSRChecks()).toContain(jasmine.objectContaining({ + reason: 'Running with damaged hip', + })); + }); + + it('keeps TW Run colored when running itself triggers a zero-hex damage PSR', () => { + const forceUnit = createForceUnitHarness({ + critSlots: [{ ...crit('Gyro'), loc: 'CT' }], + rulesId: 'tw', + }); + const turnState = forceUnit.turnState(); + turnState.moveMode.set('run'); + turnState.moveDistance.set(0); + + const runOption = forceUnit.getAvailableMotiveModes(false) + .find(option => option.mode === 'run'); + + expect(runOption?.psr).toBeTrue(); + }); + + it('does not invent a TW PSR merely because destroyed-leg Minimum Movement counts as running', () => { + const biped = createForceUnitHarness({ + committedDestroyedLocations: ['LL'], + rulesId: 'tw', + }); + const bipedTurnState = biped.turnState(); + bipedTurnState.moveMode.set('run'); + bipedTurnState.moveDistance.set(0); + + expect((biped.rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 1, run: 0 })); + expect(biped.rules.isMotiveModeAvailable('run')).toBeTrue(); + expect(biped.rules.getCommittedDamageMovementModePSRCheck('run', 0)).toBeNull(); + expect(biped.getAvailableMotiveModes(false).find(option => option.mode === 'run')?.psr).toBeFalse(); + + const oneDestroyedLegQuad = createRulesHarness({ + internalLocations: ['RLL', 'FLL', 'RRL', 'FRL'], + committedDestroyedLocations: ['RLL'], + rulesId: 'tw', + }); + const twoDestroyedLegQuad = createRulesHarness({ + internalLocations: ['RLL', 'FLL', 'RRL', 'FRL'], + committedDestroyedLocations: ['RLL', 'FLL'], + rulesId: 'tw', + }); + + expect(oneDestroyedLegQuad.getCommittedDamageMovementModePSRCheck('run', 0)).toBeNull(); + expect(twoDestroyedLegQuad.isMotiveModeAvailable('run')).toBeTrue(); + expect(twoDestroyedLegQuad.getCommittedDamageMovementModePSRCheck('run', 0)).toBeNull(); + }); + + it('ignores a TW hip hit in a destroyed leg but checks a hip in the surviving leg', () => { + const destroyedLegHip = createRulesHarness({ + committedDestroyedLocations: ['LL'], + critSlots: [{ ...crit('Hip'), loc: 'LL' }], + rulesId: 'tw', + }); + const survivingLegHip = createRulesHarness({ + committedDestroyedLocations: ['LL'], + critSlots: [{ ...crit('Hip'), loc: 'RL' }], + rulesId: 'tw', + }); + + expect(destroyedLegHip.getCommittedDamageMovementModePSRCheck('run', 0)).toBeNull(); + expect(survivingLegHip.getCommittedDamageMovementModePSRCheck('run', 0)?.reason) + .toBe('Running with damaged hip'); }); it('never lets destroyed-leg movement increase a slower biped', () => { @@ -2623,8 +3550,8 @@ describe('MekRules', () => { const expected = [ { destroyed: ['RLL'], walk: 4, run: 6, psr: 1 }, { destroyed: ['RLL', 'FLL'], walk: 3, run: 5, psr: 2 }, - { destroyed: ['RLL', 'FLL', 'RRL'], walk: 1, run: 2, psr: 3 }, - { destroyed: locations, walk: 0, run: 0, psr: 4 }, + { destroyed: ['RLL', 'FLL', 'RRL'], walk: 1, run: 2, psr: 4 }, + { destroyed: locations, walk: 0, run: 0, psr: 0 }, ]; for (const scenario of expected) { @@ -2644,6 +3571,138 @@ describe('MekRules', () => { } }); + it('keeps the fixed Core 1/2 profile when the sole remaining Quad leg has actuator damage', () => { + const rules = createRulesHarness({ + internalLocations: ['RLL', 'FLL', 'RRL', 'FRL'], + committedDestroyedLocations: ['RLL', 'FLL', 'RRL'], + critSlots: [{ ...crit('Upper Leg Actuator'), loc: 'FRL' }], + walk: 5, + run: 8, + }); + + expect(rules.movementState()).toEqual(jasmine.objectContaining({ walk: 1, run: 2 })); + }); + + it('uses aggregate TW Quad leg-destruction PSR modifiers', () => { + const locations = ['RLL', 'FLL', 'RRL', 'FRL']; + const expected = [ + { destroyed: ['RLL'], modifier: 0 }, + { destroyed: ['RLL', 'FLL'], modifier: 5 }, + { destroyed: ['RLL', 'FLL', 'RRL'], modifier: 0 }, + { destroyed: locations, modifier: 0 }, + ]; + + for (const scenario of expected) { + const rules = createRulesHarness({ + internalLocations: locations, + committedDestroyedLocations: scenario.destroyed, + rulesId: 'tw', + }); + + expect(rules.PSRModifiers().modifier) + .withContext(`${scenario.destroyed.length} destroyed Quad legs`) + .toBe(scenario.modifier); + } + }); + + it('applies the TW Quad one-leg +5 only to the required jump PSR', () => { + const forceUnit = createForceUnitHarness({ + internalLocations: ['RLL', 'FLL', 'RRL', 'FRL'], + committedDestroyedLocations: ['RLL'], + rulesId: 'tw', + }); + const turnState = forceUnit.turnState(); + + expect(forceUnit.rules.PSRModifiers().modifier).toBe(0); + expect(forceUnit.rules.getCommittedDamageMovementModePSRCheck('run', 0)).toBeNull(); + expect(forceUnit.rules.getCommittedDamageMovementModePSRCheck('jump', 0)) + .toEqual(jasmine.objectContaining({ pilotCheck: 5, reason: 'Jumping with damaged leg' })); + + turnState.moveMode.set('jump'); + turnState.moveDistance.set(1); + + expect(turnState.getPSRChecks()).toEqual([ + jasmine.objectContaining({ pilotCheck: 5, reason: 'Jumping with damaged leg' }), + ]); + expect(forceUnit.rules.PSRModifiers().modifier).toBe(5); + }); + + it('halves TW Quad Walking MP for each surviving-leg hip hit after leg-loss adjustment', () => { + const rules = createRulesHarness({ + internalLocations: ['RLL', 'FLL', 'RRL', 'FRL'], + committedDestroyedLocations: ['RLL'], + critSlots: [{ ...crit('Hip'), loc: 'FRL' }], + rulesId: 'tw', + walk: 5, + run: 8, + }); + + expect(rules.movementState()).toEqual(jasmine.objectContaining({ walk: 2, run: 3 })); + }); + + it('reduces TW ground MP to zero at the terminal hip threshold without making the Mek immobile', () => { + const scenarios = [ + { name: 'biped', locations: ['LL', 'RL'], hips: ['LL', 'RL'] }, + { name: 'tripod', locations: ['LL', 'RL', 'CL'], hips: ['LL', 'RL'] }, + { name: 'tripod past threshold', locations: ['LL', 'RL', 'CL'], hips: ['LL', 'RL', 'CL'] }, + { name: 'quad', locations: ['RLL', 'FLL', 'RRL', 'FRL'], hips: ['RLL', 'FLL', 'RRL', 'FRL'] }, + ]; + + for (const scenario of scenarios) { + const forceUnit = createForceUnitHarness({ + internalLocations: scenario.locations, + critSlots: scenario.hips.map((loc, index) => ({ + ...crit('Hip'), + id: `${loc}-hip`, + loc, + slot: index, + })), + rulesId: 'tw', + walk: 5, + run: 8, + }); + + expect((forceUnit.rules as MekRules).movementState()) + .withContext(scenario.name) + .toEqual(jasmine.objectContaining({ walk: 0, run: 0, moveImpaired: true })); + expect(forceUnit.rules.hasComputedCondition('immobile')) + .withContext(scenario.name) + .toBeFalse(); + } + }); + + it('keeps the Core Quad two-leg hip-equivalent run trigger to one PSR', () => { + const forceUnit = createForceUnitHarness({ + internalLocations: ['RLL', 'FLL', 'RRL', 'FRL'], + committedDestroyedLocations: ['RLL', 'FLL'], + critSlots: [ + { ...crit('Hip'), id: 'rear-left-hip', loc: 'RLL' }, + { ...crit('Hip'), id: 'front-left-hip', loc: 'FLL' }, + ], + }); + const turnState = forceUnit.turnState(); + turnState.moveMode.set('run'); + turnState.moveDistance.set(1); + + expect(turnState.getPSRChecks().filter(check => check.reason === 'Running with damaged hip').length) + .toBe(1); + }); + + it('does not let an intact Core Quad stand with a destroyed gyro', () => { + const forceUnit = createForceUnitHarness({ + internalLocations: ['RLL', 'FLL', 'RRL', 'FRL'], + critSlots: [ + { id: 'gyro-1', name: 'Gyro', loc: 'CT', slot: 0, destroyed: 1 }, + { id: 'gyro-2', name: 'Gyro', loc: 'CT', slot: 1, destroyed: 1 }, + ], + }); + forceUnit.setCondition('prone', true); + + expect(forceUnit.turnState().canStandUp()).toBeFalse(); + // This only classifies an otherwise-legal stand; canStandUp owns eligibility. + expect(forceUnit.turnState().canStandWithoutPSR()).toBeTrue(); + }); + it('requires one hex for running damage PSRs but checks zero-hex jumps', () => { const biped = createRulesHarness({ committedDestroyedLocations: ['LL'] }); @@ -2665,10 +3724,49 @@ describe('MekRules', () => { internalLocations: ['RLL', 'FLL', 'RRL', 'FRL'], committedDestroyedLocations: ['RLL', 'FLL'], }); + const threeLegQuad = createRulesHarness({ + internalLocations: ['RLL', 'FLL', 'RRL', 'FRL'], + committedDestroyedLocations: ['RLL', 'FLL', 'RRL'], + }); expect(oneLegQuad.getCommittedDamageMovementModePSRCheck('run', 1)).toBeNull(); - expect(twoLegQuad.getCommittedDamageMovementModePSRCheck('run', 1)?.reason).toBe('Running with damaged leg'); + expect(oneLegQuad.getCommittedDamageMovementModePSRCheck('jump', 1)).toBeNull(); + expect(twoLegQuad.getCommittedDamageMovementModePSRCheck('run', 0)).toBeNull(); + expect(twoLegQuad.getCommittedDamageMovementModePSRCheck('run', 1)?.reason).toBe('Running with damaged hip'); + expect(twoLegQuad.getCommittedDamageMovementModePSRCheck('run', 1)?.kind).toBeUndefined(); + expect(twoLegQuad.getCommittedDamageMovementModePSRCheck('jump', 0)?.reason).toBe('Jumping with damaged hip'); expect(twoLegQuad.getCommittedDamageMovementModePSRCheck('run', 1)?.loc).toBeUndefined(); + expect(threeLegQuad.getCommittedDamageMovementModePSRCheck('run', 0)).toBeNull(); + expect(threeLegQuad.getCommittedDamageMovementModePSRCheck('run', 1)?.reason) + .toBe('Running with damaged leg'); + expect(threeLegQuad.getCommittedDamageMovementModePSRCheck('jump', 0)?.reason) + .toBe('Jumping with damaged leg'); + + const damagedGyro = createRulesHarness({ + critSlots: [{ ...crit('Gyro'), loc: 'CT' }], + }); + const damagedHip = createRulesHarness({ + critSlots: [{ ...crit('Hip'), loc: 'LL' }], + }); + + expect(damagedGyro.getCommittedDamageMovementModePSRCheck('run', 0)).toBeNull(); + expect(damagedHip.getCommittedDamageMovementModePSRCheck('run', 0)).toBeNull(); + + const twDamagedGyro = createRulesHarness({ + critSlots: [{ ...crit('Gyro'), loc: 'CT' }], + rulesId: 'tw', + }); + const twDamagedHip = createRulesHarness({ + critSlots: [{ ...crit('Hip'), loc: 'LL' }], + rulesId: 'tw', + }); + + expect(twDamagedGyro.getCommittedDamageMovementModePSRCheck('run', 0)?.reason) + .toBe('Running with damaged gyro'); + expect(twDamagedHip.getCommittedDamageMovementModePSRCheck('run', 0)?.reason) + .toBe('Running with damaged hip'); + expect(twDamagedHip.getCommittedDamageMovementModePSRCheck('run', 0)?.kind) + .toBe('damaged-hip-movement'); }); it('requires a jump PSR for foot damage without requiring a run PSR', () => { @@ -2678,6 +3776,8 @@ describe('MekRules', () => { expect(rules.getCommittedDamageMovementModePSRCheck('jump', 0)?.reason) .toBe('Jumping with damaged leg actuator'); + expect(rules.getCommittedDamageMovementModePSRCheck('jump', 0)?.kind) + .toBe('damaged-leg-actuator-movement'); expect(rules.getCommittedDamageMovementModePSRCheck('run', 1)).toBeNull(); }); @@ -2705,6 +3805,25 @@ describe('MekRules', () => { expect(forceUnit.rules.PSRModifiers().modifier).toBe(3); }); + it('ignores Core fall checks while prone but retains the Core hip modifier', () => { + const forceUnit = createForceUnitHarness({ + critSlots: [legActuatorCrit('hip', 'Hip', 'LL')], + }); + const turnState = forceUnit.turnState(); + forceUnit.setCondition('prone', true); + turnState.addDmgReceived(20); + turnState.setPSRCheckState({ hipsHit: new Set(['LL']) }); + + expect(turnState.getPSRChecks()).toEqual([]); + expect(turnState.PSRRollsCount()).toBe(0); + expect(forceUnit.rules.PSRModifiers()).toEqual(jasmine.objectContaining({ + modifier: 1, + modifiers: jasmine.arrayContaining([ + jasmine.objectContaining({ pilotCheck: 1, loc: 'LL', reason: 'Hip Destroyed' }), + ]), + })); + }); + it('consolidates Core actuator triggers per leg rather than per unit', () => { const forceUnit = createForceUnitHarness(); const turnState = forceUnit.turnState(); @@ -2769,12 +3888,14 @@ describe('MekRules', () => { reason: 'Hip hit, Leg Actuator hit, Foot hit', })]); expect(turnState.PSRRollsCount()).toBe(1); - expect(forceUnit.rules.PSRModifiers().modifier).toBe(3); + expect(forceUnit.rules.PSRModifiers().modifier).toBe(2); expect(forceUnit.rules.PSRModifiers().modifiers).toEqual(jasmine.arrayContaining([ jasmine.objectContaining({ pilotCheck: 1, loc: 'LL', reason: 'Hip Destroyed' }), jasmine.objectContaining({ pilotCheck: 1, loc: 'LL', reason: 'Leg Actuator(s) Destroyed' }), - jasmine.objectContaining({ pilotCheck: 1, loc: 'LL', reason: 'Foot Actuator(s) Destroyed' }), ])); + expect(forceUnit.rules.PSRModifiers().modifiers.some( + modifier => modifier.reason === 'Foot Actuator(s) Destroyed', + )).toBeFalse(); }); it('uses separate Core jump PSRs for actuator damage in different legs', () => { @@ -2794,10 +3915,10 @@ describe('MekRules', () => { jasmine.objectContaining({ loc: 'RL', reason: 'Hip hit' }), ]); expect(turnState.PSRRollsCount()).toBe(2); - expect(forceUnit.rules.PSRModifiers().modifier).toBe(3); + expect(forceUnit.rules.PSRModifiers().modifier).toBe(2); }); - it('keeps pre-existing destroyed actuator modifiers grouped by location', () => { + it('keeps Core pre-existing leg actuator modifiers grouped by location and ignores feet', () => { const forceUnit = createForceUnitHarness({ critSlots: [ legActuatorCrit('upper-leg', 'Upper Leg Actuator', 'LL'), @@ -2806,7 +3927,7 @@ describe('MekRules', () => { ], }); - expect(forceUnit.rules.PSRModifiers().modifier).toBe(3); + expect(forceUnit.rules.PSRModifiers().modifier).toBe(2); expect(forceUnit.rules.PSRModifiers().modifiers).toEqual(jasmine.arrayContaining([ jasmine.objectContaining({ pilotCheck: 2, @@ -2814,13 +3935,8 @@ describe('MekRules', () => { reason: 'Leg Actuator(s) Destroyed', modifierReason: 'Leg Actuators Destroyed (2)', }), - jasmine.objectContaining({ - pilotCheck: 1, - loc: 'RL', - reason: 'Foot Actuator(s) Destroyed', - modifierReason: 'Foot Actuator Destroyed', - }), ])); + expect(forceUnit.rules.PSRModifiers().modifiers.some(modifier => modifier.loc === 'RL')).toBeFalse(); }); it('merges current and movement actuator triggers for the same Core leg', () => { @@ -2935,7 +4051,7 @@ describe('MekRules', () => { fallCheck: 2, pilotCheck: 2, loc: 'CT', - reason: 'Jumping with damaged heavy-duty gyro', + reason: 'Jumping with damaged HD gyro', ignorePreExistingGyro: true, })); }); @@ -3002,7 +4118,10 @@ describe('MekRules', () => { expect(rules.PSRModifiers().modifiers).toContain(jasmine.objectContaining({ pilotCheck: 1, loc: 'LL', reason: 'Hip Destroyed', })); - expect(rules.PSRModifiers().modifiers.some(modifier => modifier.reason === 'Leg Actuator(s) Destroyed')).toBeFalse(); + expect(rules.PSRModifiers().modifiers.some(modifier => + modifier.reason === 'Leg Actuator(s) Destroyed' + || modifier.reason === 'Foot Actuator(s) Destroyed' + )).toBeFalse(); expect(rules.PSRModifiers().modifiers).toContain(jasmine.objectContaining({ pilotCheck: 2, reason: 'Gyro damaged' })); expect(toHitModifierTotal(rules.getEquipmentToHitModifiers(armWeapon))).toBe(0); diff --git a/src/app/models/rules/tw-rules.spec.ts b/src/app/models/rules/tw-rules.spec.ts index cf3cf57a1..9a764bb8b 100644 --- a/src/app/models/rules/tw-rules.spec.ts +++ b/src/app/models/rules/tw-rules.spec.ts @@ -14,6 +14,7 @@ import { UnitInitializerService } from '../../services/unit-initializer.service' import { createEmptyUnit } from '../../testing/unit-test-helpers'; import { OptionsService } from '../../services/options.service'; import { TWMekRules } from './tw-rules'; +import { MEK_LOCATIONS } from '../entity/types'; class TestCBTForce extends CBTForce { override emitChanged(): void { @@ -56,8 +57,8 @@ function createTWForceUnit(critSlots: CriticalSlot[] = []): CBTForceUnit { const force = new TestCBTForce('Test Force', dataService, unitInitializer, injector); const forceUnit = new CBTForceUnit(baseUnit, force, dataService, unitInitializer, injector); forceUnit.locations = { - internal: new Map(['LL', 'RL'].map(loc => [loc, { loc, points: 1 }])), - armor: new Map(['LL', 'RL'].map(loc => [loc, { loc, rear: false, points: 1 }])), + internal: new Map(MEK_LOCATIONS.map(loc => [loc, { loc, points: 1 }])), + armor: new Map(MEK_LOCATIONS.map(loc => [loc, { loc, rear: false, points: 1 }])), }; forceUnit.setLocations({}, true); forceUnit.writeCrits(critSlots); @@ -65,6 +66,12 @@ function createTWForceUnit(critSlots: CriticalSlot[] = []): CBTForceUnit { return forceUnit; } +function hitCrit(forceUnit: CBTForceUnit, loc: string, slot: number): void { + const crit = forceUnit.getCritSlot(loc, slot); + if (!crit) throw new Error(`Missing critical slot ${loc}:${slot}`); + forceUnit.applyHitToCritSlot(crit); +} + describe('TWMekRules', () => { beforeEach(() => { dataService = jasmine.createSpyObj('DataService', ['getEquipmentRegistry', 'findEquipment', 'getUnitByName']); @@ -102,6 +109,25 @@ describe('TWMekRules', () => { expect(forceUnit.rules.PSRModifiers().modifier).toBe(4); }); + it('ignores TW fall checks while prone but retains the TW hip modifier', () => { + const forceUnit = createTWForceUnit([ + legActuatorCrit('hip', 'Hip', 'LL'), + ]); + const turnState = forceUnit.turnState(); + forceUnit.setCondition('prone', true); + turnState.addDmgReceived(20); + turnState.setPSRCheckState({ hipsHit: new Set(['LL']) }); + + expect(turnState.getPSRChecks()).toEqual([]); + expect(turnState.PSRRollsCount()).toBe(0); + expect(forceUnit.rules.PSRModifiers()).toEqual(jasmine.objectContaining({ + modifier: 2, + modifiers: jasmine.arrayContaining([ + jasmine.objectContaining({ pilotCheck: 2, loc: 'LL', reason: 'Hip Destroyed' }), + ]), + })); + }); + it('records foot, upper-leg, lower-leg, and hip hits as separate TW triggers', () => { const forceUnit = createTWForceUnit([ { ...legActuatorCrit('foot', 'Foot', 'LL', false), destroying: 1 }, @@ -140,6 +166,191 @@ describe('TWMekRules', () => { expect(forceUnit.rules.PSRModifiers().modifier).toBe(5); }); + it('keeps multiple committed leg-actuator modifiers cumulative in TW', () => { + const forceUnit = createTWForceUnit([ + legActuatorCrit('upper-leg', 'Upper Leg Actuator', 'LL', false), + legActuatorCrit('lower-leg', 'Lower Leg Actuator', 'LL', false), + ]); + + hitCrit(forceUnit, 'LL', 1); + hitCrit(forceUnit, 'LL', 2); + forceUnit.endPhase(); + + expect(forceUnit.rules.PSRModifiers().modifier).toBe(2); + expect(forceUnit.rules.PSRModifiers().modifiers).toContain(jasmine.objectContaining({ + pilotCheck: 2, + loc: 'LL', + modifierReason: 'Leg Actuators Destroyed (2)', + })); + expect((forceUnit.rules as TWMekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 3, run: 5 })); + }); + + it('keeps same-phase actuator then hip modifiers cumulative after commit in TW', () => { + const forceUnit = createTWForceUnit([ + legActuatorCrit('hip', 'Hip', 'LL', false), + legActuatorCrit('upper-leg', 'Upper Leg Actuator', 'LL', false), + ]); + let timestamp = 100; + spyOn(Date, 'now').and.callFake(() => ++timestamp); + + hitCrit(forceUnit, 'LL', 1); + const actuatorDestructionTimestamp = forceUnit.getCritSlot('LL', 1)?.destroying; + hitCrit(forceUnit, 'LL', 0); + const hipDestructionTimestamp = forceUnit.getCritSlot('LL', 0)?.destroying; + + expect(forceUnit.turnState().getPSRChecks().map(check => check.reason)).toEqual([ + 'Leg actuator hit', + 'Hip hit', + ]); + expect(forceUnit.rules.PSRModifiers().modifier).toBe(3); + + forceUnit.endPhase(); + + expect(forceUnit.rules.PSRModifiers().modifier).toBe(3); + expect(actuatorDestructionTimestamp!).toBeLessThan(hipDestructionTimestamp!); + expect(forceUnit.getCritSlot('LL', 0)?.destroyed).toBe(hipDestructionTimestamp); + expect(forceUnit.getCritSlot('LL', 1)?.destroyed).toBe(actuatorDestructionTimestamp); + expect(forceUnit.getCritSlot('LL', 0)?.destroyedTurn) + .toBe(forceUnit.getCritSlot('LL', 1)?.destroyedTurn); + expect((forceUnit.rules as TWMekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 2, run: 3 })); + }); + + it('keeps same-phase hip then actuator modifiers cumulative after commit in TW', () => { + const forceUnit = createTWForceUnit([ + legActuatorCrit('hip', 'Hip', 'LL', false), + legActuatorCrit('upper-leg', 'Upper Leg Actuator', 'LL', false), + ]); + let timestamp = 150; + spyOn(Date, 'now').and.callFake(() => ++timestamp); + + hitCrit(forceUnit, 'LL', 0); + const hipDestructionTimestamp = forceUnit.getCritSlot('LL', 0)?.destroying; + hitCrit(forceUnit, 'LL', 1); + const actuatorDestructionTimestamp = forceUnit.getCritSlot('LL', 1)?.destroying; + + expect(forceUnit.turnState().getPSRChecks().map(check => check.reason)).toEqual( + jasmine.arrayWithExactContents(['Hip hit', 'Leg actuator hit']), + ); + expect(forceUnit.rules.PSRModifiers().modifier).toBe(3); + + forceUnit.endPhase(); + + expect(forceUnit.rules.PSRModifiers().modifier).toBe(3); + expect(hipDestructionTimestamp!).toBeLessThan(actuatorDestructionTimestamp!); + expect(forceUnit.getCritSlot('LL', 0)?.destroyed).toBe(hipDestructionTimestamp); + expect(forceUnit.getCritSlot('LL', 1)?.destroyed).toBe(actuatorDestructionTimestamp); + expect(forceUnit.getCritSlot('LL', 1)?.destroyedTurn) + .toBe(forceUnit.getCritSlot('LL', 0)?.destroyedTurn); + expect((forceUnit.rules as TWMekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 2, run: 3 })); + }); + + it('keeps an earlier-phase actuator when the same-turn hip is hit later in TW', () => { + const forceUnit = createTWForceUnit([ + legActuatorCrit('hip', 'Hip', 'LL', false), + legActuatorCrit('upper-leg', 'Upper Leg Actuator', 'LL', false), + ]); + let timestamp = 200; + spyOn(Date, 'now').and.callFake(() => ++timestamp); + + hitCrit(forceUnit, 'LL', 1); + forceUnit.endPhase(); + expect(forceUnit.rules.PSRModifiers().modifier).toBe(1); + + hitCrit(forceUnit, 'LL', 0); + expect(forceUnit.rules.PSRModifiers().modifier).toBe(3); + forceUnit.endPhase(); + + expect(forceUnit.rules.PSRModifiers().modifier).toBe(3); + expect(forceUnit.getCritSlot('LL', 1)!.destroyed!) + .toBeLessThan(forceUnit.getCritSlot('LL', 0)!.destroyed!); + expect(forceUnit.getCritSlot('LL', 0)?.destroyedTurn) + .toBe(forceUnit.getCritSlot('LL', 1)?.destroyedTurn); + expect((forceUnit.rules as TWMekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 2, run: 3 })); + }); + + it('keeps a later-phase actuator modifier after an existing same-leg hip in TW', () => { + const forceUnit = createTWForceUnit([ + legActuatorCrit('hip', 'Hip', 'LL', false), + legActuatorCrit('upper-leg', 'Upper Leg Actuator', 'LL', false), + ]); + let timestamp = 300; + spyOn(Date, 'now').and.callFake(() => ++timestamp); + + hitCrit(forceUnit, 'LL', 0); + forceUnit.endPhase(); + expect(forceUnit.rules.PSRModifiers().modifier).toBe(2); + + hitCrit(forceUnit, 'LL', 1); + expect(forceUnit.rules.PSRModifiers().modifier).toBe(3); + forceUnit.endPhase(); + + expect(forceUnit.rules.PSRModifiers().modifier).toBe(3); + expect(forceUnit.getCritSlot('LL', 0)!.destroyed!) + .toBeLessThan(forceUnit.getCritSlot('LL', 1)!.destroyed!); + expect(forceUnit.getCritSlot('LL', 1)?.destroyedTurn) + .toBe(forceUnit.getCritSlot('LL', 0)?.destroyedTurn); + expect((forceUnit.rules as TWMekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 2, run: 3 })); + }); + + it('lets a later-turn hip replace an earlier same-leg actuator modifier in TW', () => { + const forceUnit = createTWForceUnit([ + legActuatorCrit('hip', 'Hip', 'LL', false), + legActuatorCrit('upper-leg', 'Upper Leg Actuator', 'LL', false), + ]); + + hitCrit(forceUnit, 'LL', 1); + forceUnit.endTurn(); + + hitCrit(forceUnit, 'LL', 0); + expect(forceUnit.rules.PSRModifiers().modifier).toBe(2); + forceUnit.endPhase(); + + expect(forceUnit.rules.PSRModifiers().modifier).toBe(2); + expect(forceUnit.getCritSlot('LL', 0)!.destroyedTurn!) + .toBeGreaterThan(forceUnit.getCritSlot('LL', 1)!.destroyedTurn!); + expect((forceUnit.rules as TWMekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 3, run: 5 })); + }); + + it('keeps a later-turn actuator modifier after an existing same-leg hip in TW', () => { + const forceUnit = createTWForceUnit([ + legActuatorCrit('hip', 'Hip', 'LL', false), + legActuatorCrit('upper-leg', 'Upper Leg Actuator', 'LL', false), + ]); + + hitCrit(forceUnit, 'LL', 0); + forceUnit.endTurn(); + + hitCrit(forceUnit, 'LL', 1); + expect(forceUnit.rules.PSRModifiers().modifier).toBe(3); + forceUnit.endPhase(); + + expect(forceUnit.rules.PSRModifiers().modifier).toBe(3); + expect(forceUnit.getCritSlot('LL', 1)!.destroyedTurn!) + .toBeGreaterThan(forceUnit.getCritSlot('LL', 0)!.destroyedTurn!); + expect((forceUnit.rules as TWMekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 2, run: 3 })); + }); + + it('retains the destroyed foot actuator PSR modifier in TW', () => { + const forceUnit = createTWForceUnit([ + legActuatorCrit('foot', 'Foot', 'LL'), + ]); + + expect(forceUnit.rules.PSRModifiers().modifier).toBe(1); + expect(forceUnit.rules.PSRModifiers().modifiers).toContain(jasmine.objectContaining({ + pilotCheck: 1, + loc: 'LL', + reason: 'Leg Actuator(s) Destroyed', + modifierReason: 'Leg Actuator Destroyed', + })); + }); + it('stacks independent TW actuator checks with damage and gyro PSRs', () => { const forceUnit = createTWForceUnit(); const turnState = forceUnit.turnState(); @@ -159,11 +370,11 @@ describe('TWMekRules', () => { expect(forceUnit.rules.PSRModifiers().modifier).toBe(6); }); - it('retains one legacy TW movement trigger and hip-dominant modifier for same-leg damage', () => { + it('keeps one TW movement trigger when a later hip replaces older same-leg modifiers', () => { const forceUnit = createTWForceUnit([ - legActuatorCrit('hip', 'Hip', 'LL'), - legActuatorCrit('upper-leg', 'Upper Leg Actuator', 'LL'), - legActuatorCrit('foot', 'Foot', 'LL'), + { ...legActuatorCrit('hip', 'Hip', 'LL'), destroyedTurn: 2 }, + { ...legActuatorCrit('upper-leg', 'Upper Leg Actuator', 'LL'), destroyedTurn: 1 }, + { ...legActuatorCrit('foot', 'Foot', 'LL'), destroyedTurn: 1 }, ]); const turnState = forceUnit.turnState(); turnState.moveMode.set('jump'); @@ -172,6 +383,7 @@ describe('TWMekRules', () => { expect(turnState.getPSRChecks()).toEqual([jasmine.objectContaining({ fallCheck: 0, pilotCheck: 0, + kind: 'damaged-leg-actuator-movement', reason: 'Jumping with damaged leg actuator', })]); expect(turnState.PSRRollsCount()).toBe(1); @@ -183,6 +395,26 @@ describe('TWMekRules', () => { })); }); + it('classifies movement checks independently from their display reason', () => { + const forceUnit = createTWForceUnit([ + legActuatorCrit('lower-leg', 'Lower Leg Actuator', 'LL'), + ]); + const turnState = forceUnit.turnState(); + turnState.moveMode.set('jump'); + turnState.moveDistance.set(1); + spyOn(forceUnit.rules, 'getCommittedDamageMovementModePSRCheck').and.returnValue({ + fallCheck: 0, + pilotCheck: 0, + kind: 'damaged-leg-actuator-movement', + reason: 'Localized movement check label', + }); + + expect(turnState.getPSRChecks()).toEqual([jasmine.objectContaining({ + kind: 'damaged-leg-actuator-movement', + reason: 'Localized movement check label', + })]); + }); + it('keeps current-hit and committed-movement actuator PSRs independent in TW', () => { const forceUnit = createTWForceUnit([ legActuatorCrit('lower-leg', 'Lower Leg Actuator', 'LL'), diff --git a/src/app/models/rules/tw-rules.ts b/src/app/models/rules/tw-rules.ts index 0d7b61fef..9fd2ed977 100644 --- a/src/app/models/rules/tw-rules.ts +++ b/src/app/models/rules/tw-rules.ts @@ -12,9 +12,10 @@ import type { ChargeDamage, PSRCheck, UnitHeatSource } from './unit-type-rules'; import type { CriticalSlot, SerializedC3NetworkGroup } from '../force-serialization'; import type { CBTForceUnit } from '../cbt-force-unit.model'; import { C3TaxCalculator } from '../c3-network.model'; -import { getMekLimbLocations, inferMekConfigFromLocations, LEG_LOCATIONS, MEK_SIDE_TORSO_LOCATIONS, MEK_TORSO_LOCATIONS } from '../entity/types'; +import { getMekLimbLocations, inferMekConfigFromLocations, LEG_LOCATIONS, MEK_SIDE_TORSO_LOCATIONS, MEK_TORSO_LOCATIONS, QUAD_LEG_LOCATIONS, type MekConfig } from '../entity/types'; import type { TurnState } from '../turn-state.model'; import type { Equipment } from '../equipment.model'; +import type { MountedEquipment } from '../mounted-equipment.model'; function calculateTWC3Tax( unit: CBTForceUnit, @@ -31,7 +32,7 @@ function calculateTWChargeDamage( maxBonusDamage = bonusDamage, ): ChargeDamage { const damagePerHex = unit.getUnit().tons / 10; - const moveMode = unit.turnState().moveMode(); + const moveMode = unit.turnState().effectiveMoveMode(); const movedHexes = Math.max(1, unit.turnState().moveDistance() ?? 0); const maxMovedHexes = Math.max(1, unit.getUnit().run); const ramPlates = unit.getInventory().filter(entry => entry.equipment?.hasFlag('F_RAM_PLATE')); @@ -58,9 +59,37 @@ function calculateTWChargeDamage( export class TWMekRules extends MekRules { override readonly standingUpPSRModifier: number = 0; + override readonly supportsCarefulStand: boolean = true; protected override get shieldBashPunchBonusEnabled(): boolean { return false; } protected override get standaloneShieldDamageEnabled(): boolean { return true; } + protected override shieldRetainsMobilityPenalty(entry: MountedEquipment): boolean { + if (entry.committedDestroyed()) return false; + const criticals = this.entryCriticalSlots(entry); + if (criticals.length === 0) { + return this.unit.getEquipmentInstallationLocationStatus(entry) === 'available'; + } + // TW retains the modifier until every shield critical is unavailable. + // Critical status also accounts for a committed destroyed/blown-off arm. + return !this.allShieldCriticalsUnavailable(entry); + } + + protected override destroyedLegStandThreshold(config: MekConfig): number { + return config === 'Quad' ? 2 : 1; + } + + override getStandAttemptLimit(_turnState: TurnState): number | null { + const { config, destroyedLegs } = this.currentLegState(); + return this.isDestroyedLegStandException(config, destroyedLegs.length) ? 1 : null; + } + + protected override isMovementPSRFoldedIntoStandAttempt(turnState: TurnState): boolean { + const { config, destroyedLegs } = this.currentLegState(); + return (turnState.standAttempts() ?? 0) > 0 + && (turnState.moveDistance() ?? 0) === 0 + && this.isDestroyedLegStandException(config, destroyedLegs.length); + } + override heatLifeSupportPilotHits(heat: number): number { if (!this.hasDamagedLifeSupport() || heat <= 0) return 0; @@ -83,30 +112,31 @@ export class TWMekRules extends MekRules { protected override getLegActuatorPSRChecks( turnState: TurnState, movementCheck: PSRCheck | null, + includeCurrentHits = true, ): PSRCheck[] { const checks: PSRCheck[] = []; const psr = turnState.getPSRCheckState(); - psr.legActuators?.forEach((count, loc) => { - for (let index = 0; index < count; index++) { + if (includeCurrentHits) { + psr.legActuators?.forEach((count, loc) => { + for (let index = 0; index < count; index++) { + checks.push({ + fallCheck: 1, + pilotCheck: 1, + loc, + reason: 'Leg actuator hit', + }); + } + }); + psr.hipsHit?.forEach(loc => { checks.push({ - fallCheck: 1, - pilotCheck: 1, + fallCheck: this.hipPSRModifier, + pilotCheck: this.hipPSRModifier, loc, - reason: 'Leg actuator hit', + reason: 'Hip hit', }); - } - }); - psr.hipsHit?.forEach(loc => { - checks.push({ - fallCheck: this.hipPSRModifier, - pilotCheck: this.hipPSRModifier, - loc, - legFilter: loc, - reason: 'Hip hit', }); - }); - if (movementCheck?.reason === 'Jumping with damaged leg actuator' - || movementCheck?.reason === 'Running with damaged hip') { + } + if (this.isLegDamageMovementPSRCheck(movementCheck)) { checks.push(movementCheck); } return checks; @@ -120,19 +150,18 @@ export class TWMekRules extends MekRules { const modifiers: PSRCheck[] = []; const destroyedHips = critSlots.filter(slot => slot.loc && LEG_LOCATIONS.has(slot.loc) - && !this.unit.isEquipmentOperational(slot) + && slot.destroyed !== undefined && !ignoreLeg.has(slot.loc) && this.isNamedCrit(slot, 'Hip')); for (const hip of destroyedHips) { modifier += this.hipPSRModifier; modifiers.push({ pilotCheck: this.hipPSRModifier, loc: hip.loc!, reason: 'Hip Destroyed' }); - ignoreLeg.add(hip.loc!); } - const destroyedActuators = critSlots.filter(slot => slot.loc - && LEG_LOCATIONS.has(slot.loc) - && !this.unit.isEquipmentOperational(slot) - && !ignoreLeg.has(slot.loc) - && (this.isNamedCrit(slot, 'Leg') || this.isNamedCrit(slot, 'Foot'))); + const destroyedActuators = this.effectiveCommittedLegActuators( + critSlots, + this.unit.turnState().getPSRCheckState().hipsHit, + ) + .filter(slot => !ignoreLeg.has(slot.loc!)); const destroyedActuatorCounts = new Map(); for (const actuator of destroyedActuators) { destroyedActuatorCounts.set(actuator.loc!, (destroyedActuatorCounts.get(actuator.loc!) ?? 0) + 1); @@ -151,6 +180,49 @@ export class TWMekRules extends MekRules { return { modifier, modifiers }; } + private effectiveCommittedLegActuators( + critSlots: readonly CriticalSlot[], + currentTurnHipHits: ReadonlySet | undefined = undefined, + ): CriticalSlot[] { + // BMM: a hip replaces same-leg actuator modifiers from earlier turns; + // actuator hits from the hip's turn or a later turn remain cumulative. + const hipDestroyedOnTurnByLeg = new Map(); + for (const slot of critSlots) { + if (!slot.loc + || !LEG_LOCATIONS.has(slot.loc) + || slot.destroyed === undefined + || !this.isNamedCrit(slot, 'Hip')) continue; + hipDestroyedOnTurnByLeg.set( + slot.loc, + Math.max( + hipDestroyedOnTurnByLeg.get(slot.loc) ?? 0, + slot.destroyedTurn ?? 0, + ), + ); + } + if (currentTurnHipHits && currentTurnHipHits.size > 0) { + const currentTurn = this.unit.turnState().getTurnCounter(); + for (const loc of currentTurnHipHits) { + hipDestroyedOnTurnByLeg.set(loc, currentTurn); + } + } + + return critSlots.filter(slot => { + if (!slot.loc + || !LEG_LOCATIONS.has(slot.loc) + || slot.destroyed === undefined + || this.unit.isInternalLocCommittedDestroyed(slot.loc) + || (!this.isNamedCrit(slot, 'Leg') && !this.isNamedCrit(slot, 'Foot'))) return false; + const hipDestroyedOnTurn = hipDestroyedOnTurnByLeg.get(slot.loc); + const actuatorDestroyedOnTurn = slot.destroyedTurn ?? 0; + return hipDestroyedOnTurn === undefined || actuatorDestroyedOnTurn >= hipDestroyedOnTurn; + }); + } + + protected override legActuatorMovementReduction(): number { + return this.effectiveCommittedLegActuators(this.unit.getCritSlots()).length; + } + protected override usesTorsoCripplingRules(): boolean { return false; } @@ -268,7 +340,7 @@ export class TWMekRules extends MekRules { protected override readonly immobile = computed(() => { if (!this.unit.isLoaded()) return false; if (this.unit.getCondition('shutdown')) return true; - if (this.allLimbsDestroyedOrMissing()) return true; + if (this.allLimbsDestroyed()) return true; if (!this.hasDroneOperatingSystem() && !this.hasFunctionalCrew()) return true; return false; }); @@ -277,6 +349,27 @@ export class TWMekRules extends MekRules { return { fallCheck: 100, pilotCheck: 5 }; } + protected override getPreExistingDestroyedLegPSRModifiers( + config: MekConfig, + destroyedLegs: readonly string[], + ): PSRCheck[] { + if (config !== 'Quad') return super.getPreExistingDestroyedLegPSRModifiers(config, destroyedLegs); + if (destroyedLegs.length !== 2) return []; + return [{ + pilotCheck: 5, + reason: 'Leg Destroyed', + modifierReason: 'Legs Destroyed (2)', + }]; + } + + protected override destroyedLegMovementPSRModifier( + moveMode: 'run' | 'jump', + isQuadruped: boolean, + destroyedLegsCount: number, + ): number { + return moveMode === 'jump' && isQuadruped && destroyedLegsCount === 1 ? 5 : 0; + } + protected override damagedLegRequiresMovementCheck(_isQuadruped: boolean, destroyedLegsCount: number): boolean { return destroyedLegsCount > 0; } @@ -285,6 +378,23 @@ export class TWMekRules extends MekRules { return false; } + protected override runningDamageCheckRequiresHexMovement(): boolean { + return false; + } + + protected override destroyedLegsApplyHipMovementCheck(_isQuadruped: boolean, _destroyedLegsCount: number): boolean { + return false; + } + + protected override getRunningMinimumMovementDistance(): number { + const movement = this.movementState(); + if (!movement || movement.walk < 1) return 0; + const systemsStatus = this.systemsStatus(); + const isQuadruped = QUAD_LEG_LOCATIONS.some(loc => systemsStatus.internalLocations.has(loc)); + const oneLeggedDestroyedCount = isQuadruped ? 2 : 1; + return systemsStatus.destroyedLegsCount === oneLeggedDestroyedCount ? 1 : 0; + } + protected override applyLegDamageToMovement( walk: number, _unitRun: number, @@ -296,9 +406,15 @@ export class TWMekRules extends MekRules { let moveImpaired = false; if (isBiped) { - for (let index = 0; index < damage.destroyedHipsCount; index++) { - walk = Math.ceil(walk * 0.5); + if (damage.destroyedHipsCount >= 2) { + walk = 0; moveImpaired = true; + runDisabled = true; + } else { + for (let index = 0; index < damage.destroyedHipsCount; index++) { + walk = Math.ceil(walk * 0.5); + moveImpaired = true; + } } if (damage.destroyedLegsCount === 1) { walk = Math.min(walk, 1); @@ -310,10 +426,6 @@ export class TWMekRules extends MekRules { runDisabled = true; } } else if (isQuadruped) { - if (damage.destroyedHipsCount !== 0) { - walk -= damage.destroyedHipsCount; - moveImpaired = true; - } if (damage.destroyedLegsCount === 1) walk--; if (damage.destroyedLegsCount === 2) { walk = Math.min(walk, 1); @@ -322,6 +434,17 @@ export class TWMekRules extends MekRules { walk = 0; runDisabled = true; } + + if (damage.destroyedHipsCount >= 4) { + walk = 0; + moveImpaired = true; + runDisabled = true; + } else { + for (let index = 0; index < damage.destroyedHipsCount && walk > 0; index++) { + walk = Math.ceil(walk * 0.5); + moveImpaired = true; + } + } } return { walk, runDisabled, runCap: null, moveImpaired, applyActuatorDamage: true }; diff --git a/src/app/models/rules/unit-type-rules.ts b/src/app/models/rules/unit-type-rules.ts index e20604a75..97aae62c9 100644 --- a/src/app/models/rules/unit-type-rules.ts +++ b/src/app/models/rules/unit-type-rules.ts @@ -29,10 +29,13 @@ import type { UnitSystemStatusFacts, } from '../equipment-status.model'; +export type PSRCheckKind = 'damaged-leg-actuator-movement' | 'damaged-hip-movement'; + export interface PSRCheck { id?: string; fallCheck?: number; pilotCheck?: number; + kind?: PSRCheckKind; reason: string; modifierReason?: string; failureOutcome?: string; @@ -255,6 +258,24 @@ export interface UnitTypeRules { /** Modifier applied specifically when this unit attempts to stand up. */ readonly standingUpPSRModifier: number; + /** Whether this unit can stand up in its current turn state. */ + canStandUp(turnState: TurnState): boolean; + + /** Whether this unit's current configuration lets it stand without a control roll. */ + canStandWithoutPSR(turnState: TurnState): boolean; + + /** Whether this rules implementation supports the optional Careful Stand rule. */ + readonly supportsCarefulStand: boolean; + + /** Whether this unit has at least 3 available Walking MP for a careful stand attempt. */ + canCarefulStand(turnState: TurnState): boolean; + + /** Movement mode used to classify a stand attempt in the current turn state. */ + getStandAttemptMovementMode(turnState: TurnState): MotiveModes | null; + + /** Maximum stand attempts permitted this turn, or null when no special limit applies. */ + getStandAttemptLimit(turnState: TurnState): number | null; + /** Whether current phase damage causes automatic falling or equivalent unit-type failure. */ readonly autoFall: Signal; @@ -369,6 +390,9 @@ export interface UnitTypeRules { /** Unit-type-specific effective movement distance for turn-state choices. */ getEffectiveMaxDistanceForMoveMode(moveMode: MotiveModes, turnState: TurnState): number | null; + /** Movement points already spent on non-translational movement actions. */ + getMovementPointsSpent(turnState: TurnState): number; + /** Unit-type-specific minimum movement distance override. Return null to use 0. */ getMinDistanceForMoveMode(moveMode: MotiveModes): number | null; @@ -650,6 +674,28 @@ export abstract class UnitTypeRulesBase implements UnitTypeRules { return []; } + canStandUp(_turnState: TurnState): boolean { + return false; + } + + canStandWithoutPSR(_turnState: TurnState): boolean { + return false; + } + + readonly supportsCarefulStand: boolean = false; + + canCarefulStand(_turnState: TurnState): boolean { + return false; + } + + getStandAttemptMovementMode(turnState: TurnState): MotiveModes | null { + return turnState.moveMode(); + } + + getStandAttemptLimit(_turnState: TurnState): number | null { + return null; + } + reconcileRuleChecks(): void { } @@ -706,6 +752,10 @@ export abstract class UnitTypeRulesBase implements UnitTypeRules { return this.getMaxDistanceForMoveMode(moveMode); } + getMovementPointsSpent(_turnState: TurnState): number { + return 0; + } + getMinDistanceForMoveMode(_moveMode: MotiveModes): number | null { return null; } @@ -736,8 +786,8 @@ export abstract class UnitTypeRulesBase implements UnitTypeRules { getAttackModifierBreakdown(turnState: TurnState): UnitModifierBreakdownEntry[] { const entries: UnitModifierBreakdownEntry[] = []; - const moveMode = turnState.moveMode(); - const movementModifier = this.getAttackMovementModifier(turnState.moveMode(), turnState.airborne() ?? false); + const moveMode = turnState.effectiveMoveMode(); + const movementModifier = this.getAttackMovementModifier(moveMode, turnState.airborne() ?? false); if (movementModifier !== 0 && moveMode !== null) { entries.push({ label: getMotiveModeLabel(moveMode, this.unit.getUnit(), turnState.airborne() ?? false), @@ -756,7 +806,7 @@ export abstract class UnitTypeRulesBase implements UnitTypeRules { if (this.unit.gameRules.supportsSkidding && turnState.unitState.hasCondition('skidding')) { entries.push({ label: 'Skidding', modifier: TN_SKIDDING_MODIFIER }); } - const moveMode = turnState.moveMode(); + const moveMode = turnState.effectiveMoveMode(); if (moveMode === 'jump') { entries.push({ label: 'Jumped', modifier: TN_AIRBORNE_MOVE_TYPE_MODIFIER }); } else if (turnState.airborne() === true) { @@ -802,7 +852,7 @@ export abstract class UnitTypeRulesBase implements UnitTypeRules { protected computeChargeDamage(bonusDamage = 0, maxBonusDamage = bonusDamage): ChargeDamage { const damagePerTMM = this.unit.getUnit().tons / 5; - const moveMode = this.unit.turnState().moveMode(); + const moveMode = this.unit.turnState().effectiveMoveMode(); const ramPlates = this.unit.getInventory().filter(entry => entry.equipment?.hasFlag('F_RAM_PLATE')); const hasRamPlate = ramPlates.length > 0; const hasWorkingRamPlate = ramPlates.some(entry => this.unit.isEquipmentOperational(entry)); From 2dc64cc08036b317692ba3c88c69d19a138ddfbb Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 21 Aug 2026 19:03:28 +0200 Subject: [PATCH 11/87] style --- src/styles.scss | 45 +++++++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/src/styles.scss b/src/styles.scss index 2cb886cdc..7db0d11dd 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -904,22 +904,6 @@ hr { width: 100%; } - .random-button { - flex: 0 0 32px; - width: 32px; - height: 32px; - border: none; - background: transparent url('/images/random.svg') center / 24px 24px no-repeat; - cursor: pointer; - opacity: 0.8; - transition: opacity 0.2s ease-in-out; - - &:hover, - &:focus-visible { - opacity: 1; - } - } - .field-input { width: 100%; flex: 1 1 auto; @@ -961,6 +945,35 @@ hr { } } +.random-button, +.form-fields .random-button { + flex: 0 0 32px; + width: 24px; + height: 32px; + border: none; + background: transparent url('/images/random.svg') center / 24px 24px no-repeat; + cursor: pointer; + opacity: 0.8; + transition: opacity 0.2s ease-in-out; + + &.large { + flex: 0 0 64px; + width: 64px; + height: 64px; + background-size: 50px 50px; + } + + &:hover, + &:focus-visible { + opacity: 1; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.35; + } +} + // specific for inventory dialog, to make the input fields smaller and more compact .weapons-equipment-panel { display: flex; From a2e061fbef044b0fc698fb0235e50dfdb88729c3 Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 21 Aug 2026 19:17:59 +0200 Subject: [PATCH 12/87] critical hit dialog --- .../mek-critical-dialog.component.scss | 202 +++++- ...mek-critical-roll-dialog.component.spec.ts | 563 +++++++++++++++- .../mek-critical-roll-dialog.component.ts | 618 ++++++++++++++++-- 3 files changed, 1276 insertions(+), 107 deletions(-) diff --git a/src/app/components/page-viewer/mek-critical-dialog.component.scss b/src/app/components/page-viewer/mek-critical-dialog.component.scss index fadd55702..7e589f449 100644 --- a/src/app/components/page-viewer/mek-critical-dialog.component.scss +++ b/src/app/components/page-viewer/mek-critical-dialog.component.scss @@ -3,7 +3,7 @@ } .panel { - width: min(460px, 100vw); + width: min(460px, calc(100vw - 24px)); } .body { @@ -23,6 +23,26 @@ margin: 0; } +.critical-random-row { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; +} + +.critical-dice-trigger { + cursor: pointer; +} + +.critical-dice-trigger:focus-visible { + outline: 2px solid var(--bt-yellow); + outline-offset: 4px; +} + +.critical-dice-trigger[aria-disabled="true"] { + cursor: default; +} + .critical-table-hint { color: var(--text-color-tertiary); font-size: 0.85em; @@ -40,10 +60,6 @@ min-width: 0; } -.result-slot-hidden { - visibility: hidden; -} - .critical-roll-details { align-self: stretch; margin: 0; @@ -151,6 +167,42 @@ line-height: 1.35; } +.case-ii-check { + align-self: stretch; + display: flex; + flex-direction: column; + gap: 4px; + padding: 0.65rem 0.75rem; + border: 1px solid #e8a64a; + background: rgba(120, 80, 0, 0.2); + color: var(--text-color-secondary); + font-size: 0.88em; + line-height: 1.35; + text-align: left; +} + +.case-ii-check strong { + color: #e8a64a; +} + +.case-ii-manual-options { + align-self: stretch; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.case-ii-manual-options .bt-button, +.critical-non-explosive-action { + min-width: 0; + white-space: normal; +} + +.critical-non-explosive-action { + align-self: stretch; + min-height: 38px; +} + .critical-result { font-size: 1.05em; color: var(--danger); @@ -178,7 +230,8 @@ } .actions { - gap: 8px; + gap: 4px; + flex-wrap: wrap; } .actions .bt-button { @@ -187,16 +240,139 @@ min-width: 0; } -.critical-chance-actions { - flex-wrap: wrap; +.critical-manual-results { + flex: 1 0 100%; + display: grid; + gap: 6px; +} + +.critical-manual-results-label { + color: var(--text-color-secondary); + font-size: 0.72em; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.critical-chance-options { + width: 100%; + align-self: stretch; + display: grid; + grid-template-columns: repeat(4, auto); + gap: 4px; +} + +.critical-chance-options .bt-button { + min-width: 0; + height: 40px; + white-space: nowrap; } -.critical-chance-actions .critical-action { - order: -1; - flex: 0 0 100%; +.critical-chance-options.rolled-result-action { + grid-template-columns: minmax(0, 1fr); +} + +.critical-result-action, +.critical-dismiss-actions .bt-button { width: 100%; } -.critical-chance-actions .critical-action.action-unavailable { - visibility: hidden; +.critical-slot-options { + align-self: stretch; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1px; +} + +.critical-slot-option { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) 64px; + min-height: 32px; + min-width: 0; + border: 1px solid var(--border-color); + background: var(--background-input); + transition: opacity 0.15s ease-in-out, border-color 0.15s ease-in-out; +} + +.critical-slot-number { + display: grid; + place-items: center; + background-color: var(--border-color); + color: var(--text-color); + font-size: 0.8em; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.critical-slot-name { + align-self: stretch; + display: flex; + align-items: center; + min-width: 0; + padding: 4px 8px; + border-right: 1px solid var(--border-color); + color: var(--text-color-secondary); +} + +.critical-slot-hit-button { + width: 100%; + min-width: 0; + min-height: 100%; +} + +.critical-slot-option.critical-slot-hit { + border-color: var(--danger); + background: color-mix(in srgb, var(--danger) 18%, var(--background-highlight)); + opacity: 1; +} + +.critical-slot-option.critical-slot-dimmed { + opacity: 0.3; +} + +.critical-slot-option.critical-slot-collapsed { + grid-template-columns: 24px minmax(0, 1fr); + height: 18px; + min-height: 18px; + overflow: hidden; +} + +.critical-slot-option.critical-slot-collapsed .critical-slot-number { + font-size: 0.6em; +} + +.critical-slot-option.critical-slot-collapsed .critical-slot-name { + padding: 0 6px; + border-right: 0; + font-size: 0.7em; + line-height: 1; +} + +.critical-slot-option.critical-slot-hit .critical-slot-number { + background-color: var(--danger); + font-size: 1.2em; +} + +.critical-slot-option.critical-slot-hit .critical-slot-name { + color: #fff; +} + +.critical-slot-unavailable { + width: 100%; + height: 4px; + background: var(--border-color); + transition: opacity 0.15s ease-in-out; +} + +.critical-slot-unavailable.critical-slot-destroyed { + background: color-mix(in srgb, var(--danger) 32%, var(--background-input)); +} + +.critical-slot-unavailable.critical-slot-hit { + background: var(--danger); + opacity: 1; +} + +.critical-slot-unavailable.critical-slot-dimmed { + opacity: 0.3; } diff --git a/src/app/components/page-viewer/mek-critical-roll-dialog.component.spec.ts b/src/app/components/page-viewer/mek-critical-roll-dialog.component.spec.ts index 9c51c6f91..99d9d499e 100644 --- a/src/app/components/page-viewer/mek-critical-roll-dialog.component.spec.ts +++ b/src/app/components/page-viewer/mek-critical-roll-dialog.component.spec.ts @@ -3,12 +3,13 @@ // Author: Drake import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; -import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { provideZonelessChangeDetection, signal, type WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; import { MiscEquipment, WeaponEquipment } from '../../models/equipment.model'; -import type { CriticalSlot } from '../../models/force-serialization'; -import { CORE_2026_GAME_RULES } from '../../models/rules/game-rules'; +import type { CriticalSlot, SerializedPendingUnitCheck } from '../../models/force-serialization'; +import { CORE_2026_GAME_RULES, TW_GAME_RULES } from '../../models/rules/game-rules'; +import { MekCriticalHitAutomationService } from '../../services/mek-critical-hit-automation.service'; import { MekCriticalRollDialogComponent } from './mek-critical-roll-dialog.component'; describe('MekCriticalRollDialogComponent', () => { @@ -16,6 +17,28 @@ describe('MekCriticalRollDialogComponent', () => { let caseIISlot: CriticalSlot; let criticalSlots: CriticalSlot[]; let dialogRef: { close: jasmine.Spy }; + let previewRoll: jasmine.Spy; + let applyRoll: jasmine.Spy; + let dialogData: { + unit: CBTForceUnit; + location: string; + requiredHits: number; + consolidateImmediately: boolean; + locationDestroyed?: boolean; + pendingCriticalId: string; + caseIICheckRequired?: boolean; + caseIICheckPassed?: boolean; + caseIICheckResult?: 'resolve' | 'discard'; + canUndoToChance?: boolean; + }; + let getPendingCriticalHit: jasmine.Spy; + let setPendingCriticalRoll: jasmine.Spy; + let clearPendingCriticalRoll: jasmine.Spy; + let resolvePendingCriticalHit: jasmine.Spy; + let discardPendingCriticalHits: jasmine.Spy; + let setPendingCriticalCaseIICheckResult: jasmine.Spy; + let passPendingCriticalCaseIICheck: jasmine.Spy; + let pendingUnitChecks: WritableSignal; const slotsVersion = signal(0); beforeEach(async () => { @@ -30,17 +53,54 @@ describe('MekCriticalRollDialogComponent', () => { name: 'Medium Laser', type: 'weapon', }); + const secondWeapon = new WeaponEquipment({ + id: 'SmallLaser', + name: 'Small Laser', + type: 'weapon', + }); const inapplicableElement = document.createElementNS('http://www.w3.org/2000/svg', 'g'); inapplicableElement.setAttribute('hittable', '0'); caseIISlot = { id: 'caseii@LT', name: caseII.name, loc: 'LT', slot: 1, eq: caseII, el: inapplicableElement }; criticalSlots = [ { id: 'laser@LT', name: weapon.name, loc: 'LT', slot: 0, eq: weapon }, caseIISlot, + { id: 'small-laser@LT', name: secondWeapon.name, loc: 'LT', slot: 2, eq: secondWeapon }, + { id: 'destroyed-laser@LT', name: weapon.name, loc: 'LT', slot: 3, eq: weapon, hits: 1, destroyed: 1 }, ]; dialogRef = { close: jasmine.createSpy('close') }; + previewRoll = jasmine.createSpy('previewRoll').and.returnValue(null); + applyRoll = jasmine.createSpy('applyRoll').and.resolveTo({ + cancelled: false, + outcome: { + applied: false, + slotNumber: 2, + equipment: 'CASE II', + armoredAbsorption: false, + reason: 'unhittable', + }, + }); + getPendingCriticalHit = jasmine.createSpy('getPendingCriticalHit').and.returnValue(undefined); + setPendingCriticalRoll = jasmine.createSpy('setPendingCriticalRoll').and.returnValue(true); + clearPendingCriticalRoll = jasmine.createSpy('clearPendingCriticalRoll').and.returnValue(true); + resolvePendingCriticalHit = jasmine.createSpy('resolvePendingCriticalHit').and.returnValue(true); + discardPendingCriticalHits = jasmine.createSpy('discardPendingCriticalHits').and.returnValue(true); + setPendingCriticalCaseIICheckResult = jasmine.createSpy('setPendingCriticalCaseIICheckResult').and.returnValue(true); + passPendingCriticalCaseIICheck = jasmine.createSpy('passPendingCriticalCaseIICheck').and.returnValue(true); + pendingUnitChecks = signal([]); + const turnState = { + getPendingCriticalHit, + setPendingCriticalRoll, + clearPendingCriticalRoll, + resolvePendingCriticalHit, + discardPendingCriticalHits, + setPendingCriticalCaseIICheckResult, + passPendingCriticalCaseIICheck, + actionablePendingUnitChecks: () => pendingUnitChecks(), + }; const unit = { gameRules: CORE_2026_GAME_RULES, rules: { mountedCriticalDamageDestructionThreshold: () => 1 }, + turnState: () => turnState, getCritSlots: () => { slotsVersion(); return criticalSlots; @@ -51,16 +111,21 @@ describe('MekCriticalRollDialogComponent', () => { }, getUnit: () => ({ comp: [] }), } as unknown as CBTForceUnit; + dialogData = { + unit, + location: 'LT', + requiredHits: 1, + consolidateImmediately: true, + pendingCriticalId: '0198b234-7abc-7def-8123-456789abcdef', + }; await TestBed.configureTestingModule({ imports: [MekCriticalRollDialogComponent], providers: [ provideZonelessChangeDetection(), { provide: DialogRef, useValue: dialogRef }, - { - provide: DIALOG_DATA, - useValue: { unit, location: 'LT', requiredHits: 1, consolidateImmediately: true }, - }, + { provide: MekCriticalHitAutomationService, useValue: { previewRoll, applyRoll } }, + { provide: DIALOG_DATA, useValue: dialogData }, ], }).compileComponents(); fixture = TestBed.createComponent(MekCriticalRollDialogComponent); @@ -91,10 +156,201 @@ describe('MekCriticalRollDialogComponent', () => { expect(roll).toHaveBeenCalledOnceWith([1, 1]); }); - it('automatically retries if a selected slot becomes invalid before the roll finishes', () => { + it('keeps a stable three-button footer before a hit is selected', () => { + const actions = fixture.nativeElement.querySelectorAll( + '.actions .bt-button', + ) as NodeListOf; + + expect(actions).toHaveSize(3); + expect(actions[0].textContent).toContain('APPLY'); + expect(actions[0].disabled).toBeTrue(); + expect(actions[1].textContent).toContain('UNDO'); + expect(actions[1].disabled).toBeTrue(); + expect(actions[2].textContent).toContain('CANCEL'); + expect(actions[2].disabled).toBeFalse(); + }); + + it('previews explosion damage as soon as a hit target is selected and removes it on local UNDO', () => { + previewRoll.and.returnValue({ + applied: true, + slotNumber: 1, + equipment: 'AC/10 Ammo', + armoredAbsorption: false, + explosion: { + timing: 'immediate', + equipment: 'AC/10 Ammo', + rawDamage: 20, + pilotHits: 1, + locations: [{ + location: 'LT', + internalDamage: 12, + armorDamage: 8, + armorRear: true, + protection: 'case-ii', + }], + automaticCriticalEquipment: 'Engine', + }, + }); + + fixture.componentInstance.onFinished({ results: [1, 1] }); + fixture.detectChanges(); + + const preview = fixture.nativeElement.querySelector('.explosion-result') as HTMLElement; + expect(preview.textContent).toContain('AC/10 Ammo explosion: 20 damage'); + expect(preview.textContent).toContain('12 internal'); + expect(preview.textContent).toContain('8 rear armor'); + expect(preview.textContent).toContain('CASE II'); + expect(preview.textContent).toContain('MechWarrior feedback: 1 hit'); + expect(preview.textContent).toContain('Engine: automatic critical will be applied'); + expect(applyRoll).not.toHaveBeenCalled(); + + const selectedRows = fixture.nativeElement.querySelectorAll( + '.critical-slot-option', + ) as NodeListOf; + expect(selectedRows).toHaveSize(2); + expect(selectedRows[0].classList).not.toContain('critical-slot-collapsed'); + expect(selectedRows[1].classList).toContain('critical-slot-collapsed'); + expect(getComputedStyle(selectedRows[1]).height).toBe('18px'); + const selectedName = selectedRows[0].querySelector('.critical-slot-name') as HTMLElement; + const collapsedName = selectedRows[1].querySelector('.critical-slot-name') as HTMLElement; + expect(collapsedName.textContent).toContain('Small Laser'); + expect(parseFloat(getComputedStyle(collapsedName).fontSize)) + .toBeLessThan(parseFloat(getComputedStyle(selectedName).fontSize)); + expect(fixture.nativeElement.querySelector('.critical-random-row')).toBeNull(); + + (fixture.nativeElement.querySelector('.critical-slot-hit-button') as HTMLButtonElement).click(); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.explosion-result')).toBeNull(); + expect(fixture.nativeElement.querySelector('.critical-random-row')).not.toBeNull(); + expect(Array.from(fixture.nativeElement.querySelectorAll( + '.critical-slot-option', + ) as NodeListOf).every(row => !row.classList.contains('critical-slot-collapsed'))) + .toBeTrue(); + }); + + it('stages a physical slot choice with only a local UNDO before applying it', async () => { + caseIISlot.hits = 1; + caseIISlot.destroyed = 1; + slotsVersion.update(version => version + 1); + fixture.detectChanges(); + applyRoll.and.resolveTo({ + cancelled: false, + outcome: { + applied: true, + slotNumber: 1, + equipment: 'Medium Laser', + armoredAbsorption: false, + }, + }); + const choices = fixture.nativeElement.querySelectorAll( + '.critical-slot-option', + ) as NodeListOf; + const hitButtons = fixture.nativeElement.querySelectorAll( + '.critical-slot-hit-button', + ) as NodeListOf; + + expect(choices).toHaveSize(2); + expect(Array.from(choices, choice => choice.querySelector('.critical-slot-number')?.textContent?.trim())) + .toEqual(['1', '3']); + expect(Array.from(choices, choice => choice.querySelector('.critical-slot-name')?.textContent?.trim())) + .toEqual(['Medium Laser', 'Small Laser']); + expect(hitButtons).toHaveSize(2); + expect(Array.from(hitButtons, button => button.textContent?.trim())).toEqual(['HIT', 'HIT']); + expect(Array.from(choices, choice => choice.textContent).join(' ')).not.toContain('CASE II'); + const unavailableSlots = fixture.nativeElement.querySelectorAll( + '.critical-slot-unavailable', + ) as NodeListOf; + expect(unavailableSlots).toHaveSize(10); + expect(getComputedStyle(unavailableSlots[0]).height).toBe('4px'); + expect(unavailableSlots[0].classList).not.toContain('critical-slot-destroyed'); + expect(unavailableSlots[1].classList).toContain('critical-slot-destroyed'); + + hitButtons[0].click(); + fixture.detectChanges(); + + expect(setPendingCriticalRoll).toHaveBeenCalledOnceWith(dialogData.pendingCriticalId, [1, 1]); + expect(applyRoll).not.toHaveBeenCalled(); + let selectedChoices = fixture.nativeElement.querySelectorAll( + '.critical-slot-option', + ) as NodeListOf; + let selectedHitButtons = fixture.nativeElement.querySelectorAll( + '.critical-slot-hit-button', + ) as NodeListOf; + expect(selectedChoices).toHaveSize(2); + expect(selectedHitButtons).toHaveSize(1); + expect(selectedHitButtons[0].textContent).toContain('UNDO'); + expect(selectedChoices[0].classList).toContain('critical-slot-hit'); + expect(selectedChoices[0].classList).not.toContain('critical-slot-dimmed'); + expect(selectedChoices[1].classList).toContain('critical-slot-dimmed'); + expect(selectedChoices[1].classList).toContain('critical-slot-collapsed'); + expect(getComputedStyle(selectedChoices[1]).height).toBe('18px'); + expect(selectedChoices[1].classList).not.toContain('critical-slot-hit'); + const selectedUnavailableSlots = fixture.nativeElement.querySelectorAll( + '.critical-slot-unavailable', + ) as NodeListOf; + expect(Array.from(selectedUnavailableSlots) + .every(line => line.classList.contains('critical-slot-dimmed'))).toBeTrue(); + expect((fixture.nativeElement.querySelector('.actions .bt-button.primary') as HTMLButtonElement) + .textContent).toContain('APPLY'); + + selectedHitButtons[0].click(); + fixture.detectChanges(); + + expect(clearPendingCriticalRoll).toHaveBeenCalledOnceWith(dialogData.pendingCriticalId); + expect(applyRoll).not.toHaveBeenCalled(); + expect(Array.from(fixture.nativeElement.querySelectorAll( + '.critical-slot-option', + ) as NodeListOf).every(row => !row.classList.contains('critical-slot-collapsed'))) + .toBeTrue(); + expect(Array.from(fixture.nativeElement.querySelectorAll('.critical-slot-hit-button') as NodeListOf, + button => button.textContent?.trim())).toEqual(['HIT', 'HIT']); + + (fixture.nativeElement.querySelector('.critical-slot-hit-button') as HTMLButtonElement).click(); + fixture.detectChanges(); + (fixture.nativeElement.querySelector('.actions .bt-button.primary') as HTMLButtonElement).click(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(applyRoll).toHaveBeenCalledOnceWith( + dialogData.unit, + 'LT', + [1, 1], + true, + jasmine.any(Object), + ); + expect(fixture.nativeElement.querySelector('.critical-result')?.textContent).toContain('Medium Laser'); + }); + + it('stages a dice result through the same row-level UNDO path', () => { + fixture.componentInstance.onFinished({ results: [1, 1] }); + fixture.detectChanges(); + + let hitButtons = fixture.nativeElement.querySelectorAll( + '.critical-slot-hit-button', + ) as NodeListOf; + expect(hitButtons).toHaveSize(1); + expect(hitButtons[0].textContent).toContain('UNDO'); + expect(applyRoll).not.toHaveBeenCalled(); + expect((fixture.nativeElement.querySelector('.actions .bt-button.primary') as HTMLButtonElement) + .textContent).toContain('APPLY'); + + hitButtons[0].click(); + fixture.detectChanges(); + + hitButtons = fixture.nativeElement.querySelectorAll( + '.critical-slot-hit-button', + ) as NodeListOf; + expect(clearPendingCriticalRoll).toHaveBeenCalledOnceWith(dialogData.pendingCriticalId); + expect(Array.from(hitButtons, button => button.textContent?.trim())).toEqual(['HIT', 'HIT']); + }); + + it('automatically retries if a selected slot becomes invalid before the roll finishes', async () => { const roll = spyOn(fixture.componentInstance, 'roll'); fixture.componentInstance.onFinished({ results: [1, 2] }); + fixture.componentInstance.primaryAction(); + await fixture.whenStable(); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.critical-result')).toBeNull(); @@ -102,46 +358,295 @@ describe('MekCriticalRollDialogComponent', () => { expect(roll).toHaveBeenCalledTimes(1); }); - it('discards remaining criticals and dismisses when no valid slot remains', () => { - criticalSlots[0].destroyed = 1; - slotsVersion.update(version => version + 1); - fixture.detectChanges(); - - expect(fixture.componentInstance.rollButtonLabel()).toBe('DISCARD REMAINING'); - expect(fixture.componentInstance.complete()).toBeFalse(); + it('discards a non-explosive result instead of rerolling it for a destroyed location', async () => { + dialogData.locationDestroyed = true; + applyRoll.and.resolveTo({ + cancelled: false, + outcome: { + applied: false, + slotNumber: 2, + equipment: 'Heat Sink', + armoredAbsorption: false, + reason: 'non-explosive', + }, + }); + const roll = spyOn(fixture.componentInstance, 'roll'); - const primaryButton = fixture.nativeElement.querySelector('.bt-button.primary') as HTMLButtonElement; - expect(primaryButton.disabled).toBeFalse(); - primaryButton.click(); + fixture.componentInstance.onFinished({ results: [1, 2] }); + fixture.componentInstance.primaryAction(); + await fixture.whenStable(); fixture.detectChanges(); - expect(fixture.componentInstance.discarded()).toBeTrue(); + expect(fixture.componentInstance.appliedHits()).toBe(0); + expect(fixture.componentInstance.discardedHits()).toBe(1); expect(fixture.componentInstance.complete()).toBeTrue(); - expect(fixture.componentInstance.rollButtonLabel()).toBe('CRITICALS DISCARDED'); expect(dialogRef.close).toHaveBeenCalledOnceWith({ completed: true }); + expect(fixture.nativeElement.querySelector('.critical-result')?.textContent) + .toContain('not explosive — critical discarded'); + const unavailableSlots = fixture.nativeElement.querySelectorAll( + '.critical-slot-unavailable', + ) as NodeListOf; + expect(unavailableSlots[0].classList).toContain('critical-slot-hit'); + expect(unavailableSlots[0].classList).not.toContain('critical-slot-dimmed'); + expect(Array.from(unavailableSlots) + .filter((_, index) => index !== 0) + .every(line => line.classList.contains('critical-slot-dimmed'))).toBeTrue(); + expect(roll).not.toHaveBeenCalled(); }); - it('uses completed primary-button states as dismiss actions', () => { - const primaryButton = fixture.nativeElement.querySelector('.bt-button.primary') as HTMLButtonElement; + it('keeps a cancelled explosion review available without applying the critical hit', async () => { + applyRoll.and.resolveTo({ cancelled: true, outcome: null }); + + fixture.componentInstance.onFinished({ results: [1, 1] }); + fixture.componentInstance.primaryAction(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.componentInstance.appliedHits()).toBe(0); + expect(fixture.componentInstance.primaryLabel()).toBe('APPLY'); + expect(fixture.nativeElement.querySelector('.critical-result')?.textContent) + .toContain('rolled hit has not been applied'); + }); + + it('omits zero-valued explosion effects while preserving armor-only and no-damage results', async () => { + applyRoll.and.resolveTo({ + cancelled: false, + outcome: { + applied: true, + slotNumber: 1, + equipment: 'Ammo', + armoredAbsorption: false, + explosion: { + equipment: 'Ammo', + rawDamage: 20, + pilotHits: 0, + locations: [ + { + location: 'LT', + internalDamage: 0, + armorDamage: 5, + armorRear: true, + protection: 'case-ii', + }, + { + location: 'CT', + internalDamage: 0, + armorDamage: 0, + armorRear: false, + protection: 'none', + }, + ], + }, + }, + }); + + fixture.componentInstance.onFinished({ results: [1, 1] }); + fixture.componentInstance.primaryAction(); + await fixture.whenStable(); + fixture.detectChanges(); + + const summary = fixture.nativeElement.querySelector('.explosion-result')?.textContent ?? ''; + expect(summary).toContain('5 rear armor'); + expect(summary).toContain('No damage'); + expect(summary).not.toContain('0 internal'); + expect(summary).not.toContain('MechWarrior feedback'); + }); + it('persists a rolled hit and leaves it pending when review is cancelled', async () => { + dialogData.pendingCriticalId = 'critical:1'; + applyRoll.and.resolveTo({ cancelled: true, outcome: null }); + + fixture.componentInstance.onFinished({ results: [2, 5] }); + fixture.componentInstance.close(); + + expect(setPendingCriticalRoll).toHaveBeenCalledOnceWith('critical:1', [2, 5]); + expect(applyRoll).not.toHaveBeenCalled(); + expect(resolvePendingCriticalHit).not.toHaveBeenCalled(); + expect(dialogRef.close).toHaveBeenCalledOnceWith({ completed: false }); + }); + + it('cannot be dismissed while critical dice are rolling', () => { + const roller = fixture.componentInstance.roller()!; + spyOn(roller, 'isRolling').and.returnValue(true); + + fixture.componentInstance.close(); + + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + + it('pauses a Total Warfare critical chain for immediate consciousness', () => { + (dialogData.unit as unknown as { gameRules: typeof TW_GAME_RULES }).gameRules = TW_GAME_RULES; + pendingUnitChecks.set([{ + type: 'unit-check', + id: 'consciousness:1', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: 'immediate:test', + target: 5, + }]); + fixture.componentInstance.outcome.set({ + applied: true, + slotNumber: 1, + equipment: 'Medium Laser', + armoredAbsorption: false, + }); fixture.componentInstance.appliedHits.set(1); fixture.detectChanges(); - expect(primaryButton.textContent).toContain('CRITICALS APPLIED'); - expect(primaryButton.disabled).toBeFalse(); - primaryButton.click(); + const primary = fixture.nativeElement.querySelector('.actions .bt-button.primary') as HTMLButtonElement; + expect(primary.textContent).toContain('CONTINUE'); + + fixture.componentInstance.primaryAction(); + + expect(dialogRef.close).toHaveBeenCalledOnceWith({ + completed: true, + interruptedForConsciousness: true, + }); + }); + + it('offers physical CASE II outcomes before exposing critical slots', () => { + fixture.destroy(); + dialogData.caseIICheckRequired = true; + fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture.detectChanges(); + + const options = fixture.nativeElement.querySelectorAll( + '.case-ii-manual-options .bt-button', + ) as NodeListOf; + expect(Array.from(options, button => button.textContent?.trim())).toEqual([ + '2–7 · RESOLVE CRITICAL', + '8+ · DISCARD CRITICAL', + ]); + expect(options[1].classList).toContain('danger'); + expect(fixture.nativeElement.querySelector('.critical-slot-options')).toBeNull(); + + options[0].click(); + fixture.detectChanges(); + + expect(passPendingCriticalCaseIICheck).toHaveBeenCalledOnceWith(dialogData.pendingCriticalId); + expect(fixture.nativeElement.querySelector('.critical-slot-options')).not.toBeNull(); + }); + + it('persists a virtual CASE II result until it is explicitly applied', () => { + fixture.destroy(); + dialogData.caseIICheckRequired = true; + fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture.detectChanges(); + fixture.componentInstance.onCaseIIFinished({ results: [4, 4] }); + fixture.detectChanges(); + + expect(setPendingCriticalCaseIICheckResult).toHaveBeenCalledOnceWith( + dialogData.pendingCriticalId, + 'discard', + ); + expect(resolvePendingCriticalHit).not.toHaveBeenCalled(); + expect(fixture.nativeElement.querySelector('.actions .bt-button.danger')?.textContent) + .toContain('DISCARD'); + + fixture.componentInstance.close(); + expect(dialogRef.close).toHaveBeenCalledOnceWith({ completed: false }); + expect(resolvePendingCriticalHit).not.toHaveBeenCalled(); + }); + + it('records a physical CASE II discard as one resolved pending critical', () => { + fixture.destroy(); + dialogData.caseIICheckRequired = true; + dialogData.requiredHits = 2; + fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture.detectChanges(); + + fixture.componentInstance.applyCaseIICheck('discard'); + fixture.detectChanges(); + + expect(resolvePendingCriticalHit).toHaveBeenCalledOnceWith(dialogData.pendingCriticalId); + expect(fixture.componentInstance.discardedHits()).toBe(1); + expect(fixture.componentInstance.complete()).toBeFalse(); + expect(fixture.nativeElement.querySelector('.critical-result')?.textContent) + .toContain('CASE II discarded'); + expect(fixture.componentInstance.primaryLabel()).toBe('NEXT'); + }); + + it('decrements persisted work only after the critical is applied', async () => { + dialogData.pendingCriticalId = 'critical:1'; + applyRoll.and.resolveTo({ + cancelled: false, + outcome: { + applied: true, + slotNumber: 1, + equipment: 'Medium Laser', + armoredAbsorption: false, + }, + }); + + fixture.componentInstance.onFinished({ results: [1, 1] }); + fixture.componentInstance.primaryAction(); + await fixture.whenStable(); + + expect(setPendingCriticalRoll).toHaveBeenCalledOnceWith('critical:1', [1, 1]); + expect(resolvePendingCriticalHit).toHaveBeenCalledOnceWith('critical:1'); + expect(clearPendingCriticalRoll).not.toHaveBeenCalled(); expect(dialogRef.close).toHaveBeenCalledOnceWith({ completed: true }); + }); - dialogRef.close.calls.reset(); - fixture.componentInstance.appliedHits.set(0); - fixture.componentInstance.discarded.set(true); + it('restores an unresolved roll for review without rerolling it', () => { + fixture.destroy(); + dialogData.pendingCriticalId = 'critical:1'; + getPendingCriticalHit.and.returnValue({ + id: 'critical:1', + location: 'LT', + remainingHits: 1, + roll: [3, 4], + }); + fixture = TestBed.createComponent(MekCriticalRollDialogComponent); fixture.detectChanges(); - expect(primaryButton.textContent).toContain('CRITICALS DISCARDED'); + expect(fixture.componentInstance.primaryLabel()).toBe('APPLY'); + expect(fixture.nativeElement.querySelector('.critical-result')?.textContent) + .toContain('rolled hit has not been applied'); + }); + + it('offers sequence UNDO only before the first critical is committed', () => { + fixture.destroy(); + dialogData.canUndoToChance = true; + fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture.detectChanges(); + + const undo = fixture.nativeElement.querySelector('.critical-sequence-undo') as HTMLButtonElement; + expect(undo.textContent).toContain('UNDO'); + + undo.click(); + expect(dialogRef.close).toHaveBeenCalledOnceWith({ completed: false, undoToChance: true }); + + fixture.componentInstance.appliedHits.set(1); + fixture.detectChanges(); + expect((fixture.nativeElement.querySelector('.critical-sequence-undo') as HTMLButtonElement).disabled) + .toBeTrue(); + }); + + it('discards remaining criticals and dismisses when no valid slot remains', () => { + criticalSlots[0].destroyed = 1; + criticalSlots[2].destroyed = 1; + slotsVersion.update(version => version + 1); + fixture.detectChanges(); + + expect(fixture.componentInstance.primaryLabel()).toBe('DISCARD'); + expect(fixture.componentInstance.complete()).toBeFalse(); + + const primaryButton = fixture.nativeElement.querySelector('.bt-button.primary') as HTMLButtonElement; expect(primaryButton.disabled).toBeFalse(); primaryButton.click(); + fixture.detectChanges(); + expect(discardPendingCriticalHits).toHaveBeenCalledOnceWith(dialogData.pendingCriticalId); expect(dialogRef.close).toHaveBeenCalledOnceWith({ completed: true }); }); + + it('displays critical-slot dice as separate selectors without sum notation', () => { + const element = fixture.nativeElement as HTMLElement; + + expect(fixture.componentInstance.roller()?.showSum()).toBeFalse(); + expect(element.querySelector('.critical-dice-trigger .plus-sign')).toBeNull(); + expect(element.querySelector('.critical-dice-trigger .sum')).toBeNull(); + }); }); diff --git a/src/app/components/page-viewer/mek-critical-roll-dialog.component.ts b/src/app/components/page-viewer/mek-critical-roll-dialog.component.ts index 0b88c4d86..aca91e647 100644 --- a/src/app/components/page-viewer/mek-critical-roll-dialog.component.ts +++ b/src/app/components/page-viewer/mek-critical-roll-dialog.component.ts @@ -6,26 +6,58 @@ import { ChangeDetectionStrategy, Component, computed, inject, signal, viewChild import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; import { getMekLocationLabel } from '../../models/entity/types'; +import type { CriticalSlot } from '../../models/force-serialization'; import { - applyMekCriticalRoll, getMekExplosionProtection, - hasRollableMekCriticalSlot, + getRollableMekCriticalSlots, mekCriticalRollDiceCount, + mekCriticalRollForSlot, mekCriticalRollLocation, + mekCriticalSlotRollability, + mekCriticalSlotIndexForRoll, randomValidMekCriticalRoll, + type MekCriticalHitPreview, + type MekExplosionLocationDamage, + type MekCriticalRollOptions, type MekCriticalRollOutcome, } from '../../utils/mek-critical-hit.util'; +import { MekCriticalHitAutomationService } from '../../services/mek-critical-hit-automation.service'; import { DiceRollerComponent } from '../dice-roller/dice-roller.component'; export interface MekCriticalRollDialogData { readonly unit: CBTForceUnit; readonly location: string; - readonly requiredHits?: number; + readonly targetLocation?: string; + readonly requiredHits: number; + readonly locationDestroyed?: boolean; readonly consolidateImmediately: boolean; + readonly pendingCriticalId: string; + readonly caseIICheckRequired?: boolean; + readonly caseIICheckPassed?: boolean; + readonly caseIICheckResult?: 'resolve' | 'discard'; + readonly pilotDamageGroup?: string; + readonly canUndoToChance?: boolean; } export interface MekCriticalRollDialogResult { readonly completed: boolean; + readonly interruptedForConsciousness?: boolean; + readonly undoToChance?: true; +} + +interface CriticalSlotRow { + readonly slotIndex: number; + readonly slot: CriticalSlot | null; + readonly destroyed: boolean; +} + +interface CriticalExplosionDisplay { + readonly equipment: string; + readonly rawDamage: number; + readonly pilotHits: number; + readonly locations: readonly MekExplosionLocationDamage[]; + readonly phaseEnd: boolean; + readonly automaticCriticalMessage?: string; } @Component({ @@ -38,9 +70,13 @@ export interface MekCriticalRollDialogResult {
Critical Roll · {{ locationLabel }}
- @if (data.requiredHits !== undefined) { -
- {{ appliedHits() }} / {{ data.requiredHits }} critical hits applied +
+ {{ resolvedHits() }} / {{ data.requiredHits }} critical hits + {{ data.locationDestroyed ? 'resolved' : 'applied' }} +
+ @if (data.locationDestroyed) { +
+ Destroyed location: only hits on explosive components resolve; all others are discarded.
} @if (explosionProtection !== 'none') { @@ -49,36 +85,81 @@ export interface MekCriticalRollDialogResult { {{ explosionProtectionNote }}
} - + @if (needsCaseIICheck()) { +
+ CASE II critical check + Roll 2D6 for this critical: resolve it on 2–7; discard it on 8+. +
+
+ +
+ +
+
+ @if (caseIICheckResult(); as checkResult) { +
+ {{ checkResult === 'discard' + ? 'CASE II discards this critical hit.' + : 'CASE II allows this critical hit.' }} +
+ } @else { +
+ + +
+ } + } @else if (selectedSlotIndex() === null) { +
+ +
+ +
+
+ } @if (outcome(); as currentOutcome) {
{{ outcomeLabel(currentOutcome) }}
- @if (currentOutcome.explosion; as explosion) { -
- {{ explosion.equipment }} explodes for {{ explosion.rawDamage }} damage. - @for (damage of explosion.locations; track damage.location) { -
- {{ locationName(damage.location) }}: - {{ damage.internalDamage }} internal - @if (damage.armorDamage > 0) { - · {{ damage.armorDamage }} {{ damage.armorRear ? 'rear ' : '' }}armor - } - @if (damage.protection !== 'none') { - · {{ damage.protection === 'case-ii' ? 'CASE II' : 'CASE' }} - } -
- } -
MechWarrior feedback: {{ explosion.pilotHits }} hit{{ explosion.pilotHits === 1 ? '' : 's' }}.
- @if (explosion.automaticCritical; as automatic) { -
- {{ automatic.equipment }} slot {{ automatic.slotNumber }}: - {{ automatic.armoredAbsorption ? 'component armor absorbs the automatic critical' : 'automatic critical applied' }}. -
- } -
- } @if (currentOutcome.pendingExplosion; as pendingExplosion) {
@@ -88,20 +169,114 @@ export interface MekCriticalRollDialogResult {
It resolves at phase end. Firing the weapon this phase prevents it.
} + } @else if (currentDiscardReason(); as discardReason) { +
+ {{ discardReason === 'case-ii' + ? 'CASE II discarded this critical hit.' + : 'Non-explosive physical roll recorded; critical discarded.' }} +
+ } @else if (automationCancelled()) { +
Critical review paused; the rolled hit has not been applied.
} @else if (!hasRollableSlot()) {
No valid critical slots remain; excess critical hits are discarded.
} + @if (explosionDisplay(); as explosion) { +
+ {{ explosion.equipment }} explosion: {{ explosion.rawDamage }} damage. + @for (damage of explosion.locations; track damage.location) { +
+ {{ locationName(damage.location) }}: + @if (damage.internalDamage > 0) { + {{ damage.internalDamage }} internal + } + @if (damage.armorDamage > 0) { + @if (damage.internalDamage > 0) { · } + {{ damage.armorDamage }} {{ damage.armorRear ? 'rear ' : '' }}armor + } + @if (damage.internalDamage === 0 && damage.armorDamage === 0) { + No damage + } + @if (damage.protection !== 'none') { + · {{ damage.protection === 'case-ii' ? 'CASE II' : 'CASE' }} + } +
+ } + @if (explosion.pilotHits > 0) { +
MechWarrior feedback: {{ explosion.pilotHits }} hit{{ explosion.pilotHits === 1 ? '' : 's' }}.
+ } + @if (explosion.automaticCriticalMessage; as automaticCriticalMessage) { +
{{ automaticCriticalMessage }}
+ } + @if (explosion.phaseEnd) { +
It resolves at phase end. Firing the weapon this phase prevents it.
+ } +
+ } + @if (showManualSlots()) { +
+ @for (row of manualSlotRows(); track row.slotIndex) { + @if (row.slot; as slot) { +
+ {{ slotNumber(slot) }} + {{ slotLabel(slot) }} + @if (isSelectedSlot(slot)) { + + } @else if (selectedSlotIndex() === null) { + + } +
+ } @else { + + } + } +
+ @if (canDiscardNonExplosiveResult()) { + + } + }
+ + -
`, @@ -112,77 +287,303 @@ export interface MekCriticalRollDialogResult { }) export class MekCriticalRollDialogComponent { private readonly dialogRef = inject(DialogRef); + private readonly criticalHitAutomation = inject(MekCriticalHitAutomationService); readonly data = inject(DIALOG_DATA); readonly roller = viewChild('roller'); - readonly targetLocation = mekCriticalRollLocation(this.data.unit, this.data.location); + readonly caseIIRoller = viewChild('caseIIRoller'); + readonly criticalRollOptions: MekCriticalRollOptions = { + transfer: false, + ...(this.data.locationDestroyed && { explosiveSlotsOnly: true }), + ...(this.data.pilotDamageGroup + ? { pilotDamageGroup: this.data.pilotDamageGroup } + : {}), + }; + readonly targetLocation = this.data.targetLocation ?? (this.data.locationDestroyed + ? this.data.location + : mekCriticalRollLocation(this.data.unit, this.data.location)); readonly locationLabel = this.targetLocation === this.data.location ? getMekLocationLabel(this.targetLocation) ?? this.targetLocation : `${getMekLocationLabel(this.data.location) ?? this.data.location} → ${getMekLocationLabel(this.targetLocation) ?? this.targetLocation}`; readonly diceCount = mekCriticalRollDiceCount(this.targetLocation); readonly appliedHits = signal(0); + readonly discardedHits = signal(0); + readonly resolvedHits = computed(() => this.appliedHits() + this.discardedHits()); readonly outcome = signal(null); - readonly discarded = signal(false); - readonly complete = computed(() => - this.discarded() - || (this.data.requiredHits !== undefined && this.appliedHits() >= this.data.requiredHits)); - readonly hasRollableSlot = computed(() => hasRollableMekCriticalSlot( + readonly resolving = signal(false); + readonly caseIICheckPassed = signal(this.data.caseIICheckPassed ?? false); + readonly caseIICheckResult = signal<'resolve' | 'discard' | null>(this.data.caseIICheckResult ?? null); + readonly currentDiscardReason = signal<'case-ii' | 'non-explosive' | null>(null); + private readonly restoredRoll = this.data.unit.turnState() + .getPendingCriticalHit(this.data.pendingCriticalId)?.roll; + readonly selectedSlotIndex = signal(this.restoredRoll + ? mekCriticalSlotIndexForRoll(this.targetLocation, this.restoredRoll) + : null); + readonly automationCancelled = signal(!!this.restoredRoll); + private readonly pendingResults = signal( + this.restoredRoll ? [...this.restoredRoll] : null, + ); + readonly pendingHitPreview = computed(() => { + const results = this.pendingResults(); + return results + ? this.criticalHitAutomation.previewRoll( + this.data.unit, + this.targetLocation, + results, + this.criticalRollOptions, + ) + : null; + }); + readonly explosionDisplay = computed(() => { + const preview = this.pendingHitPreview()?.explosion; + if (preview) { + return { + equipment: preview.equipment, + rawDamage: preview.rawDamage, + pilotHits: preview.pilotHits, + locations: preview.locations, + phaseEnd: preview.timing === 'phase-end', + ...(preview.automaticCriticalEquipment + ? { automaticCriticalMessage: `${preview.automaticCriticalEquipment}: automatic critical will be applied.` } + : {}), + }; + } + + const applied = this.outcome()?.explosion; + if (!applied) return null; + return { + equipment: applied.equipment, + rawDamage: applied.rawDamage, + pilotHits: applied.pilotHits, + locations: applied.locations, + phaseEnd: false, + ...(applied.automaticCritical + ? { + automaticCriticalMessage: `${applied.automaticCritical.equipment} slot ${applied.automaticCritical.slotNumber}: ${applied.automaticCritical.armoredAbsorption + ? 'component armor absorbs the automatic critical' + : 'automatic critical applied'}.`, + } + : {}), + }; + }); + readonly complete = computed(() => this.resolvedHits() >= this.data.requiredHits); + readonly canUndoToChance = computed(() => (this.data.canUndoToChance ?? false) + && this.resolvedHits() === 0); + private readonly availableSlots = computed(() => getRollableMekCriticalSlots( this.data.unit, this.targetLocation, - { transfer: false }, + this.criticalRollOptions, )); + private readonly lockedSlotRows = signal( + this.restoredRoll ? this.createSlotRows(this.availableSlots()) : null, + ); + readonly manualSlotRows = computed(() => + this.lockedSlotRows() ?? this.createSlotRows(this.availableSlots())); + readonly hasRollableSlot = computed(() => this.availableSlots().length > 0); + readonly isRolling = computed(() => this.roller()?.isRolling() ?? false); + readonly isCaseIIRolling = computed(() => this.caseIIRoller()?.isRolling() ?? false); + readonly isAnyRolling = computed(() => this.isRolling() || this.isCaseIIRolling()); + readonly needsCaseIICheck = computed(() => (this.data.caseIICheckRequired ?? false) + && !this.caseIICheckPassed() + && !this.complete() + && !this.currentDiscardReason() + && this.hasRollableSlot()); + readonly canStartCaseIIRoll = computed(() => this.needsCaseIICheck() + && !this.isAnyRolling() + && !this.resolving() + && !this.caseIICheckResult()); + readonly canStartRoll = computed(() => !this.isRolling() + && !this.needsCaseIICheck() + && !this.resolving() + && !this.complete() + && !this.currentDiscardReason() + && !this.pendingResults() + && !this.outcome() + && this.hasRollableSlot()); + readonly showManualSlots = computed(() => !this.needsCaseIICheck() && this.manualSlotRows().length > 0); + readonly canDiscardNonExplosiveResult = computed(() => this.data.locationDestroyed === true + && this.canStartRoll()); + readonly highlightedSlotNumber = computed(() => { + const outcome = this.outcome(); + if (outcome) return outcome.slotNumber; + + const results = this.pendingResults(); + if (!results) return null; + const slotIndex = mekCriticalSlotIndexForRoll(this.targetLocation, results); + return slotIndex === null ? null : slotIndex + 1; + }); + readonly canUsePrimary = computed(() => !this.isAnyRolling() + && !this.resolving() + && (!!this.caseIICheckResult() + || !!this.pendingResults() + || !!this.outcome() + || !!this.currentDiscardReason() + || !this.hasRollableSlot())); + readonly hasInterruptingConsciousness = computed(() => + this.data.unit.gameRules.id === 'tw' + && this.data.unit.turnState().actionablePendingUnitChecks() + .some(check => check.kind === 'consciousness')); // Keep the protection that applied when rolling began visible after an explosion destroys the location. readonly explosionProtection = getMekExplosionProtection(this.data.unit, this.targetLocation); readonly explosionProtectionLabel = this.explosionProtection === 'case-ii' ? '[CASE II]' : '[CASE]'; readonly explosionProtectionNote = this.data.unit.gameRules.getMekExplosionProtectionNote(this.explosionProtection); roll(): void { - if (this.complete()) { - this.close(); - return; - } - if (!this.hasRollableSlot()) { - this.discarded.set(true); - this.close(); - return; - } + if (!this.canStartRoll()) return; + this.lockManualSlots(); this.outcome.set(null); + this.automationCancelled.set(false); const results = randomValidMekCriticalRoll( this.data.unit, this.targetLocation, Math.random, - { transfer: false }, + this.criticalRollOptions, ); if (!results) return; this.roller()?.roll(results); } + rollCaseIICheck(): void { + if (!this.canStartCaseIIRoll()) return; + this.caseIIRoller()?.roll(); + } + + onCaseIIFinished(event: { readonly results: readonly number[] }): void { + const result = event.results.reduce((total, die) => total + die, 0) >= 8 + ? 'discard' + : 'resolve'; + this.caseIICheckResult.set(result); + this.data.unit.turnState().setPendingCriticalCaseIICheckResult( + this.data.pendingCriticalId, + result, + ); + } + + applyCaseIICheck(result: 'resolve' | 'discard'): void { + if (this.isAnyRolling() || this.resolving() || !this.needsCaseIICheck()) return; + if (result === 'discard') { + this.discardCurrentCritical('case-ii'); + return; + } + if (!this.data.unit.turnState().passPendingCriticalCaseIICheck(this.data.pendingCriticalId)) return; + this.caseIICheckPassed.set(true); + this.caseIICheckResult.set(null); + } + onFinished(event: { readonly results: number[] }): void { - const outcome = applyMekCriticalRoll( + this.stageResults(event.results); + } + + selectSlot(slot: CriticalSlot): void { + if (!this.canStartRoll() || slot.slot === undefined) return; + this.stageResults(mekCriticalRollForSlot(this.targetLocation, slot.slot)); + } + + private stageResults(results: readonly number[]): void { + if (this.resolving() || this.complete() || this.pendingResults() || this.outcome()) return; + const slotIndex = mekCriticalSlotIndexForRoll(this.targetLocation, results); + if (slotIndex === null) return; + if (!this.data.unit.turnState().setPendingCriticalRoll(this.data.pendingCriticalId, results)) return; + this.lockManualSlots(); + this.pendingResults.set([...results]); + this.selectedSlotIndex.set(slotIndex); + this.automationCancelled.set(false); + } + + undoSlotSelection(): void { + if (this.selectedSlotIndex() === null || !this.pendingResults() + || this.isAnyRolling() || this.resolving()) return; + if (!this.data.unit.turnState().clearPendingCriticalRoll(this.data.pendingCriticalId)) return; + this.pendingResults.set(null); + this.selectedSlotIndex.set(null); + this.automationCancelled.set(false); + this.unlockManualSlots(); + } + + private async resolvePendingRoll(): Promise { + const results = this.pendingResults(); + if (!results || this.resolving()) return; + + this.resolving.set(true); + const resolution = await this.criticalHitAutomation.applyRoll( this.data.unit, this.targetLocation, - event.results, + results, this.data.consolidateImmediately, - { transfer: false }, - ); + this.criticalRollOptions, + ).finally(() => this.resolving.set(false)); + if (resolution.cancelled) { + this.automationCancelled.set(true); + return; + } + + this.pendingResults.set(null); + this.selectedSlotIndex.set(null); + this.automationCancelled.set(false); + const outcome = resolution.outcome; if (!outcome?.applied) { + if (this.data.locationDestroyed && outcome?.reason === 'non-explosive') { + this.resolvePersistedHit(); + this.outcome.set(outcome); + this.discardedHits.update(value => value + 1); + if (this.complete()) this.completeDialog(); + return; + } + this.clearPersistedRoll(); this.outcome.set(null); + this.unlockManualSlots(); this.roll(); return; } + this.resolvePersistedHit(); this.outcome.set(outcome); this.appliedHits.update(value => value + 1); + if (this.complete()) this.completeDialog(this.hasInterruptingConsciousness()); } - rollButtonLabel(): string { - if (this.discarded()) return 'CRITICALS DISCARDED'; - if (this.complete()) return 'CRITICALS APPLIED'; - if (!this.hasRollableSlot()) return 'DISCARD REMAINING'; - if (this.data.requiredHits === undefined) return 'ROLL CRITICAL'; - return `ROLL CRITICAL (${this.appliedHits()+1}/${this.data.requiredHits})`; + primaryLabel(): string { + const caseIICheckResult = this.caseIICheckResult(); + if (caseIICheckResult === 'resolve') return 'RESOLVE'; + if (caseIICheckResult === 'discard') return 'DISCARD'; + if (this.outcome() && this.hasInterruptingConsciousness()) return 'CONTINUE'; + if (!this.hasRollableSlot()) return 'DISCARD'; + if (this.outcome() || this.currentDiscardReason()) return 'NEXT'; + return 'APPLY'; + } + + primaryAction(): void { + if (!this.canUsePrimary()) return; + const caseIICheckResult = this.caseIICheckResult(); + if (caseIICheckResult) { + this.applyCaseIICheck(caseIICheckResult); + return; + } + if (this.pendingResults()) { + void this.resolvePendingRoll(); + return; + } + if (this.outcome() && this.hasInterruptingConsciousness()) { + this.close(true); + return; + } + if (!this.hasRollableSlot()) { + this.data.unit.turnState().discardPendingCriticalHits(this.data.pendingCriticalId); + this.completeDialog(); + return; + } + if (this.outcome() || this.currentDiscardReason()) { + this.outcome.set(null); + this.currentDiscardReason.set(null); + this.caseIICheckPassed.set(false); + this.unlockManualSlots(); + } } outcomeLabel(outcome: MekCriticalRollOutcome): string { if (!outcome.applied) { + if (outcome.reason === 'non-explosive') { + const equipment = outcome.equipment ? `: ${outcome.equipment}` : ''; + return `Slot ${outcome.slotNumber}${equipment} is not explosive — critical discarded.`; + } const reason = outcome.reason === 'already-damaged' ? 'already damaged' : outcome.reason === 'unhittable' ? 'unhittable' : 'empty'; @@ -198,9 +599,96 @@ export class MekCriticalRollDialogComponent { return getMekLocationLabel(location) ?? location; } - close(): void { + slotNumber(slot: CriticalSlot): number { + return (slot.slot ?? 0) + 1; + } + + slotLabel(slot: CriticalSlot): string { + return slot.name?.trim() || slot.eq?.name || 'Equipment'; + } + + isHighlightedSlot(slot: CriticalSlot): boolean { + return this.highlightedSlotNumber() === this.slotNumber(slot); + } + + isSelectedSlot(slot: CriticalSlot): boolean { + return slot.slot !== undefined && this.selectedSlotIndex() === slot.slot; + } + + isDimmedSlot(slot: CriticalSlot): boolean { + const highlighted = this.highlightedSlotNumber(); + return highlighted !== null && highlighted !== this.slotNumber(slot); + } + + isCollapsedSlot(slot: CriticalSlot): boolean { + return this.selectedSlotIndex() !== null && !this.isSelectedSlot(slot); + } + + undoToChance(): void { + if (!this.canUndoToChance() || this.isAnyRolling() || this.resolving()) return; + this.dialogRef.close({ completed: false, undoToChance: true }); + } + + private clearPersistedRoll(): void { + this.data.unit.turnState().clearPendingCriticalRoll(this.data.pendingCriticalId); + } + + private resolvePersistedHit(): void { + this.data.unit.turnState().resolvePendingCriticalHit(this.data.pendingCriticalId); + } + + discardCurrentCritical(reason: 'case-ii' | 'non-explosive'): void { + if (this.isAnyRolling() || this.resolving() || this.complete()) return; + if (!this.data.unit.turnState().resolvePendingCriticalHit(this.data.pendingCriticalId)) return; + this.pendingResults.set(null); + this.caseIICheckPassed.set(false); + this.caseIICheckResult.set(null); + this.currentDiscardReason.set(reason); + this.discardedHits.update(value => value + 1); + if (this.complete()) this.completeDialog(); + } + + private createSlotRows(slots: readonly CriticalSlot[]): readonly CriticalSlotRow[] { + const slotsByIndex = new Map(slots.flatMap(slot => + slot.slot === undefined ? [] : [[slot.slot, slot] as const])); + const slotCount = this.diceCount === 1 ? 6 : 12; + return Array.from({ length: slotCount }, (_, slotIndex) => { + const slot = slotsByIndex.get(slotIndex) ?? null; + return { + slotIndex, + slot, + // Color reflects why the underlying table slot is unavailable. + // Explosion-only filtering must not hide that a component was already destroyed. + destroyed: slot === null && mekCriticalSlotRollability( + this.data.unit, + this.targetLocation, + slotIndex, + ) === 'already-damaged', + }; + }); + } + + private lockManualSlots(): void { + if (this.lockedSlotRows() !== null) return; + this.lockedSlotRows.set(this.createSlotRows(this.availableSlots())); + } + + private unlockManualSlots(): void { + this.lockedSlotRows.set(null); + } + + close(interruptedForConsciousness = false): void { + if (this.roller()?.isRolling() || this.caseIIRoller()?.isRolling() || this.resolving()) return; + this.dialogRef.close({ + completed: this.complete(), + ...(interruptedForConsciousness ? { interruptedForConsciousness: true } : {}), + }); + } + + private completeDialog(interruptedForConsciousness = false): void { this.dialogRef.close({ - completed: this.data.requiredHits === undefined || this.complete(), + completed: true, + ...(interruptedForConsciousness ? { interruptedForConsciousness: true } : {}), }); } } From 00acb7a4bf4b50fb7c7e97114ceec5cf492c6fe5 Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 21 Aug 2026 22:42:27 +0200 Subject: [PATCH 13/87] critical chance/hit --- ...k-critical-chance-dialog.component.spec.ts | 201 +++++- .../mek-critical-chance-dialog.component.ts | 140 ++-- .../mek-critical-dialog.component.scss | 2 +- ...mek-critical-roll-dialog.component.spec.ts | 80 ++- .../mek-critical-roll-dialog.component.ts | 62 +- .../svg-interaction.service.spec.ts | 422 +++++++++++- .../page-viewer/svg-interaction.service.ts | 266 +++++--- src/app/models/crew-member.model.ts | 5 +- src/app/models/rules/aero-rules.spec.ts | 70 +- src/app/models/rules/aero-rules.ts | 66 +- src/app/models/rules/heat-management.spec.ts | 40 ++ src/app/models/rules/heat-management.ts | 61 +- src/app/models/rules/mek-rules.spec.ts | 2 + src/app/models/rules/mek-rules.ts | 631 ++++++++++++++---- src/app/services/unit-svg-mek.service.ts | 52 +- src/app/services/unit-svg.service.ts | 56 +- src/app/utils/heat-effects.util.spec.ts | 139 ++++ src/app/utils/heat-effects.util.ts | 181 +++++ src/app/utils/heat-summary.util.spec.ts | 26 + src/app/utils/heat-summary.util.ts | 47 ++ 20 files changed, 2126 insertions(+), 423 deletions(-) create mode 100644 src/app/models/rules/heat-management.spec.ts create mode 100644 src/app/utils/heat-effects.util.spec.ts create mode 100644 src/app/utils/heat-effects.util.ts create mode 100644 src/app/utils/heat-summary.util.spec.ts create mode 100644 src/app/utils/heat-summary.util.ts diff --git a/src/app/components/page-viewer/mek-critical-chance-dialog.component.spec.ts b/src/app/components/page-viewer/mek-critical-chance-dialog.component.spec.ts index a2e911e92..22c77e669 100644 --- a/src/app/components/page-viewer/mek-critical-chance-dialog.component.spec.ts +++ b/src/app/components/page-viewer/mek-critical-chance-dialog.component.spec.ts @@ -16,7 +16,7 @@ describe('MekCriticalChanceDialogComponent', () => { { provide: DialogRef, useValue: { close: jasmine.createSpy('close') } }, { provide: DIALOG_DATA, - useValue: { locationLabel: 'Left Torso', canBlowOff: false }, + useValue: { locationLabel: 'Left Torso', canBlowOff: false, industrialMek: false }, }, ], }).compileComponents(); @@ -30,49 +30,149 @@ describe('MekCriticalChanceDialogComponent', () => { jasmine.clock().uninstall(); }); - it('reserves the full-row critical action while hiding it until there is a result to apply', () => { + it('offers direct tabletop results and replaces them with the rolled result', () => { const roller = fixture.componentInstance.roller()!; - const action = fixture.nativeElement.querySelector('.critical-action') as HTMLButtonElement; - - expect(action).not.toBeNull(); - expect(action.parentElement?.classList).toContain('critical-chance-actions'); - expect(action.disabled).toBeTrue(); - expect(getComputedStyle(action).visibility).toBe('hidden'); + const dialogRef = TestBed.inject(DialogRef) as unknown as { close: jasmine.Spy }; + const manualActions = fixture.nativeElement.querySelectorAll( + '.critical-chance-options:not(.rolled-result-action) .bt-button', + ) as NodeListOf; - roller.roll([1, 1]); - jasmine.clock().tick(500); - fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.critical-manual-results-label')?.textContent.trim()) + .toBe('Criticals'); + expect(Array.from(manualActions, button => button.textContent?.trim())) + .toEqual(['NO CRITICAL', '1', '2', '3']); + expect(fixture.nativeElement.querySelector('.critical-result-action')).toBeNull(); - expect(action.disabled).toBeTrue(); - expect(getComputedStyle(action).visibility).toBe('hidden'); + manualActions[2].click(); + expect(dialogRef.close).toHaveBeenCalledOnceWith({ kind: 'critical-hits', count: 2 }); roller.roll([4, 4]); jasmine.clock().tick(500); fixture.detectChanges(); - expect(action.disabled).toBeFalse(); - expect(getComputedStyle(action).visibility).toBe('visible'); + const rolledAction = fixture.nativeElement.querySelector('.critical-result-action') as HTMLButtonElement; + expect(rolledAction.textContent).toContain('APPLY 1 CRITICAL'); + expect(fixture.nativeElement.querySelector( + '.critical-chance-options:not(.rolled-result-action)', + )).toBeNull(); }); - it('keeps the result area height stable when replacing the roll table with a result', () => { + it('keeps the critical table visible while the action changes to the rolled effect', () => { const roller = fixture.componentInstance.roller()!; const slot = fixture.nativeElement.querySelector('.critical-result-slot') as HTMLElement; - const result = slot.querySelector('.critical-result') as HTMLElement; const hint = slot.querySelector('.critical-table-hint') as HTMLElement; const initialHeight = slot.getBoundingClientRect().height; - expect(getComputedStyle(result).visibility).toBe('hidden'); expect(getComputedStyle(hint).visibility).toBe('visible'); roller.roll([4, 4]); jasmine.clock().tick(500); fixture.detectChanges(); - expect(getComputedStyle(result).visibility).toBe('visible'); - expect(getComputedStyle(hint).visibility).toBe('hidden'); + expect(getComputedStyle(hint).visibility).toBe('visible'); + expect(fixture.nativeElement.querySelector('.critical-result-action')?.textContent) + .toContain('APPLY 1 CRITICAL'); expect(slot.getBoundingClientRect().height).toBe(initialHeight); }); + it('keeps the previous rolled action visible and disabled while rerolling', () => { + const roller = fixture.componentInstance.roller()!; + const dialogRef = TestBed.inject(DialogRef) as unknown as { close: jasmine.Spy }; + + roller.roll([4, 4]); + jasmine.clock().tick(500); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.critical-result-action')?.textContent) + .toContain('APPLY 1 CRITICAL'); + + fixture.componentInstance.roll(); + fixture.detectChanges(); + + const previousAction = fixture.nativeElement.querySelector( + '.critical-result-action', + ) as HTMLButtonElement; + expect(previousAction.textContent).toContain('APPLY 1 CRITICAL'); + expect(previousAction.disabled).toBeTrue(); + expect((fixture.nativeElement.querySelector('.random-button') as HTMLButtonElement).disabled) + .toBeTrue(); + expect(fixture.nativeElement.querySelector( + '.critical-chance-options:not(.rolled-result-action)', + )).toBeNull(); + previousAction.click(); + expect(dialogRef.close).not.toHaveBeenCalled(); + + jasmine.clock().tick(500); + }); + + it('cannot close while rolling and preserves the completed virtual result on close', () => { + const dialogRef = TestBed.inject(DialogRef) as unknown as { close: jasmine.Spy }; + const roller = fixture.componentInstance.roller()!; + + roller.roll([4, 4]); + fixture.componentInstance.close(); + expect(dialogRef.close).not.toHaveBeenCalled(); + + jasmine.clock().tick(500); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.critical-dismiss-actions > .bt-button:last-child') + ?.textContent.trim()).toBe('CLOSE'); + fixture.componentInstance.close(); + expect(dialogRef.close).toHaveBeenCalledOnceWith(undefined); + }); + + it('offers NO CRITICAL inline while CLOSE only dismisses the dialog', () => { + const dialogRef = TestBed.inject(DialogRef) as unknown as { close: jasmine.Spy }; + const manualActions = fixture.nativeElement.querySelectorAll( + '.critical-chance-options:not(.rolled-result-action) .bt-button', + ) as NodeListOf; + + manualActions[0].click(); + expect(dialogRef.close).toHaveBeenCalledOnceWith({ kind: 'none' }); + dialogRef.close.calls.reset(); + fixture.componentInstance.close(); + + expect(dialogRef.close).toHaveBeenCalledOnceWith(undefined); + }); + + it('labels dismissal as CANCEL for a transient manual chance', () => { + const dialogRef = TestBed.inject(DialogRef) as unknown as { close: jasmine.Spy }; + const data = fixture.componentInstance.data as { manual?: boolean }; + fixture.destroy(); + data.manual = true; + fixture = TestBed.createComponent(MekCriticalChanceDialogComponent); + fixture.detectChanges(); + + const cancel = fixture.nativeElement.querySelector( + '.critical-dismiss-actions > .bt-button:last-child', + ) as HTMLButtonElement; + expect(cancel.textContent.trim()).toBe('CANCEL'); + + cancel.click(); + expect(dialogRef.close).toHaveBeenCalledOnceWith(undefined); + }); + + it('keeps the manual 3 choice as three critical hits for a torso', () => { + const dialogRef = TestBed.inject(DialogRef) as unknown as { close: jasmine.Spy }; + const manualActions = fixture.nativeElement.querySelectorAll( + '.critical-chance-options:not(.rolled-result-action) .bt-button', + ) as NodeListOf; + + manualActions[3].click(); + + expect(dialogRef.close).toHaveBeenCalledOnceWith({ kind: 'critical-hits', count: 3 }); + }); + + it('starts the same roll from the random control or the dice', () => { + const roller = fixture.componentInstance.roller()!; + const roll = spyOn(roller, 'roll'); + const element = fixture.nativeElement as HTMLElement; + + element.querySelector('.random-button')!.click(); + element.querySelector('.critical-dice-trigger')!.click(); + + expect(roll).toHaveBeenCalledTimes(2); + }); + it('opts into Other modifiers above the dice and recomputes without rerolling', () => { const roller = fixture.componentInstance.roller()!; const modifiers = fixture.nativeElement.querySelector('.critical-roll-details') as HTMLElement; @@ -140,6 +240,7 @@ describe('MekCriticalChanceDialogComponent optional modifiers', () => { useValue: { locationLabel: 'Left Arm', canBlowOff: true, + industrialMek: false, modifiers: [{ label: 'Hardened armor in damaged facing', value: -2, @@ -191,4 +292,64 @@ describe('MekCriticalChanceDialogComponent optional modifiers', () => { expect(fixture.nativeElement.querySelector('dice-roller .modifier-value')).toBeNull(); expect(fixture.componentInstance.result()).toEqual({ kind: 'critical-hits', count: 1 }); }); + + it('maps the manual 3 choice to blown-off for locations that can blow off', () => { + const dialogRef = TestBed.inject(DialogRef) as unknown as { close: jasmine.Spy }; + const manualActions = fixture.nativeElement.querySelectorAll( + '.critical-chance-options:not(.rolled-result-action) .bt-button', + ) as NodeListOf; + + expect(Array.from(manualActions, button => button.textContent?.trim())) + .toEqual(['NO CRITICAL', '1', '2', 'BLOWN OFF']); + + manualActions[3].click(); + + expect(dialogRef.close).toHaveBeenCalledOnceWith({ kind: 'blown-off' }); + }); +}); + +describe('MekCriticalChanceDialogComponent IndustrialMech table', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MekCriticalChanceDialogComponent], + providers: [ + { provide: DialogRef, useValue: { close: jasmine.createSpy('close') } }, + { + provide: DIALOG_DATA, + useValue: { + locationLabel: 'Left Torso', + canBlowOff: false, + industrialMek: true, + modifiers: [{ label: 'IndustrialMech', value: 2 }], + }, + }, + ], + }).compileComponents(); + jasmine.clock().install(); + fixture = TestBed.createComponent(MekCriticalChanceDialogComponent); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + jasmine.clock().uninstall(); + }); + + it('keeps the four explicit manual choices and resolves a modified 14 without clamping to 12', () => { + const manualActions = fixture.nativeElement.querySelectorAll( + '.critical-chance-options:not(.rolled-result-action) .bt-button', + ) as NodeListOf; + expect(Array.from(manualActions, button => button.textContent?.trim())) + .toEqual(['NO CRITICAL', '1', '2', '3']); + + fixture.componentInstance.roller()!.roll([6, 6]); + jasmine.clock().tick(500); + fixture.detectChanges(); + + expect(fixture.componentInstance.result()).toEqual({ kind: 'critical-hits', count: 4 }); + expect(fixture.nativeElement.querySelector('.critical-result-action')?.textContent) + .toContain('APPLY 4 CRITICALS'); + }); }); diff --git a/src/app/components/page-viewer/mek-critical-chance-dialog.component.ts b/src/app/components/page-viewer/mek-critical-chance-dialog.component.ts index 2882e1b05..fc5f5ae9b 100644 --- a/src/app/components/page-viewer/mek-critical-chance-dialog.component.ts +++ b/src/app/components/page-viewer/mek-critical-chance-dialog.component.ts @@ -14,7 +14,11 @@ import { export interface MekCriticalChanceDialogData { readonly locationLabel: string; readonly canBlowOff: boolean; + readonly industrialMek: boolean; readonly modifiers?: readonly MekCriticalChanceModifier[]; + readonly initialResult?: MekCriticalChanceResult; + readonly onResultChange?: (result: MekCriticalChanceResult | null) => void; + readonly manual?: boolean; } @Component({ @@ -106,45 +110,68 @@ export interface MekCriticalChanceDialogData { - -
+
+
- @if (result(); as currentResult) { - {{ resultLabel(currentResult) }} - } @else { - No critical hits. - } +
-
- 2–7: No Critical | 8–9: 1 | 10–11: 2 | 12: {{ data.canBlowOff ? 'blow off' : '3' }} +
+
+
+ {{ criticalTableHint() }}
-
- - +
+ } @else { +
+
Criticals
+
+ @for (manualResult of manualResults; track manualResult.label) { + + } +
+
+ } + - `, @@ -157,25 +184,30 @@ export class MekCriticalChanceDialogComponent { private readonly dialogRef = inject(DialogRef); readonly data = inject(DIALOG_DATA); readonly roller = viewChild('roller'); - readonly result = signal(null); + readonly result = signal(this.data.initialResult ?? null); readonly modifiers = this.data.modifiers ?? []; readonly isRolling = computed(() => this.roller()?.isRolling() ?? false); - readonly canContinue = computed(() => { - const result = this.result(); - return !this.isRolling() && result !== null && result.kind !== 'none'; - }); private readonly optionalModifiers = signal(new Set( this.modifiers.filter(modifier => modifier.optional && modifier.enabled !== false) .map(modifier => modifier.label), )); readonly situationalModifierEnabled = signal(false); readonly situationalModifier = signal(0); + readonly manualResults: readonly { label: string; result: MekCriticalChanceResult }[] = [ + { label: 'NO CRITICAL', result: { kind: 'none' } }, + { label: '1', result: { kind: 'critical-hits', count: 1 } }, + { label: '2', result: { kind: 'critical-hits', count: 2 } }, + { + label: this.data.canBlowOff ? 'BLOWN OFF' : '3', + result: resolveMekCriticalChance(12, this.data.canBlowOff, this.data.industrialMek), + }, + ]; readonly modifierTotal = computed(() => this.modifiers.reduce((total, modifier) => total + (!modifier.optional || this.optionalModifiers().has(modifier.label) ? modifier.value : 0), this.situationalModifierEnabled() ? this.situationalModifier() : 0)); roll(): void { - this.result.set(null); + if (this.isRolling()) return; this.roller()?.roll(); } @@ -185,8 +217,8 @@ export class MekCriticalChanceDialogComponent { private resolveRoll(raw: number): void { const modifier = this.modifierTotal(); - const modified = Math.min(12, raw + modifier); - this.result.set(resolveMekCriticalChance(modified, this.data.canBlowOff)); + const modified = Math.min(this.data.industrialMek ? 14 : 12, raw + modifier); + this.setResult(resolveMekCriticalChance(modified, this.data.canBlowOff, this.data.industrialMek)); } optionalModifierEnabled(modifier: MekCriticalChanceModifier): boolean { @@ -217,7 +249,7 @@ export class MekCriticalChanceDialogComponent { const roller = this.roller(); const results = roller?.diceResults(); if (!roller?.rollFinished() || !results || results.some(value => value === null)) { - this.result.set(null); + this.setResult(null); return; } this.resolveRoll(results.reduce((total, die) => total + (die ?? 0), 0)); @@ -227,29 +259,33 @@ export class MekCriticalChanceDialogComponent { return value >= 0 ? `+${value}` : String(value); } - resultLabel(result: MekCriticalChanceResult): string { - if (result.kind === 'none') return 'No critical hits.'; - if (result.kind === 'blown-off') return 'Location blown off!'; - return `${result.count} critical hit${result.count === 1 ? '' : 's'}.`; - } - continueLabel(result: MekCriticalChanceResult): string { - if (result.kind === 'none') return ''; + if (result.kind === 'none') return 'NO CRITICAL HITS'; if (result.kind === 'blown-off') return 'APPLY BLOWN-OFF'; return `APPLY ${result.count} CRITICAL${result.count === 1 ? '' : 'S'}`; } - continueWithCurrentResult(): void { - const result = this.result(); - if (!result || result.kind === 'none') return; - this.continueWith(result); + criticalTableHint(): string { + const twelve = this.data.canBlowOff ? 'blow off' : '3'; + if (!this.data.industrialMek) { + return `2–7: No Critical | 8–9: 1 | 10–11: 2 | 12: ${twelve}`; + } + const fourteen = this.data.canBlowOff ? 'blow off' : '4'; + return `2–7: No Critical | 8–9: 1 | 10–11: 2 | 12–13: ${twelve} | 14+: ${fourteen}`; } continueWith(result: MekCriticalChanceResult): void { + this.setResult(result); this.dialogRef.close(result); } close(): void { - this.dialogRef.close(); + if (this.isRolling()) return; + this.dialogRef.close(undefined); + } + + private setResult(result: MekCriticalChanceResult | null): void { + this.result.set(result); + this.data.onResultChange?.(result); } } diff --git a/src/app/components/page-viewer/mek-critical-dialog.component.scss b/src/app/components/page-viewer/mek-critical-dialog.component.scss index 7e589f449..6dd343c07 100644 --- a/src/app/components/page-viewer/mek-critical-dialog.component.scss +++ b/src/app/components/page-viewer/mek-critical-dialog.component.scss @@ -44,7 +44,7 @@ } .critical-table-hint { - color: var(--text-color-tertiary); + color: var(--text-color-secondary); font-size: 0.85em; } diff --git a/src/app/components/page-viewer/mek-critical-roll-dialog.component.spec.ts b/src/app/components/page-viewer/mek-critical-roll-dialog.component.spec.ts index 99d9d499e..911cab76b 100644 --- a/src/app/components/page-viewer/mek-critical-roll-dialog.component.spec.ts +++ b/src/app/components/page-viewer/mek-critical-roll-dialog.component.spec.ts @@ -25,7 +25,8 @@ describe('MekCriticalRollDialogComponent', () => { requiredHits: number; consolidateImmediately: boolean; locationDestroyed?: boolean; - pendingCriticalId: string; + pendingCriticalId?: string; + manual?: boolean; caseIICheckRequired?: boolean; caseIICheckPassed?: boolean; caseIICheckResult?: 'resolve' | 'discard'; @@ -156,18 +157,71 @@ describe('MekCriticalRollDialogComponent', () => { expect(roll).toHaveBeenCalledOnceWith([1, 1]); }); - it('keeps a stable three-button footer before a hit is selected', () => { + it('omits sequence UNDO when a queued critical has no chance step to return to', () => { const actions = fixture.nativeElement.querySelectorAll( '.actions .bt-button', ) as NodeListOf; - expect(actions).toHaveSize(3); - expect(actions[0].textContent).toContain('APPLY'); + expect(Array.from(actions, action => action.textContent?.trim())) + .toEqual(['APPLY', 'CLOSE']); expect(actions[0].disabled).toBeTrue(); - expect(actions[1].textContent).toContain('UNDO'); - expect(actions[1].disabled).toBeTrue(); - expect(actions[2].textContent).toContain('CANCEL'); - expect(actions[2].disabled).toBeFalse(); + expect(actions[1].disabled).toBeFalse(); + expect(fixture.nativeElement.querySelector('.critical-sequence-undo')).toBeNull(); + }); + + it('uses CANCEL for a transient manual critical without touching pending events', () => { + fixture.destroy(); + dialogData.manual = true; + dialogData.pendingCriticalId = undefined; + fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture.detectChanges(); + + const actions = fixture.nativeElement.querySelectorAll( + '.actions .bt-button', + ) as NodeListOf; + + expect(Array.from(actions, action => action.textContent?.trim())) + .toEqual(['APPLY', 'CANCEL']); + expect(fixture.nativeElement.querySelector('.critical-sequence-undo')).toBeNull(); + actions[1].click(); + + expect(setPendingCriticalRoll).not.toHaveBeenCalled(); + expect(clearPendingCriticalRoll).not.toHaveBeenCalled(); + expect(resolvePendingCriticalHit).not.toHaveBeenCalled(); + expect(discardPendingCriticalHits).not.toHaveBeenCalled(); + expect(dialogRef.close).toHaveBeenCalledOnceWith({ completed: false }); + }); + + it('applies a transient manual critical without creating or resolving pending work', async () => { + fixture.destroy(); + dialogData.manual = true; + dialogData.pendingCriticalId = undefined; + applyRoll.and.resolveTo({ + cancelled: false, + outcome: { + applied: true, + slotNumber: 1, + equipment: 'Medium Laser', + armoredAbsorption: false, + }, + }); + fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture.detectChanges(); + + fixture.componentInstance.onFinished({ results: [1, 1] }); + fixture.componentInstance.primaryAction(); + await fixture.whenStable(); + + expect(applyRoll).toHaveBeenCalledOnceWith( + dialogData.unit, + 'LT', + [1, 1], + true, + jasmine.any(Object), + ); + expect(setPendingCriticalRoll).not.toHaveBeenCalled(); + expect(resolvePendingCriticalHit).not.toHaveBeenCalled(); + expect(dialogRef.close).toHaveBeenCalledOnceWith({ completed: true }); }); it('previews explosion damage as soon as a hit target is selected and removes it on local UNDO', () => { @@ -606,22 +660,26 @@ describe('MekCriticalRollDialogComponent', () => { .toContain('rolled hit has not been applied'); }); - it('offers sequence UNDO only before the first critical is committed', () => { + it('offers in-memory sequence UNDO only before the first manual critical is committed', () => { fixture.destroy(); + dialogData.manual = true; + dialogData.pendingCriticalId = undefined; dialogData.canUndoToChance = true; fixture = TestBed.createComponent(MekCriticalRollDialogComponent); fixture.detectChanges(); const undo = fixture.nativeElement.querySelector('.critical-sequence-undo') as HTMLButtonElement; expect(undo.textContent).toContain('UNDO'); + expect(undo.classList).not.toContain('danger'); undo.click(); expect(dialogRef.close).toHaveBeenCalledOnceWith({ completed: false, undoToChance: true }); + expect(resolvePendingCriticalHit).not.toHaveBeenCalled(); + expect(discardPendingCriticalHits).not.toHaveBeenCalled(); fixture.componentInstance.appliedHits.set(1); fixture.detectChanges(); - expect((fixture.nativeElement.querySelector('.critical-sequence-undo') as HTMLButtonElement).disabled) - .toBeTrue(); + expect(fixture.nativeElement.querySelector('.critical-sequence-undo')).toBeNull(); }); it('discards remaining criticals and dismisses when no valid slot remains', () => { diff --git a/src/app/components/page-viewer/mek-critical-roll-dialog.component.ts b/src/app/components/page-viewer/mek-critical-roll-dialog.component.ts index aca91e647..df2776c8f 100644 --- a/src/app/components/page-viewer/mek-critical-roll-dialog.component.ts +++ b/src/app/components/page-viewer/mek-critical-roll-dialog.component.ts @@ -31,7 +31,8 @@ export interface MekCriticalRollDialogData { readonly requiredHits: number; readonly locationDestroyed?: boolean; readonly consolidateImmediately: boolean; - readonly pendingCriticalId: string; + readonly pendingCriticalId?: string; + readonly manual?: boolean; readonly caseIICheckRequired?: boolean; readonly caseIICheckPassed?: boolean; readonly caseIICheckResult?: 'resolve' | 'discard'; @@ -42,6 +43,7 @@ export interface MekCriticalRollDialogData { export interface MekCriticalRollDialogResult { readonly completed: boolean; readonly interruptedForConsciousness?: boolean; + readonly remainingHits?: number; readonly undoToChance?: true; } @@ -271,11 +273,13 @@ interface CriticalExplosionDisplay { type="button" [disabled]="!canUsePrimary()" (click)="primaryAction()">{{ primaryLabel() }} - + @if (canUndoToChance()) { + + } @@ -313,8 +317,9 @@ export class MekCriticalRollDialogComponent { readonly caseIICheckPassed = signal(this.data.caseIICheckPassed ?? false); readonly caseIICheckResult = signal<'resolve' | 'discard' | null>(this.data.caseIICheckResult ?? null); readonly currentDiscardReason = signal<'case-ii' | 'non-explosive' | null>(null); - private readonly restoredRoll = this.data.unit.turnState() - .getPendingCriticalHit(this.data.pendingCriticalId)?.roll; + private readonly restoredRoll = this.data.pendingCriticalId + ? this.data.unit.turnState().getPendingCriticalHit(this.data.pendingCriticalId)?.roll + : undefined; readonly selectedSlotIndex = signal(this.restoredRoll ? mekCriticalSlotIndexForRoll(this.targetLocation, this.restoredRoll) : null); @@ -452,10 +457,8 @@ export class MekCriticalRollDialogComponent { ? 'discard' : 'resolve'; this.caseIICheckResult.set(result); - this.data.unit.turnState().setPendingCriticalCaseIICheckResult( - this.data.pendingCriticalId, - result, - ); + const pendingId = this.data.pendingCriticalId; + if (pendingId) this.data.unit.turnState().setPendingCriticalCaseIICheckResult(pendingId, result); } applyCaseIICheck(result: 'resolve' | 'discard'): void { @@ -464,7 +467,8 @@ export class MekCriticalRollDialogComponent { this.discardCurrentCritical('case-ii'); return; } - if (!this.data.unit.turnState().passPendingCriticalCaseIICheck(this.data.pendingCriticalId)) return; + const pendingId = this.data.pendingCriticalId; + if (pendingId && !this.data.unit.turnState().passPendingCriticalCaseIICheck(pendingId)) return; this.caseIICheckPassed.set(true); this.caseIICheckResult.set(null); } @@ -482,7 +486,7 @@ export class MekCriticalRollDialogComponent { if (this.resolving() || this.complete() || this.pendingResults() || this.outcome()) return; const slotIndex = mekCriticalSlotIndexForRoll(this.targetLocation, results); if (slotIndex === null) return; - if (!this.data.unit.turnState().setPendingCriticalRoll(this.data.pendingCriticalId, results)) return; + if (!this.persistRoll(results)) return; this.lockManualSlots(); this.pendingResults.set([...results]); this.selectedSlotIndex.set(slotIndex); @@ -492,7 +496,7 @@ export class MekCriticalRollDialogComponent { undoSlotSelection(): void { if (this.selectedSlotIndex() === null || !this.pendingResults() || this.isAnyRolling() || this.resolving()) return; - if (!this.data.unit.turnState().clearPendingCriticalRoll(this.data.pendingCriticalId)) return; + if (!this.clearPersistedRoll()) return; this.pendingResults.set(null); this.selectedSlotIndex.set(null); this.automationCancelled.set(false); @@ -566,7 +570,7 @@ export class MekCriticalRollDialogComponent { return; } if (!this.hasRollableSlot()) { - this.data.unit.turnState().discardPendingCriticalHits(this.data.pendingCriticalId); + this.discardPersistedHits(); this.completeDialog(); return; } @@ -629,17 +633,33 @@ export class MekCriticalRollDialogComponent { this.dialogRef.close({ completed: false, undoToChance: true }); } - private clearPersistedRoll(): void { - this.data.unit.turnState().clearPendingCriticalRoll(this.data.pendingCriticalId); + private persistRoll(results: readonly number[]): boolean { + const pendingId = this.data.pendingCriticalId; + return pendingId === undefined + || this.data.unit.turnState().setPendingCriticalRoll(pendingId, results); + } + + private clearPersistedRoll(): boolean { + const pendingId = this.data.pendingCriticalId; + return pendingId === undefined + || this.data.unit.turnState().clearPendingCriticalRoll(pendingId); + } + + private resolvePersistedHit(): boolean { + const pendingId = this.data.pendingCriticalId; + return pendingId === undefined + || this.data.unit.turnState().resolvePendingCriticalHit(pendingId); } - private resolvePersistedHit(): void { - this.data.unit.turnState().resolvePendingCriticalHit(this.data.pendingCriticalId); + private discardPersistedHits(): boolean { + const pendingId = this.data.pendingCriticalId; + return pendingId === undefined + || this.data.unit.turnState().discardPendingCriticalHits(pendingId); } discardCurrentCritical(reason: 'case-ii' | 'non-explosive'): void { if (this.isAnyRolling() || this.resolving() || this.complete()) return; - if (!this.data.unit.turnState().resolvePendingCriticalHit(this.data.pendingCriticalId)) return; + if (!this.resolvePersistedHit()) return; this.pendingResults.set(null); this.caseIICheckPassed.set(false); this.caseIICheckResult.set(null); @@ -679,9 +699,11 @@ export class MekCriticalRollDialogComponent { close(interruptedForConsciousness = false): void { if (this.roller()?.isRolling() || this.caseIIRoller()?.isRolling() || this.resolving()) return; + const remainingHits = Math.max(0, this.data.requiredHits - this.resolvedHits()); this.dialogRef.close({ completed: this.complete(), ...(interruptedForConsciousness ? { interruptedForConsciousness: true } : {}), + ...(interruptedForConsciousness && remainingHits > 0 ? { remainingHits } : {}), }); } 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 4f95ca289..24a39e540 100644 --- a/src/app/components/page-viewer/svg-interaction.service.spec.ts +++ b/src/app/components/page-viewer/svg-interaction.service.spec.ts @@ -4,6 +4,7 @@ import { Injector } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { Subject } from 'rxjs'; import { DataService } from '../../services/data.service'; import { EquipmentRegistry } from '../../models/equipment-lookup'; @@ -24,9 +25,12 @@ import { SvgInteractionService } from './svg-interaction.service'; import type { ZoomPanServiceInterface } from './zoom-pan.interface'; import { PageViewerStateService } from './internal/page-viewer-state.service'; import { CORE_2026_GAME_RULES, TW_GAME_RULES } from '../../models/rules/game-rules'; -import type { EquipmentAction } from '../../models/cbt-force-unit.model'; -import { MekCriticalChanceDialogComponent } from './mek-critical-chance-dialog.component'; -import { MekCriticalRollDialogComponent } from './mek-critical-roll-dialog.component'; +import type { CBTUnitAutomationTrigger, EquipmentAction } from '../../models/cbt-force-unit.model'; +import { CBTAutomationService } from '../../services/cbt-automation.service'; +import { MekCriticalHitAutomationService } from '../../services/mek-critical-hit-automation.service'; +import { MekCriticalResolutionService } from '../../services/mek-critical-resolution.service'; +import { UnitCheckResolutionService } from '../../services/unit-check-resolution.service'; +import { FallingResolutionService } from '../../services/falling-resolution.service'; type SvgInteractionServicePrivate = { addSvgTapHandler( @@ -43,8 +47,9 @@ type SvgInteractionServicePrivate = { updateHeatHighlight(heatValue: number): void; locationConditionDropdownChoices(unit: any, loc: string): Array<{ key: string; action?: boolean; isBreak?: boolean }>; setupLocationConditionInteractions(svg: SVGSVGElement, signal: AbortSignal): void; - openMekCriticalChanceDialog(unit: any, location: string): void; - openMekCriticalRollDialog(unit: any, location: string, requiredHits?: number): void; + openMekCriticalChanceDialog(unit: any, location: string): Promise; + openMekCriticalRollDialog(unit: any, location: string): Promise; + automationQueue: Promise; }; const NO_CONDITION_RULES = { @@ -69,9 +74,13 @@ function createSvgInteractionUnit(overrides: T): T & { getInve armorType: 'Standard', }), getInventory: () => [], + getCritSlot: () => null, getEquipmentStatus: () => 'available', isEquipmentOperational: () => true, canPerformEquipmentAction: () => true, + getNotificationDisplayName: () => 'Test Unit', + applyUnderwaterBreachAndFlooding: () => undefined, + automationTriggers: new Subject(), rules: NO_CONDITION_RULES, ...overrides, } as T & { @@ -96,6 +105,13 @@ describe('SvgInteractionService', () => { let options: { pickerStyle: 'default' | 'linear' | 'radial'; colorScheme: 'default' | 'night'; trackPhaseAndTurn: boolean }; let registryGetChoices: jasmine.Spy; let registryHandleSelection: jasmine.Spy; + let automationResolve: jasmine.Spy; + let criticalApplySlot: jasmine.Spy; + let criticalOpenManualChance: jasmine.Spy; + let criticalChanceResume: jasmine.Spy; + let criticalOpenManual: jasmine.Spy; + let openUnitChecks: jasmine.Spy; + let openFalling: jasmine.Spy; beforeEach(() => { zoomPanService = { @@ -126,6 +142,31 @@ describe('SvgInteractionService', () => { }; registryGetChoices = jasmine.createSpy('getChoices').and.returnValue([]); registryHandleSelection = jasmine.createSpy('handleSelection').and.returnValue(false); + automationResolve = jasmine.createSpy('resolve').and.callFake( + (_key: string, events: Array<{ id: string }>) => + Promise.resolve(new Set(events.map(event => event.id))), + ); + criticalApplySlot = jasmine.createSpy('applySlot').and.callFake(( + unit: { applyHitToCritSlot: (slot: unknown, hits: number, consolidateImmediately: boolean) => void }, + slot: { slot?: number; name?: string }, + consolidateImmediately: boolean, + ) => { + unit.applyHitToCritSlot(slot, 1, consolidateImmediately); + return Promise.resolve({ + cancelled: false, + outcome: { + applied: true, + slotNumber: (slot.slot ?? 0) + 1, + equipment: slot.name ?? null, + armoredAbsorption: false, + }, + }); + }); + criticalOpenManualChance = jasmine.createSpy('openManualChance').and.resolveTo(); + criticalChanceResume = jasmine.createSpy('resumeChance').and.resolveTo(); + criticalOpenManual = jasmine.createSpy('openManual').and.resolveTo(); + openUnitChecks = jasmine.createSpy('open').and.resolveTo(); + openFalling = jasmine.createSpy('open').and.resolveTo(); options = { pickerStyle: 'default', colorScheme: 'default', @@ -161,6 +202,24 @@ describe('SvgInteractionService', () => { provide: PickerFactoryService, useValue: pickerFactory }, + { + provide: CBTAutomationService, + useValue: { resolve: automationResolve }, + }, + { + provide: MekCriticalHitAutomationService, + useValue: { applySlot: criticalApplySlot }, + }, + { + provide: MekCriticalResolutionService, + useValue: { + openManualChance: criticalOpenManualChance, + resumeChance: criticalChanceResume, + openManual: criticalOpenManual, + }, + }, + { provide: UnitCheckResolutionService, useValue: { open: openUnitChecks } }, + { provide: FallingResolutionService, useValue: { open: openFalling } }, { provide: ToastService, useValue: { showToast: jasmine.createSpy('showToast') } } ] }); @@ -310,49 +369,215 @@ describe('SvgInteractionService', () => { expect(torsoChoices.filter(choice => choice.isBreak).length).toBe(1); }); - it('hands critical chance hits to a guided critical roll dialog', () => { + it('opens a manual critical chance without queueing it', () => { const unit = createSvgInteractionUnit({}); service.openMekCriticalChanceDialog(unit, 'LA'); - expect(dialogsService.createDialog.calls.mostRecent().args[0]).toBe(MekCriticalChanceDialogComponent); + expect(criticalOpenManualChance).toHaveBeenCalledOnceWith(unit, 'LA', false); + }); - dialogClosedCallbacks[0]({ kind: 'critical-hits', count: 2 }); + it('opens a manual critical roll without queueing it', () => { + const unit = createSvgInteractionUnit({}); + + service.openMekCriticalRollDialog(unit, 'LT'); + expect(criticalOpenManual).toHaveBeenCalledOnceWith(unit, 'LT', false); + }); - expect(dialogsService.createDialog.calls.mostRecent().args[0]).toBe(MekCriticalRollDialogComponent); - expect(dialogsService.createDialog.calls.mostRecent().args[1].data).toEqual(jasmine.objectContaining({ - unit, - location: 'LA', - requiredHits: 2, + it('serializes rapid already-persisted critical chance workflows', async () => { + const chanceResolvers: Array<() => void> = []; + criticalChanceResume.and.callFake(() => new Promise(resolve => chanceResolvers.push(resolve))); + const automationTriggers = new Subject(); + const unit = createSvgInteractionUnit({ + id: 'unit-a', + automationTriggers, + getNotificationDisplayName: () => 'Archer ARC-2D', + }); + service.updateUnit(unit); + + automationTriggers.next({ + kind: 'critical-hit-chance', + id: 'critical:1', + }); + automationTriggers.next({ + kind: 'critical-hit-chance', + id: 'critical:2', + }); + await settlePromises(); + + expect(criticalChanceResume.calls.allArgs().map(args => args[1])).toEqual(['critical:1']); + + chanceResolvers[0](); + await settlePromises(); + expect(criticalChanceResume.calls.allArgs().map(args => args[1])).toEqual([ + 'critical:1', + 'critical:2', + ]); + + chanceResolvers[1](); + await service.automationQueue; + }); + + it('opens a newly actionable consciousness check from the unit automation queue', async () => { + const automationTriggers = new Subject(); + const unit = createSvgInteractionUnit({ + id: 'unit-a', + automationTriggers, + }); + service.updateUnit(unit); + + automationTriggers.next({ kind: 'pending-unit-check' }); + await service.automationQueue; + + expect(openUnitChecks).toHaveBeenCalledOnceWith([unit]); + }); + + it('opens seatbelt work only after falling resolution releases it', async () => { + let finishFalling!: () => void; + const automationTriggers = new Subject(); + openFalling.and.callFake(() => new Promise(resolve => { + finishFalling = () => { + automationTriggers.next({ kind: 'pending-unit-check' }); + resolve(); + }; })); + const unit = createSvgInteractionUnit({ + id: 'unit-a', + automationTriggers, + }); + service.updateUnit(unit); + + automationTriggers.next({ + kind: 'falling', + id: 'fall:1', + source: 'stand-attempt', + levelsFallen: 0, + }); + await settlePromises(); + + expect(openFalling).toHaveBeenCalledOnceWith(unit, jasmine.objectContaining({ id: 'fall:1' }), false); + expect(openUnitChecks).not.toHaveBeenCalled(); + + finishFalling(); + await service.automationQueue; + + expect(openUnitChecks).toHaveBeenCalledOnceWith([unit]); }); - it('applies a blow-off result without opening the critical roll dialog', () => { - const setLocationCondition = jasmine.createSpy('setLocationCondition'); - const unit = createSvgInteractionUnit({ setLocationCondition }); + it('waits for the current workflow before processing an automation emitted from inside it', async () => { + let finishFirst!: () => void; + const automationTriggers = new Subject(); + const unit = createSvgInteractionUnit({ + id: 'unit-a', + automationTriggers, + getNotificationDisplayName: () => 'Archer ARC-2D', + }); + const secondTrigger: CBTUnitAutomationTrigger = { + kind: 'critical-hit-chance', + id: 'critical:nested', + }; + criticalChanceResume.and.callFake((_unit, id: string) => { + if (id !== 'critical:parent') return Promise.resolve(); + automationTriggers.next(secondTrigger); + return new Promise(resolve => { finishFirst = resolve; }); + }); + service.updateUnit(unit); + + automationTriggers.next({ + kind: 'critical-hit-chance', + id: 'critical:parent', + }); + await settlePromises(); - service.openMekCriticalChanceDialog(unit, 'HD'); - dialogClosedCallbacks[0]({ kind: 'blown-off' }); + expect(criticalChanceResume.calls.allArgs().map(args => args[1])).toEqual(['critical:parent']); - expect(setLocationCondition).toHaveBeenCalledOnceWith('HD', 'blown-off', true, false); - expect(dialogsService.createDialog).toHaveBeenCalledTimes(1); + finishFirst(); + await service.automationQueue; + expect(criticalChanceResume.calls.allArgs().map(args => args[1])).toEqual([ + 'critical:parent', + 'critical:nested', + ]); }); - it('lets an intact armored shoulder absorb a limb blow-off result', () => { - const shoulder = { id: 'shoulder@LA', name: 'Shoulder', loc: 'LA', slot: 0, armored: true, hits: 0 }; - const applyHitToCritSlot = jasmine.createSpy('applyHitToCritSlot'); + it('applies only accepted locations from a breach and flood review', async () => { + const automationTriggers = new Subject(); const setLocationCondition = jasmine.createSpy('setLocationCondition'); const unit = createSvgInteractionUnit({ - getCritSlots: () => [shoulder], - applyHitToCritSlot, + id: 'unit-a', + automationTriggers, + getNotificationDisplayName: () => 'Archer ARC-2D', setLocationCondition, }); + automationResolve.and.resolveTo(new Set(['flood:1:LL'])); + service.updateUnit(unit); - service.openMekCriticalChanceDialog(unit, 'LA'); - dialogClosedCallbacks[0]({ kind: 'blown-off' }); + automationTriggers.next({ + kind: 'breach-and-flood', + id: 'flood:1', + locations: ['LL', 'RL'], + commit: true, + }); + await service.automationQueue; + + expect(automationResolve).toHaveBeenCalledWith( + 'breachAndFlood', + [ + jasmine.objectContaining({ id: 'flood:1:LL', event: 'Breach and flood' }), + jasmine.objectContaining({ id: 'flood:1:RL', event: 'Breach and flood' }), + ], + jasmine.any(Object), + ); + expect(setLocationCondition).toHaveBeenCalledOnceWith('LL', 'flooded', true, true); + }); - expect(applyHitToCritSlot).toHaveBeenCalledOnceWith(shoulder, 1, false); - expect(setLocationCondition).not.toHaveBeenCalled(); - expect(dialogsService.createDialog).toHaveBeenCalledTimes(1); + it('returns cancelled flood locations to the eligible pool instead of losing them', async () => { + const automationTriggers = new Subject(); + const deferUnderwaterBreachAndFloodingReview = jasmine.createSpy( + 'deferUnderwaterBreachAndFloodingReview', + ); + const unit = createSvgInteractionUnit({ + id: 'unit-a', + automationTriggers, + getNotificationDisplayName: () => 'Archer ARC-2D', + deferUnderwaterBreachAndFloodingReview, + }); + automationResolve.and.resolveTo(null); + service.updateUnit(unit); + + automationTriggers.next({ + kind: 'breach-and-flood', + id: 'flood:1', + locations: ['LL', 'RL'], + commit: true, + }); + await service.automationQueue; + + expect(deferUnderwaterBreachAndFloodingReview).toHaveBeenCalledOnceWith(['LL', 'RL']); + }); + + it('returns flood locations to the eligible pool if the review fails', async () => { + const automationTriggers = new Subject(); + const deferUnderwaterBreachAndFloodingReview = jasmine.createSpy( + 'deferUnderwaterBreachAndFloodingReview', + ); + const unit = createSvgInteractionUnit({ + automationTriggers, + getNotificationDisplayName: () => 'Archer ARC-2D', + deferUnderwaterBreachAndFloodingReview, + }); + automationResolve.and.rejectWith(new Error('dialog failed')); + const errorLog = spyOn(console, 'error'); + service.updateUnit(unit); + + automationTriggers.next({ + kind: 'breach-and-flood', + id: 'flood:1', + locations: ['LL', 'RL'], + commit: true, + }); + await service.automationQueue; + + expect(deferUnderwaterBreachAndFloodingReview).toHaveBeenCalledOnceWith(['LL', 'RL']); + expect(errorLog).toHaveBeenCalled(); }); it('binds location condition interactions to enlarged controls rather than label text', () => { @@ -585,7 +810,7 @@ describe('SvgInteractionService', () => { ammoProfile.setAttribute('id', 'ammoProfile'); svg.appendChild(ammoProfile); const otherUnit = { id: 'unit-a', readOnly: () => false }; - const unit = { id: 'unit-b', readOnly: () => false }; + const unit = createSvgInteractionUnit({ id: 'unit-b', readOnly: () => false }); pageViewerState.setForceUnits([otherUnit as any, unit as any]); service.updateUnit(unit); service.setupReadOnlyInteractions(svg); @@ -1144,7 +1369,101 @@ describe('SvgInteractionService', () => { pickerConfig.onPick({ value: 27 }); expect(unit.addArmorHits).toHaveBeenCalledWith('LT', 15, false, false); - expect(unit.addInternalHits).toHaveBeenCalledWith('LT', 12, false); + expect(unit.addInternalHits).toHaveBeenCalledWith('LT', 12, false, { + hardenedArmorApplies: true, + }); + }); + + it('applies an accepted head-hit pilot injury once, including armor overflow', async () => { + const { svg, location, unit } = createArmorInteractionUnit({ + location: 'HD', + armorPoints: 9, + armorHits: 7, + internalPoints: 3, + internalHits: 0, + }); + service.updateUnit(unit); + service.setupInteractions(svg); + + tap(location, 711); + pickerFactory.createNumericPicker.calls.mostRecent().args[0].onPick({ value: 4 }); + await service.automationQueue; + + expect(automationResolve).toHaveBeenCalledOnceWith( + 'pilotHitsAndConsciousness', + [jasmine.objectContaining({ + subject: 'Test Unit', + event: 'Head hit', + description: 'Apply the resulting pilot hit', + })], + { + title: 'Review Pilot Hit', + message: 'Choose whether to apply the pilot hit caused by this head hit. Cancel leaves the head damage unapplied.', + }, + ); + expect(unit.applyHeadHitPilotHits).toHaveBeenCalledTimes(1); + expect(unit.addArmorHits).toHaveBeenCalledWith('HD', 2, false, false); + expect(unit.addInternalHits).toHaveBeenCalledWith('HD', 2, false, { + hardenedArmorApplies: true, + }); + }); + + it('applies rejected head damage without applying its pilot hit', async () => { + automationResolve.and.resolveTo(new Set()); + const { svg, location, unit } = createArmorInteractionUnit({ + location: 'HD', + armorPoints: 9, + armorHits: 0, + internalPoints: 3, + internalHits: 0, + }); + service.updateUnit(unit); + service.setupInteractions(svg); + + tap(location, 713); + pickerFactory.createNumericPicker.calls.mostRecent().args[0].onPick({ value: 2 }); + await service.automationQueue; + + expect(unit.applyHeadHitPilotHits).not.toHaveBeenCalled(); + expect(unit.addArmorHits).toHaveBeenCalledOnceWith('HD', 2, false, false); + }); + + it('leaves both head damage and its pilot hit unapplied when the review is cancelled', async () => { + automationResolve.and.resolveTo(null); + const { svg, location, unit } = createArmorInteractionUnit({ + location: 'HD', + armorPoints: 9, + armorHits: 0, + internalPoints: 3, + internalHits: 0, + }); + service.updateUnit(unit); + service.setupInteractions(svg); + + tap(location, 714); + pickerFactory.createNumericPicker.calls.mostRecent().args[0].onPick({ value: 2 }); + await service.automationQueue; + + expect(unit.applyHeadHitPilotHits).not.toHaveBeenCalled(); + expect(unit.addArmorHits).not.toHaveBeenCalled(); + expect(unit.addInternalHits).not.toHaveBeenCalled(); + }); + + it('does not apply a head-hit pilot injury while repairing head damage', () => { + const { svg, location, unit } = createArmorInteractionUnit({ + location: 'HD', + armorPoints: 9, + armorHits: 4, + internalPoints: 3, + internalHits: 0, + }); + service.updateUnit(unit); + service.setupInteractions(svg); + + tap(location, 712); + pickerFactory.createNumericPicker.calls.mostRecent().args[0].onPick({ value: -4 }); + + expect(unit.applyHeadHitPilotHits).not.toHaveBeenCalled(); }); it('does not pass armor repairs backward into structure', () => { @@ -1181,7 +1500,27 @@ describe('SvgInteractionService', () => { pickerConfig.onPick({ value: 4 }); expect(unit.addArmorHits).toHaveBeenCalledWith('LT', 2, true, false); - expect(unit.addInternalHits).toHaveBeenCalledWith('LT', 2, false); + expect(unit.addInternalHits).toHaveBeenCalledWith('LT', 2, false, { + hardenedArmorApplies: true, + }); + }); + + it('records that Hardened Armor did not apply when an already-open facing takes structure damage', () => { + const { svg, location, unit } = createArmorInteractionUnit({ + armorPoints: 10, + armorHits: 10, + internalPoints: 10, + internalHits: 0, + }); + service.updateUnit(unit); + service.setupInteractions(svg); + + tap(location, 731); + pickerFactory.createNumericPicker.calls.mostRecent().args[0].onPick({ value: 2 }); + + expect(unit.addInternalHits).toHaveBeenCalledWith('LT', 2, false, { + hardenedArmorApplies: false, + }); }); it('keeps direct structure damage and repair within structure', () => { @@ -1256,7 +1595,9 @@ describe('SvgInteractionService', () => { })); pickerConfig.onPick({ label: '16', value: 16 }); expect(unit.addArmorHits).toHaveBeenCalledWith('LT', 15, false, false); - expect(unit.addInternalHits).toHaveBeenCalledWith('LT', 1, false); + expect(unit.addInternalHits).toHaveBeenCalledWith('LT', 1, false, { + hardenedArmorApplies: true, + }); }); it('does not color direct structure choices as armor overflow', () => { @@ -1807,13 +2148,13 @@ describe('SvgInteractionService', () => { it('falls back to current heat when live heat highlighting receives an invalid value', () => { const { svg, heat5, heat10, heat12 } = createHeatScaleSvg(); - const unit = { + const unit = createSvgInteractionUnit({ id: 'unit-a', svg: () => svg, getHeat: () => ({ current: 10, next: undefined }), getUnit: () => ({ type: 'Mek' }), getInventory: () => [] - }; + }); service.updateUnit(unit); service.updateHeatHighlight(Number.NaN); @@ -1825,6 +2166,10 @@ describe('SvgInteractionService', () => { }); +async function settlePromises(): Promise { + for (let index = 0; index < 12; index += 1) await Promise.resolve(); +} + function createPointerEvent(type: string, init: PointerEventInit): PointerEvent { return new PointerEvent(type, { bubbles: true, @@ -1873,6 +2218,7 @@ function tap(el: SVGElement, pointerId: number): void { } function createArmorInteractionUnit(config: { + location?: string; armorPoints: number; armorHits: number; internalPoints: number; @@ -1885,7 +2231,7 @@ function createArmorInteractionUnit(config: { location.classList.add('unitLocation'); if (config.structure) location.classList.add('structure'); if (config.rear) location.setAttribute('rear', '1'); - location.setAttribute('loc', 'LT'); + location.setAttribute('loc', config.location ?? 'LT'); svg.appendChild(location); let armorHits = config.armorHits; @@ -1903,6 +2249,7 @@ function createArmorInteractionUnit(config: { addInternalHits: jasmine.createSpy('addInternalHits').and.callFake((_loc: string, hits: number) => { internalHits += hits; }), + applyHeadHitPilotHits: jasmine.createSpy('applyHeadHitPilotHits'), getCritSlotsAsMatrix: () => ({}), }); return { svg, location, unit }; @@ -1980,6 +2327,7 @@ function createInventoryInteractionUnit(html = ` pilotingSkill: () => 5, turnState: () => ({ moveMode: () => null, + effectiveMoveMode: () => null, airborne: () => false, getAttackMovementModifier: () => 0, getAttackModifierBreakdown: () => [], diff --git a/src/app/components/page-viewer/svg-interaction.service.ts b/src/app/components/page-viewer/svg-interaction.service.ts index 2797b98e6..33595dca5 100644 --- a/src/app/components/page-viewer/svg-interaction.service.ts +++ b/src/app/components/page-viewer/svg-interaction.service.ts @@ -8,7 +8,7 @@ import { Overlay } from '@angular/cdk/overlay'; import { ComponentPortal } from '@angular/cdk/portal'; import { outputToObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { DialogsService, type DialogRef } from '../../services/dialogs.service'; -import { firstValueFrom } from 'rxjs'; +import { firstValueFrom, type Subscription } from 'rxjs'; import type { SkillType } from '../../models/crew-member.model'; import type { MountedEquipment } from '../../models/mounted-equipment.model'; import type { CriticalSlot } from '../../models/force-serialization'; @@ -24,7 +24,7 @@ import { createHandlerCommandContext, createHandlerQueryContext, EquipmentIntera import type { HandlerChoice } from '../../services/equipment-interaction-registry.service'; import { ForceBuilderService } from '../../services/force-builder.service'; import { OverlayManagerService } from '../../services/overlay-manager.service'; -import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; +import type { CBTForceUnit, CBTUnitAutomationTrigger } from '../../models/cbt-force-unit.model'; import { type ChoicePickerStyle, PickerFactoryService } from '../../services/picker-factory.service'; import { EquipmentDialogComponent } from '../equipment-dialog/equipment-dialog.component'; import type { EquipmentDialogContext, EquipmentDialogData, EquipmentDialogTab } from '../equipment-dialog/equipment-dialog.model'; @@ -43,10 +43,16 @@ import { ClusterTableDialogComponent } from '../cluster-table-dialog/cluster-tab import { hasUnitDefaultReferenceTables } from '../../utils/reference-table-definition'; import { clusterTableForUnit } from '../../utils/record-sheet-reference-table'; import { isCenterPanelTarget, isPointInCenterPanel, resolveCenterPanelCursorElements } from '../../utils/record-sheet-center-panel.util'; -import { MekCriticalChanceDialogComponent, type MekCriticalChanceDialogData } from './mek-critical-chance-dialog.component'; -import { MekCriticalRollDialogComponent, type MekCriticalRollDialogData } from './mek-critical-roll-dialog.component'; -import { applyMekBlowOff, canApplyMekCriticalHitToSlot, mekCriticalChanceCanBlowOff, mekCriticalChanceModifiers, type MekCriticalChanceResult } from '../../utils/mek-critical-hit.util'; +import { canApplyMekCriticalHitToSlot } from '../../utils/mek-critical-hit.util'; import { uidTranslations } from '../../models/common.model'; +import type { MekRules } from '../../models/rules/mek-rules'; +import { CBTAutomationService } from '../../services/cbt-automation.service'; +import { MekCriticalHitAutomationService } from '../../services/mek-critical-hit-automation.service'; +import { MekCriticalResolutionService } from '../../services/mek-critical-resolution.service'; +import { UnitCheckResolutionService } from '../../services/unit-check-resolution.service'; +import { FallingResolutionService } from '../../services/falling-resolution.service'; +import type { AutomationReviewEvent } from '../../models/automation-review.model'; +import { uuidv7 } from '../../utils/uuid.util'; type SheetInventoryRangeKey = InventoryRangeKey | 'extreme'; type HeatMarkerData = { el: SVGElement | null, heat: number; baselineHeat: number }; @@ -112,6 +118,11 @@ export class SvgInteractionService { private equipmentRegistryService = inject(EquipmentInteractionRegistryService); private pageViewerState = inject(PageViewerStateService); private pickerFactory = inject(PickerFactoryService); + private automations = inject(CBTAutomationService); + private criticalHitAutomation = inject(MekCriticalHitAutomationService); + private criticalResolution = inject(MekCriticalResolutionService); + private unitCheckResolution = inject(UnitCheckResolutionService); + private fallingResolution = inject(FallingResolutionService); // Zoom-pan service passed via initialize() private zoomPanService!: ZoomPanServiceInterface; @@ -133,6 +144,8 @@ export class SvgInteractionService { private interactionAbortController: AbortController | null = null; private activeHeatDrag: ActiveHeatDrag | null = null; private centerPanelDialogRef: DialogRef | null = null; + private unitAutomationSubscription: Subscription | null = null; + private automationQueue: Promise = Promise.resolve(); private currentHighlightedElement: SVGElement | null = null; @@ -215,7 +228,15 @@ export class SvgInteractionService { } updateUnit(unit: CBTForceUnit | null) { + this.unitAutomationSubscription?.unsubscribe(); + this.unitAutomationSubscription = null; this.unit.set(unit); + if (unit) { + this.unitAutomationSubscription = unit.automationTriggers.subscribe(trigger => { + this.scheduleAutomation(unit, trigger); + }); + unit.applyUnderwaterBreachAndFlooding(); + } } setupInteractions(svg: SVGSVGElement) { @@ -636,13 +657,20 @@ export class SvgInteractionService { pipsCount = 0; } - const getHits = () => { + const getStoredHits = () => { if (isStructure) { return this.unit()?.getInternalHits(loc) || 0; } else { return (this.unit()?.getArmorHits(loc, rear) || 0); } - } + }; + const getHits = () => { + const unit = this.unit(); + if (isShield && unit?.getUnit().type === 'Mek') { + return (unit.rules as MekRules).getShieldTrackHits(loc, true) ?? getStoredHits(); + } + return getStoredHits(); + }; const armorToastId = `${this.unit()?.id}-${isStructure ? 'structure' : 'armor'}-${loc}-${rear ? 'rear' : ''}`; let lastAmountVariationTimestamp = 0; @@ -719,49 +747,49 @@ export class SvgInteractionService { }; const title = `${loc}${rear ? ' (Rear)' : ''}`; const position = { x, y }; - const startValue = - getHits() - consumedModularArmorPoints; + // Critical/actuator shield losses are derived and cannot be + // repaired by erasing ordinary damage from the DA/DC track. + const startValue = -getStoredHits() - consumedModularArmorPoints; const remainingArmorPoints = Math.max(0, pipsCount - getHits() + availableModularArmorPoints); const internalPoints = !isStructure && !isShield ? (this.unit()?.getInternalPoints(loc) ?? 0) : 0; const remainingInternalPoints = Math.max(0, internalPoints - (this.unit()?.getInternalHits(loc) ?? 0)); const endValue = remainingArmorPoints + remainingInternalPoints; - const applyArmorChange = (value: number) => { - this.removePicker(); - const unit = this.unit(); - if (!unit) return; + const commitArmorChange = (unit: CBTForceUnit, value: number, applyHeadHit: boolean) => { + if (applyHeadHit) unit.applyHeadHitPilotHits(); if (isStructure) { unit.addInternalHits(loc, value, this.consolidateImmediately); } else { let valueToApply = value; - // We remove/add first from/to the modular armor, if any - if (availableModularArmorPoints > 0 && valueToApply > 0) { - unit.getCritSlotsAsMatrix()[loc]?.forEach(critSlot => { - if (valueToApply == 0) return; - if (!critSlot.eq?.flags?.has('F_MODULAR_ARMOR')) return; - if (critSlot.destroyed) return; - const canApply = Math.min(valueToApply, 10 - (critSlot.consumed || 0)); - critSlot.consumed = (critSlot.consumed || 0) + canApply; - valueToApply -= canApply; - availableModularArmorPoints -= canApply; - consumedModularArmorPoints += canApply; - unit.setCritSlot(critSlot); - }); - } else if (consumedModularArmorPoints > 0 && valueToApply < 0) { - unit.getCritSlotsAsMatrix()[loc]?.forEach(critSlot => { - const armorPointsToRepair = Math.min(-valueToApply, unit.getArmorHits(loc, rear)); - unit.addArmorHits(loc, -armorPointsToRepair, rear, this.consolidateImmediately); - valueToApply += armorPointsToRepair; - if (valueToApply == 0) return; - if (!critSlot.eq?.flags?.has('F_MODULAR_ARMOR')) return; - if (critSlot.destroyed) return; - const canApply = Math.min(-valueToApply, critSlot.consumed || 0); - critSlot.consumed = (critSlot.consumed || 0) - canApply; - valueToApply += canApply; - availableModularArmorPoints += canApply; - consumedModularArmorPoints -= canApply; - this.unit()?.setCritSlot(critSlot); - }); - } + // Apply damage to modular armor first, and restore it last when repairing. + if (availableModularArmorPoints > 0 && valueToApply > 0) { + unit.getCritSlotsAsMatrix()[loc]?.forEach(critSlot => { + if (valueToApply == 0) return; + if (!critSlot.eq?.flags?.has('F_MODULAR_ARMOR')) return; + if (critSlot.destroyed) return; + const canApply = Math.min(valueToApply, 10 - (critSlot.consumed || 0)); + critSlot.consumed = (critSlot.consumed || 0) + canApply; + valueToApply -= canApply; + availableModularArmorPoints -= canApply; + consumedModularArmorPoints += canApply; + unit.setCritSlot(critSlot); + }); + } else if (consumedModularArmorPoints > 0 && valueToApply < 0) { + unit.getCritSlotsAsMatrix()[loc]?.forEach(critSlot => { + const armorPointsToRepair = Math.min(-valueToApply, unit.getArmorHits(loc, rear)); + unit.addArmorHits(loc, -armorPointsToRepair, rear, this.consolidateImmediately); + valueToApply += armorPointsToRepair; + if (valueToApply == 0) return; + if (!critSlot.eq?.flags?.has('F_MODULAR_ARMOR')) return; + if (critSlot.destroyed) return; + const canApply = Math.min(-valueToApply, critSlot.consumed || 0); + critSlot.consumed = (critSlot.consumed || 0) - canApply; + valueToApply += canApply; + availableModularArmorPoints += canApply; + consumedModularArmorPoints -= canApply; + unit.setCritSlot(critSlot); + }); + } if (valueToApply != 0) { if (valueToApply > 0 && !isShield && internalPoints > 0) { const ordinaryArmorRemaining = Math.max(0, pipsCount - unit.getArmorHits(loc, rear)); @@ -771,7 +799,9 @@ export class SvgInteractionService { unit.addArmorHits(loc, armorDamage, rear, this.consolidateImmediately); } if (internalDamage > 0) { - unit.addInternalHits(loc, internalDamage, this.consolidateImmediately); + unit.addInternalHits(loc, internalDamage, this.consolidateImmediately, { + hardenedArmorApplies: ordinaryArmorRemaining > 0, + }); } } else { unit.addArmorHits(loc, valueToApply, rear, this.consolidateImmediately); @@ -784,6 +814,21 @@ export class SvgInteractionService { showArmorToast(value); }; + const applyArmorChange = (value: number) => { + this.removePicker(); + const unit = this.unit(); + if (!unit) return; + if (loc !== 'HD' || value <= 0) { + commitArmorChange(unit, value, false); + return; + } + this.queueAutomation(async () => { + const applyHeadHit = await this.reviewHeadHitPilotHit(unit); + if (applyHeadHit === null) return; + commitArmorChange(unit, value, applyHeadHit); + }); + }; + // Use numeric picker for continuous range const pickerStylePref = this.getUserPickerPreference(); if (pickerStylePref === 'radial' || pickerStylePref === 'default') { @@ -879,6 +924,7 @@ export class SvgInteractionService { } critLoc.destroying = undefined; critLoc.destroyed = undefined; + critLoc.destroyedTurn = undefined; unit.setCritLoc(critLoc); } @@ -966,6 +1012,7 @@ export class SvgInteractionService { } critLoc.destroying = undefined; critLoc.destroyed = undefined; + critLoc.destroyedTurn = undefined; unit.setCritLoc(critLoc); } @@ -1173,8 +1220,24 @@ export class SvgInteractionService { } } else if (choice.value == 'Hit') { if (!canApplyMekCriticalHitToSlot(unit, critSlot)) return; - unit.applyHitToCritSlot(critSlot, 1, this.consolidateImmediately); + const resolution = await this.criticalHitAutomation.applySlot( + unit, + critSlot, + this.consolidateImmediately, + ); + if (resolution.cancelled || !resolution.outcome?.applied) return; this.toastService.showToast(`Critical Hit on ${labelText}`, 'error'); + if (resolution.outcome.explosion) { + this.toastService.showToast( + `${resolution.outcome.explosion.equipment} explodes for ${resolution.outcome.explosion.rawDamage} damage`, + 'error', + ); + } else if (resolution.outcome.pendingExplosion) { + this.toastService.showToast( + `${resolution.outcome.pendingExplosion.equipment} explosion pending (${resolution.outcome.pendingExplosion.rawDamage} damage)`, + 'error', + ); + } } else if (choice.value == 'Repair') { unit.applyHitToCritSlot(critSlot, -1, this.consolidateImmediately); this.toastService.showToast(`Repaired ${labelText}`, 'success'); @@ -1686,38 +1749,87 @@ export class SvgInteractionService { ]; } - private openMekCriticalChanceDialog(unit: CBTForceUnit, location: string): void { - const ref = this.dialogsService.createDialog(MekCriticalChanceDialogComponent, { - data: { - locationLabel: getMekLocationLabel(location) ?? location, - canBlowOff: mekCriticalChanceCanBlowOff(location), - modifiers: mekCriticalChanceModifiers(unit, location), - } as MekCriticalChanceDialogData, - }); - ref.closed.subscribe(result => { - if (!result || result.kind === 'none') return; - if (result.kind === 'blown-off') { - const blowOff = applyMekBlowOff(unit, location, this.consolidateImmediately); - if (blowOff.kind === 'absorbed') { - this.toastService.showToast(`Armored ${blowOff.equipment} absorbs the blow-off result`, 'info'); - } else { - this.toastService.showToast(`${getMekLocationLabel(location) ?? location} blown off`, 'error'); - } - return; - } - this.openMekCriticalRollDialog(unit, location, result.count); + private scheduleAutomation(unit: CBTForceUnit, trigger: CBTUnitAutomationTrigger): void { + let task: () => Promise; + if (trigger.kind === 'critical-hit-chance') { + task = () => this.criticalResolution.resumeChance(unit, trigger.id); + } else if (trigger.kind === 'pending-unit-check') { + task = () => this.unitCheckResolution.open([unit]); + } else if (trigger.kind === 'falling') { + task = () => this.fallingResolution.open(unit, trigger, this.consolidateImmediately); + } else { + task = () => this.handleBreachAndFloodTrigger(unit, trigger); + } + + this.queueAutomation(task); + } + + private queueAutomation(task: () => Promise): void { + this.automationQueue = this.automationQueue.then(task).catch(error => { + console.error('CBT automation failed', error); }); } - private openMekCriticalRollDialog(unit: CBTForceUnit, location: string, requiredHits?: number): void { - this.dialogsService.createDialog(MekCriticalRollDialogComponent, { - data: { - unit, - location, - requiredHits, - consolidateImmediately: this.consolidateImmediately, - } as MekCriticalRollDialogData, + private async reviewHeadHitPilotHit(unit: CBTForceUnit): Promise { + const event: AutomationReviewEvent = { + id: uuidv7(), + subject: unit.getNotificationDisplayName(), + event: 'Head hit', + description: 'Apply the resulting pilot hit', + effects: ['Queue any required Consciousness Roll'], + }; + const accepted = await this.automations.resolve('pilotHitsAndConsciousness', [event], { + title: 'Review Pilot Hit', + message: 'Choose whether to apply the pilot hit caused by this head hit. Cancel leaves the head damage unapplied.', }); + return accepted === null ? null : accepted.has(event.id); + } + + private async handleBreachAndFloodTrigger( + unit: CBTForceUnit, + trigger: Extract, + ): Promise { + const events: AutomationReviewEvent[] = trigger.locations.map(location => ({ + id: `${trigger.id}:${location}`, + subject: unit.getNotificationDisplayName(), + event: 'Breach and flood', + description: `${getMekLocationLabel(location) ?? location} is exposed while submerged`, + })); + let accepted: ReadonlySet | null; + try { + accepted = await this.automations.resolve('breachAndFlood', events, { + title: 'Review Breach and Flooding', + message: 'Choose which exposed locations to flood. Cancel defers every location until flooding is evaluated again.', + }); + } catch (error) { + unit.deferUnderwaterBreachAndFloodingReview(trigger.locations); + throw error; + } + if (accepted === null) { + unit.deferUnderwaterBreachAndFloodingReview(trigger.locations); + return; + } + + for (const [index, event] of events.entries()) { + if (!accepted.has(event.id)) continue; + const location = trigger.locations[index]; + unit.setLocationCondition(location, 'flooded', true, trigger.commit); + } + } + + private openMekCriticalChanceDialog( + unit: CBTForceUnit, + location: string, + consolidateImmediately = this.consolidateImmediately, + ): Promise { + return this.criticalResolution.openManualChance(unit, location, consolidateImmediately); + } + + private openMekCriticalRollDialog( + unit: CBTForceUnit, + location: string, + ): Promise { + return this.criticalResolution.openManual(unit, location, this.consolidateImmediately); } private openEquipmentDialog(unit: CBTForceUnit, initialTab: EquipmentDialogTab): void { @@ -1968,15 +2080,17 @@ export class SvgInteractionService { const hitValue = parseInt(svgEl.getAttribute('hit') || '0'); const member = unit.getCrewMember(crewId); const currentHits = member.getHits(); + let nextHits: number; if (currentHits > hitValue) { // if there are slots above, we act as a slider - member.setHits(Math.max(0, hitValue)); + nextHits = hitValue; } else if (currentHits === hitValue) { // else we toggle the hit value of this slot - member.setHits(Math.max(0, currentHits - 1)); + nextHits = currentHits - 1; } else { - member.setHits(Math.max(0, hitValue)); + nextHits = hitValue; } + unit.setCrewHits(crewId, nextHits); }, signal); }); } @@ -2087,7 +2201,7 @@ export class SvgInteractionService { } else { const crewMember = unit.getCrewMember(crewId); const selectedState = state as CrewStateControlKey; - crewMember.setState(crewMember.getState() === selectedState ? 'healthy' : selectedState); + unit.setCrewState(crewId, crewMember.getState() === selectedState ? 'healthy' : selectedState); } if (componentRef.instance.closeOnSelect()) { this.overlayManager.closeManagedOverlay(SVG_CREW_STATE_DROPDOWN_OVERLAY_KEY); @@ -2385,6 +2499,8 @@ export class SvgInteractionService { cleanup() { this.endHeatDrag(); + this.unitAutomationSubscription?.unsubscribe(); + this.unitAutomationSubscription = null; this.centerPanelDialogRef?.close(); this.centerPanelDialogRef = null; if (this.heatMarkerEffectRef) { diff --git a/src/app/models/crew-member.model.ts b/src/app/models/crew-member.model.ts index 7cd295bee..fe1e7fd26 100644 --- a/src/app/models/crew-member.model.ts +++ b/src/app/models/crew-member.model.ts @@ -69,10 +69,7 @@ export class CrewMember { toggleUnconscious() { const newState = this.state === 'unconscious' ? 'healthy' : 'unconscious'; - if (this.state === newState) return; - this.state = newState; - this.unit.setCrewMember(this.id, this); - this.unit.setModified(); + this.unit.setCrewState(this.id, newState); } isDead(): boolean { diff --git a/src/app/models/rules/aero-rules.spec.ts b/src/app/models/rules/aero-rules.spec.ts index c4d8a579c..930434b99 100644 --- a/src/app/models/rules/aero-rules.spec.ts +++ b/src/app/models/rules/aero-rules.spec.ts @@ -4,15 +4,33 @@ import type { CBTForceUnit } from '../cbt-force-unit.model'; import { WeaponEquipment } from '../equipment.model'; +import type { CriticalSlot } from '../force-serialization'; import type { MountedEquipment } from '../mounted-equipment.model'; import { AeroRules } from './aero-rules'; -function createHarness(heat: number, physical = false): { rules: AeroRules; entry: MountedEquipment } { +function createHarness( + heat: number, + physical = false, + crewState: 'healthy' | 'unconscious' = 'healthy', + pilotHits = 0, + criticalSlots: CriticalSlot[] = [], + conditions: ReadonlySet = new Set(), +): { rules: AeroRules; entry: MountedEquipment } { + const crew = { + getState: () => crewState, + getSkill: () => 4, + getHits: () => pilotHits, + }; const unit = { + isLoaded: () => true, getHeat: () => ({ current: heat }), getInventory: () => [], - getCritSlots: () => [], + getCritSlots: () => criticalSlots, + getCrewMember: () => crew, + getCrewMembers: () => [crew], + getCondition: (condition: string) => conditions.has(condition), isEquipmentOperational: () => true, + gameRules: { supportsSkidding: true }, } as unknown as CBTForceUnit; const entry = { committedDestroyed: () => false, @@ -56,4 +74,50 @@ describe('AeroRules', () => { expect(modifiers).toEqual([]); }); -}); \ No newline at end of file + + it('makes an aerospace unit immobile while its pilot is unconscious', () => { + const { rules } = createHarness(0, false, 'unconscious'); + + expect(rules.hasComputedCondition('immobile')).toBeTrue(); + }); + + it('uses pilot, avionics, and life-support damage in a standard Control Roll', () => { + const { rules } = createHarness(0, false, 'healthy', 2, [ + { id: 'avionics_hit_1', destroyed: 1 }, + { id: 'avionics_hit_2', destroying: 2 }, + { id: 'life_support_hit_1', destroyed: 3 }, + { id: 'fcs_hit_1', destroyed: 4 }, + ]); + + expect(rules.getStandardControlRollTarget()).toBe(9); + }); + + it('keeps out-of-control and random movement as separate movement conditions', () => { + const controlled = createHarness(0).rules; + const outOfControl = createHarness(0, false, 'healthy', 0, [], new Set(['out-of-control'])).rules; + const randomMovement = createHarness(0, false, 'healthy', 0, [], new Set(['random-movement'])).rules; + + expect(controlled.isMotiveModeAvailable('run')).toBeTrue(); + expect(outOfControl.isMotiveModeAvailable('run')).toBeFalse(); + expect(randomMovement.isMotiveModeAvailable('run')).toBeFalse(); + expect(outOfControl.isMotiveModeAvailable('stationary')).toBeTrue(); + expect(randomMovement.isMotiveModeAvailable('stationary')).toBeTrue(); + }); + + it('derives shutdown out-of-control without inventing random movement', () => { + const { rules } = createHarness(8, false, 'healthy', 0, [], new Set(['shutdown'])); + + expect(rules.hasComputedCondition('out-of-control')).toBeTrue(); + expect(rules.hasComputedCondition('random-movement')).toBeFalse(); + expect(rules.computedConditions()).toContain('out-of-control'); + }); + + it('applies the Total Warfare out-of-control firing modifier independently of random movement', () => { + const { rules, entry } = createHarness(8, false, 'healthy', 0, [], new Set(['out-of-control'])); + + expect(rules.getEquipmentToHitModifiers(entry)).toEqual([ + { label: 'Heat - Fire Modifier', modifier: 1, weakened: true, kind: 'heat' }, + { label: 'Out of Control', modifier: 2, weakened: true }, + ]); + }); +}); diff --git a/src/app/models/rules/aero-rules.ts b/src/app/models/rules/aero-rules.ts index 398e222fa..27116f7fc 100644 --- a/src/app/models/rules/aero-rules.ts +++ b/src/app/models/rules/aero-rules.ts @@ -4,8 +4,8 @@ import { computed } from '@angular/core'; import type { CBTForceUnit } from '../cbt-force-unit.model'; -import type { MountedEquipment } from '../mounted-equipment.model'; -import { UnitTypeRulesBase, type UnitRuleModifier } from './unit-type-rules'; +import type { MotiveModes } from '../motiveModes.model'; +import { unitConditionControls, UnitTypeRulesBase, type UnitRuleModifier } from './unit-type-rules'; import { type HeatScaleEntry, type HeatDissipationState, @@ -19,10 +19,25 @@ import { */ export class AeroRules extends UnitTypeRulesBase { + protected override readonly baseConditionControls = unitConditionControls(['shutdown', 'out-of-control', 'random-movement']); + protected override readonly immobile = computed(() => + this.unit.isLoaded() + && !this.hasDroneOperatingSystem() + && !this.hasFunctionalCrew()); + protected override supportsDroneOperatingSystem(): boolean { return true; } + override hasComputedCondition(condition: string): boolean { + if (condition === 'out-of-control' && this.unit.getCondition('shutdown')) return true; + return super.hasComputedCondition(condition); + } + + override computedConditions(): readonly string[] { + return [...super.computedConditions(), 'out-of-control']; + } + private readonly heatMgmt: HeatManagement; constructor(unit: CBTForceUnit) { @@ -60,6 +75,29 @@ export class AeroRules extends UnitTypeRulesBase { // ── PSR / Control Rolls ────────────────────────────────────────────────── + override getStandardControlRollTarget(): number { + return this.getBasePilotingSkill() + + (this.unit.getCrewMember(0)?.getHits() ?? 0) + + this.destroyedCriticalBoxes('avionics_hit') + + this.destroyedCriticalBoxes('life_support_hit'); + } + + override isMotiveModeAvailable(moveMode: MotiveModes): boolean { + if (moveMode === 'stationary') return true; + return !this.unit.getCondition('out-of-control') + && !this.unit.getCondition('random-movement'); + } + + private destroyedCriticalBoxes(idPrefix: string): number { + return this.unit.getCritSlots().filter(slot => { + const type = slot.el?.getAttribute('type') ?? ''; + const matchesSystem = slot.id.startsWith(idPrefix) + || slot.name?.startsWith(idPrefix) === true + || type.startsWith(idPrefix); + return matchesSystem && (slot.destroyed !== undefined || slot.destroying !== undefined); + }).length; + } + // ── Heat Scale ─────────────────────────────────────────────────────────── /** @@ -86,6 +124,7 @@ export class AeroRules extends UnitTypeRulesBase { { heat: 28, ammoExp: 8 }, { heat: 30, shutdown: 100 }, ]; + override readonly heatScale = AeroRules.HEAT_SCALE; /** Compute heat-based fire modifiers from current heat level */ static getHeatEffects(heat: number): { moveModifier: number; fireModifier: number } { @@ -93,13 +132,24 @@ export class AeroRules extends UnitTypeRulesBase { } protected override buildRuleModifiers(): UnitRuleModifier[] { + const modifiers: UnitRuleModifier[] = []; const heatFireModifier = AeroRules.getHeatEffects(this.unit.getHeat().current).fireModifier; - return heatFireModifier === 0 ? [] : [{ - label: 'Heat - Fire Modifier', - values: { ranged: heatFireModifier }, - weakened: true, - kind: 'heat', - }]; + if (heatFireModifier !== 0) { + modifiers.push({ + label: 'Heat - Fire Modifier', + values: { ranged: heatFireModifier }, + weakened: true, + kind: 'heat', + }); + } + if (this.unit.getCondition('out-of-control')) { + modifiers.push({ + label: 'Out of Control', + values: { ranged: 2 }, + weakened: true, + }); + } + return modifiers; } // ── Heat Dissipation ───────────────────────────────────────────────────── diff --git a/src/app/models/rules/heat-management.spec.ts b/src/app/models/rules/heat-management.spec.ts new file mode 100644 index 000000000..7d7a49ff3 --- /dev/null +++ b/src/app/models/rules/heat-management.spec.ts @@ -0,0 +1,40 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { describeHeatScaleRollChecks, type HeatScaleEntry } from './heat-management'; + +describe('describeHeatScaleRollChecks', () => { + const scale: readonly HeatScaleEntry[] = [ + { heat: 5, move: -1, randomMovement: 5 }, + { heat: 8, fire: 1 }, + { heat: 14, shutdown: 4 }, + { heat: 18, shutdown: 6 }, + { heat: 19, ammoExp: 4 }, + { heat: 21, pilotDamage: 6 }, + { heat: 30, shutdown: 100 }, + ]; + + it('describes each cumulative roll check using the latest active threshold', () => { + expect(describeHeatScaleRollChecks(scale, 21)).toEqual([ + 'Shutdown check 6+', + 'Ammo explosion check 4+', + 'Random movement check 5+', + 'Pilot damage check 6+', + ]); + }); + + it('labels heat-30 shutdown as automatic', () => { + expect(describeHeatScaleRollChecks(scale, 30)).toContain('Automatic shutdown'); + }); + + it('omits movement and fire effects when no roll is required', () => { + expect(describeHeatScaleRollChecks(scale, 8)).toEqual([ + 'Random movement check 5+', + ]); + expect(describeHeatScaleRollChecks([ + { heat: 5, move: -1 }, + { heat: 8, fire: 1 }, + ], 8)).toEqual([]); + }); +}); diff --git a/src/app/models/rules/heat-management.ts b/src/app/models/rules/heat-management.ts index 726bafa13..26a39306b 100644 --- a/src/app/models/rules/heat-management.ts +++ b/src/app/models/rules/heat-management.ts @@ -32,6 +32,35 @@ export interface HeatScaleEntry { pilotDamage?: number; } +export interface ResolvedHeatScaleEffects { + moveModifier: number; + fireModifier: number; + shutdownTarget?: number; + ammoExplosionTarget?: number; + randomMovementTarget?: number; + pilotDamageTarget?: number; +} + +export function resolveHeatScaleEffects( + scale: readonly HeatScaleEntry[], + heat: number, +): ResolvedHeatScaleEffects { + const effects: ResolvedHeatScaleEffects = { + moveModifier: 0, + fireModifier: 0, + }; + for (const entry of scale) { + if (heat < entry.heat) break; + if (entry.move !== undefined) effects.moveModifier = entry.move; + if (entry.fire !== undefined) effects.fireModifier = entry.fire; + if (entry.shutdown !== undefined) effects.shutdownTarget = entry.shutdown; + if (entry.ammoExp !== undefined) effects.ammoExplosionTarget = entry.ammoExp; + if (entry.randomMovement !== undefined) effects.randomMovementTarget = entry.randomMovement; + if (entry.pilotDamage !== undefined) effects.pilotDamageTarget = entry.pilotDamage; + } + return effects; +} + /** * Walk a heat scale and return cumulative move/fire modifiers at a given heat level. */ @@ -39,16 +68,34 @@ export function getHeatEffects( scale: readonly HeatScaleEntry[], heat: number, ): { moveModifier: number; fireModifier: number } { - let moveModifier = 0; - let fireModifier = 0; - for (const entry of scale) { - if (heat < entry.heat) break; - if (entry.move !== undefined) moveModifier = entry.move; - if (entry.fire !== undefined) fireModifier = entry.fire; - } + const { moveModifier, fireModifier } = resolveHeatScaleEffects(scale, heat); return { moveModifier, fireModifier }; } +/** Human-readable cumulative roll checks triggered at one heat level. */ +export function describeHeatScaleRollChecks( + scale: readonly HeatScaleEntry[], + heat: number, +): string[] { + const effects = resolveHeatScaleEffects(scale, heat); + const labels: string[] = []; + if (effects.shutdownTarget !== undefined) { + labels.push(effects.shutdownTarget >= 100 + ? 'Automatic shutdown' + : `Shutdown check ${effects.shutdownTarget}+`); + } + if (effects.ammoExplosionTarget !== undefined) { + labels.push(`Ammo explosion check ${effects.ammoExplosionTarget}+`); + } + if (effects.randomMovementTarget !== undefined) { + labels.push(`Random movement check ${effects.randomMovementTarget}+`); + } + if (effects.pilotDamageTarget !== undefined) { + labels.push(`Pilot damage check ${effects.pilotDamageTarget}+`); + } + return labels; +} + // ── Dissipation State ──────────────────────────────────────────────────────── /** Base heat-dissipation shape returned by every heat-aware rules class. */ diff --git a/src/app/models/rules/mek-rules.spec.ts b/src/app/models/rules/mek-rules.spec.ts index edebc3fed..1f9b50e9b 100644 --- a/src/app/models/rules/mek-rules.spec.ts +++ b/src/app/models/rules/mek-rules.spec.ts @@ -3811,6 +3811,8 @@ describe('MekRules', () => { }); const turnState = forceUnit.turnState(); forceUnit.setCondition('prone', true); + turnState.moveMode.set('run'); + turnState.moveDistance.set(0); turnState.addDmgReceived(20); turnState.setPSRCheckState({ hipsHit: new Set(['LL']) }); diff --git a/src/app/models/rules/mek-rules.ts b/src/app/models/rules/mek-rules.ts index 8ac00b2b1..3f9f78841 100644 --- a/src/app/models/rules/mek-rules.ts +++ b/src/app/models/rules/mek-rules.ts @@ -7,7 +7,7 @@ import type { CBTForceUnit, EquipmentAction } from '../cbt-force-unit.model'; import type { CrewMember, SkillType } from '../crew-member.model'; import type { MountedEquipment } from '../mounted-equipment.model'; import type { CriticalSlot, RuleCheckOutcome } from '../force-serialization'; -import { CrewStateControlDefinition, CrewStateDefinition, crewStateDefinitions, NARC_CONDITION_COLOR, sortPSRModifiers, UnitConditionControl, unitConditionControls, UnitTypeRulesBase, type ChargeDamage, type LocationConditionControl, type PSRCheck, type UnitHeatSource, type UnitModifierBreakdownEntry, type UnitRuleModifier } from './unit-type-rules'; +import { CrewStateControlDefinition, CrewStateDefinition, crewStateDefinitions, NARC_CONDITION_COLOR, sortPSRModifiers, UnitConditionControl, unitConditionControls, UnitTypeRulesBase, type ChargeDamage, type LocationConditionControl, type PSRCheck, type PSRCheckKind, type UnitHeatSource, type UnitModifierBreakdownEntry, type UnitRuleModifier } from './unit-type-rules'; import type { EquipmentStatus, EquipmentStatusFacts } from '../equipment-status.model'; import type { TurnState } from '../turn-state.model'; import { type HeatScaleEntry, HeatManagement, getHeatEffects } from './heat-management'; @@ -21,9 +21,11 @@ import { LEG_LOCATIONS, MEK_TORSO_LOCATIONS, QUAD_LEG_LOCATIONS, + type MekConfig, } from '../entity/types'; import { resolveShieldProfile, type ShieldProfile } from '../entity/utils/physical-weapon'; import type { Equipment } from '../equipment.model'; +import type { EquipmentFlag } from '../equipment-flags.type'; export { LEG_LOCATIONS } from '../entity/types'; import type { InventoryControlDisplayData } from '../../utils/inventory-control.util'; @@ -32,6 +34,11 @@ import { uuidv7 } from '../../utils/uuid.util'; type ArmLocation = 'LA' | 'RA'; +const LEG_DAMAGE_MOVEMENT_CRITICAL_NAMES: Record = { + 'damaged-leg-actuator-movement': ['Leg', 'Foot', 'Hip'], + 'damaged-hip-movement': ['Hip'], +}; + interface MekArmStatus { destroyedShoulder: boolean; destroyedHand: boolean; @@ -50,6 +57,15 @@ interface MekArmStatus { singleArmMod: number; } +interface MekMobilityEquipmentState { + modularArmorInstalled: boolean; + modularArmorActive: boolean; + mediumShieldsInstalled: number; + mediumShieldsActive: number; + largeShieldsInstalled: number; + largeShieldsActive: number; +} + const TORSO_CRIPPLE_CHECK_KEY = 'core.torso-crippling'; export const MEK_UNIT_CONDITION_CONTROLS: readonly UnitConditionControl[] = unitConditionControls(['shutdown', 'prone', 'swarmed', 'tagged', 'ecm-shielded', 'skidding', 'jammed']); @@ -137,19 +153,16 @@ export class MekRules extends UnitTypeRulesBase { protected override readonly immobile = computed(() => { if (!this.unit.isLoaded()) return false; if (this.unit.getCondition('shutdown')) return true; - if (this.allLimbsDestroyedOrMissing()) return true; if (!this.hasDroneOperatingSystem() && !this.hasFunctionalCrew()) return true; - const unit = this.unit.getUnit(); const movement = this.computeBaseMovementProfile(); if (!movement) return false; const canUseJumpingMovement = !this.unit.getCondition('prone'); - const availableModes = [ - unit.walk > 0 ? movement.walk : null, - unit.run > 0 ? this.baseRunValue(movement) : null, - unit.jump > 0 && canUseJumpingMovement ? movement.jump : null, - unit.umu > 0 && canUseJumpingMovement ? movement.UMU : null, + const damageAvailableModes = [ + movement.baselineWalk > 0 ? movement.walk : null, + movement.baselineJump > 0 && canUseJumpingMovement ? movement.jump : null, + movement.baselineUMU > 0 && canUseJumpingMovement ? movement.UMU : null, ].filter((value): value is number => value !== null); - return availableModes.length > 0 && availableModes.every(value => value <= 0); + return damageAvailableModes.length > 0 && damageAvailableModes.every(value => value <= 0); }); protected override readonly crippled = computed(() => { @@ -190,13 +203,23 @@ export class MekRules extends UnitTypeRulesBase { // ── Cripple Check Utilities ────────────────────────────────────────────── - protected allLimbsDestroyedOrMissing(): boolean { + protected allLimbsDestroyed(): boolean { const internalLocations = this.unit.locations?.internal; if (!internalLocations) return false; const config = inferMekConfigFromLocations(internalLocations.keys()); const limbLocations = getMekLimbLocations(config); - return limbLocations.every(loc => !internalLocations.has(loc) || this.unit.isInternalLocCommittedDestroyed(loc)); + const isDestroyed = (loc: string) => this.unit.isInternalLocCommittedDestroyed(loc); + if (config !== 'Tripod') return limbLocations.every(isDestroyed); + + const armsDestroyed = limbLocations + .filter(loc => !isMekLegLocation(config, loc)) + .every(isDestroyed); + // A tripod with two destroyed legs follows the biped both-legs-destroyed state. + const destroyedLegs = getMekLegLocations(config) + .filter(isDestroyed) + .length; + return armsDestroyed && destroyedLegs >= 2; } protected isDestroyedOrDestroyingCrit(slot: CriticalSlot): boolean { @@ -322,12 +345,32 @@ export class MekRules extends UnitTypeRulesBase { } } + protected readonly currentLegState = computed(() => { + const internalLocations = this.unit.locations?.internal; + const config = inferMekConfigFromLocations(internalLocations?.keys() ?? []); + const legs = getMekLegLocations(config); + const destroyedLegs = legs.filter(loc => + internalLocations?.has(loc) && this.unit.isInternalLocDestroyed(loc) + ); + const hasIntactLeg = legs.some(loc => + internalLocations?.has(loc) && !this.unit.isInternalLocDestroyed(loc) + ); + const allLegsIntact = legs.every(loc => + internalLocations?.has(loc) && !this.unit.isInternalLocDestroyed(loc) + ); + const destroyedArms = ['LA', 'RA'].filter(loc => + internalLocations?.has(loc) && this.unit.isInternalLocDestroyed(loc) + ); + return { config, destroyedLegs, destroyedArms, hasIntactLeg, allLegsIntact }; + }); + // ── PSR ────────────────────────────────────────────────────────────────── override readonly autoFall = computed(() => { const psr = this.unit.turnState().getPSRCheckState(); - return (psr.legsDestroyed?.size ?? 0) > 0 - || psr.gyroDestroyed === true; + return !this.unit.getCondition('prone') + && ((psr.legsDestroyed?.size ?? 0) > 0 + || psr.gyroDestroyed === true); }); override getPSRChecks(turnState: TurnState): PSRCheck[] { @@ -353,6 +396,11 @@ export class MekRules extends UnitTypeRulesBase { }); } + const prone = turnState.unitState.hasCondition('prone'); + // Standing-up is resolved by its dedicated workflow. Every other check + // in this method avoids a fall, so an already-prone Mek skips it. + if (prone) return checks; + if (psr.gyroDestroyed) { const destroyedGyroCheck = this.destroyedGyroPSRCheck(); if (destroyedGyroCheck) checks.push(this.withPSRLocation(destroyedGyroCheck, this.getGyroDamageLocation())); @@ -384,7 +432,8 @@ export class MekRules extends UnitTypeRulesBase { }); } const movementCheck = turnState.applyMovePSR() - ? this.getCommittedDamageMovementModePSRCheck(turnState.moveMode(), turnState.moveDistance()) + && !this.isMovementPSRFoldedIntoStandAttempt(turnState) + ? this.getCommittedDamageMovementModePSRCheck(turnState.effectiveMoveMode(), turnState.moveDistance()) : null; checks.push(...this.getLegActuatorPSRChecks(turnState, movementCheck)); const gyroHits = (psr.gyroHit || 0); @@ -411,27 +460,30 @@ export class MekRules extends UnitTypeRulesBase { protected getLegActuatorPSRChecks( turnState: TurnState, movementCheck: PSRCheck | null, + includeCurrentHits = true, ): PSRCheck[] { const checks: PSRCheck[] = []; const psr = turnState.getPSRCheckState(); - psr.legActuators?.forEach((count, loc) => { - if (count <= 0) return; - checks.push({ - fallCheck: count, - pilotCheck: count, - loc, - reason: 'Leg Actuator hit', - modifierReason: this.formatLegActuatorModifierReason('Leg Actuator hit', count), + if (includeCurrentHits) { + psr.legActuators?.forEach((count, loc) => { + if (count <= 0) return; + checks.push({ + fallCheck: count, + pilotCheck: count, + loc, + reason: 'Leg Actuator hit', + modifierReason: this.formatLegActuatorModifierReason('Leg Actuator hit', count), + }); }); - }); - psr.hipsHit?.forEach(loc => { - checks.push({ - fallCheck: this.hipPSRModifier, - pilotCheck: this.hipPSRModifier, - loc, - reason: 'Hip hit', + psr.hipsHit?.forEach(loc => { + checks.push({ + fallCheck: this.hipPSRModifier, + pilotCheck: this.hipPSRModifier, + loc, + reason: 'Hip hit', + }); }); - }); + } if (movementCheck) { checks.push(...(this.getLegActuatorMovementPSRChecks(movementCheck) ?? [])); } @@ -459,32 +511,37 @@ export class MekRules extends UnitTypeRulesBase { return Array.from(checksByLeg.values()); } + protected isLegDamageMovementPSRCheck( + check: PSRCheck | null, + ): check is PSRCheck & { kind: PSRCheckKind } { + return check?.kind !== undefined + && LEG_DAMAGE_MOVEMENT_CRITICAL_NAMES[check.kind] !== undefined; + } + private getLegActuatorMovementPSRChecks(check: PSRCheck): PSRCheck[] | null { - let includesSlot: (slot: CriticalSlot) => boolean; - if (check.reason === 'Jumping with damaged leg actuator') { - includesSlot = slot => this.isNamedCrit(slot, 'Leg') - || this.isNamedCrit(slot, 'Foot') - || this.isNamedCrit(slot, 'Hip'); - } else if (check.reason === 'Running with damaged hip') { - includesSlot = slot => this.isNamedCrit(slot, 'Hip'); - } else { - return null; - } + const criticalNames = check.kind === undefined + ? undefined + : LEG_DAMAGE_MOVEMENT_CRITICAL_NAMES[check.kind]; + if (!criticalNames) return null; const reasonsByLeg = new Map>(); this.unit.getCritSlots().forEach(slot => { - if (!slot.loc || !LEG_LOCATIONS.has(slot.loc) || !this.isCritUnavailable(slot) || !includesSlot(slot)) return; + if (!slot.loc + || !LEG_LOCATIONS.has(slot.loc) + || !this.isCritUnavailable(slot) + || !criticalNames.some(name => this.isNamedCrit(slot, name))) return; const reasons = reasonsByLeg.get(slot.loc) ?? new Set(); if (this.isNamedCrit(slot, 'Hip')) reasons.add('Hip hit'); else if (this.isNamedCrit(slot, 'Foot')) reasons.add('Foot hit'); else reasons.add('Leg Actuator hit'); reasonsByLeg.set(slot.loc, reasons); }); - return Array.from(reasonsByLeg, ([loc, reasons]) => ({ + const movementChecks = Array.from(reasonsByLeg, ([loc, reasons]) => ({ ...check, loc, reason: this.formatLegActuatorPSRReasons(...reasons), })); + return movementChecks.length > 0 ? movementChecks : null; } private formatLegActuatorPSRReasons(...reasons: string[]): string { @@ -528,7 +585,10 @@ export class MekRules extends UnitTypeRulesBase { override getCommittedDamageMovementModePSRCheck(moveMode: MotiveModes | null, moveDistance?: number | null): PSRCheck | null { if (moveMode !== 'run' && moveMode !== 'jump') return null; if (moveDistance === null) return null; - if (moveMode === 'run' && moveDistance !== undefined && moveDistance < 1) return null; + if (moveMode === 'run' + && moveDistance !== undefined + && moveDistance < 1 + && this.runningDamageCheckRequiresHexMovement()) return null; const critSlots = this.unit.getCritSlots(); const damagedGyro = critSlots.find(slot => this.isCritUnavailable(slot) && this.isNamedCrit(slot, 'Gyro')); @@ -546,6 +606,7 @@ export class MekRules extends UnitTypeRulesBase { const hasDamagedLegActuators = critSlots.some(slot => { if (!slot.name || !slot.loc || !this.isCritUnavailable(slot)) return false; if (!LEG_LOCATIONS.has(slot.loc)) return false; + if (this.unit.isInternalLocCommittedDestroyed(slot.loc)) return false; return this.isNamedCrit(slot, 'Leg') || this.isNamedCrit(slot, 'Foot') || this.isNamedCrit(slot, 'Hip'); @@ -555,17 +616,33 @@ export class MekRules extends UnitTypeRulesBase { const isQuadruped = QUAD_LEG_LOCATIONS.some(loc => internalLocations.has(loc)); const destroyedLegsCount = this.systemsStatus().destroyedLegsCount; const damagedLegRequiresCheck = this.damagedLegRequiresMovementCheck(isQuadruped, destroyedLegsCount); + const destroyedLegsApplyHipCheck = this.destroyedLegsApplyHipMovementCheck( + isQuadruped, + destroyedLegsCount, + ); if (moveMode === 'jump') { if (damagedGyro) { const check = this.damagedGyroMovementPSRCheck(moveMode); return check ? this.withPSRLocation(check, damagedGyro.loc) : null; } - if (hasDamagedLeg && damagedLegRequiresCheck) { + if (destroyedLegsApplyHipCheck) { return { fallCheck: 0, pilotCheck: 0, - ...(damagedLegLocation && { loc: damagedLegLocation }), + reason: 'Jumping with damaged hip', + }; + } + if (hasDamagedLeg && damagedLegRequiresCheck) { + const modifier = this.destroyedLegMovementPSRModifier( + moveMode, + isQuadruped, + destroyedLegsCount, + ); + return { + fallCheck: modifier, + pilotCheck: modifier, + ...(modifier === 0 && damagedLegLocation && { loc: damagedLegLocation }), reason: 'Jumping with damaged leg' }; } @@ -573,6 +650,7 @@ export class MekRules extends UnitTypeRulesBase { return { fallCheck: 0, pilotCheck: 0, + kind: 'damaged-leg-actuator-movement', reason: 'Jumping with damaged leg actuator' }; } @@ -583,7 +661,16 @@ export class MekRules extends UnitTypeRulesBase { const gyroMovementCheck = this.damagedGyroMovementPSRCheck(moveMode); if (gyroMovementCheck) return this.withPSRLocation(gyroMovementCheck, damagedGyro.loc); } - if (this.runningWithDestroyedLegRequiresCheck() && hasDamagedLeg && damagedLegRequiresCheck) { + if (destroyedLegsApplyHipCheck) { + return { + fallCheck: 0, + pilotCheck: 0, + reason: 'Running with damaged hip', + }; + } + if (this.runningWithDestroyedLegRequiresCheck() + && hasDamagedLeg + && damagedLegRequiresCheck) { return { fallCheck: 0, pilotCheck: 0, @@ -591,20 +678,22 @@ export class MekRules extends UnitTypeRulesBase { reason: 'Running with damaged leg' }; } - if (!hasDamagedLegActuators) return null; - - const hasDamagedHip = critSlots.some(slot => { - if (!slot.name || !slot.loc || !this.isCritUnavailable(slot)) return false; - if (!LEG_LOCATIONS.has(slot.loc)) return false; - return this.isNamedCrit(slot, 'Hip'); - }); - if (!hasDamagedHip) return null; - - return { - fallCheck: 0, - pilotCheck: 0, - reason: 'Running with damaged hip' - }; + if (hasDamagedLegActuators) { + const hasDamagedHip = critSlots.some(slot => { + if (!slot.name || !slot.loc || !this.isCritUnavailable(slot)) return false; + if (!LEG_LOCATIONS.has(slot.loc)) return false; + return this.isNamedCrit(slot, 'Hip'); + }); + if (hasDamagedHip) { + return { + fallCheck: 0, + pilotCheck: 0, + kind: 'damaged-hip-movement', + reason: 'Running with damaged hip' + }; + } + } + return null; } protected damagedGyroMovementPSRCheck(moveMode: 'run' | 'jump'): PSRCheck | null { @@ -613,7 +702,7 @@ export class MekRules extends UnitTypeRulesBase { return { fallCheck: 2, pilotCheck: 2, - reason: 'Jumping with damaged heavy-duty gyro', + reason: 'Jumping with damaged HD gyro', ignorePreExistingGyro: true, }; } @@ -628,10 +717,26 @@ export class MekRules extends UnitTypeRulesBase { return isQuadruped ? destroyedLegsCount >= 2 : destroyedLegsCount >= 1; } + protected destroyedLegMovementPSRModifier( + _moveMode: 'run' | 'jump', + _isQuadruped: boolean, + _destroyedLegsCount: number, + ): number { + return 0; + } + protected runningWithDestroyedLegRequiresCheck(): boolean { return true; } + protected runningDamageCheckRequiresHexMovement(): boolean { + return true; + } + + protected destroyedLegsApplyHipMovementCheck(isQuadruped: boolean, destroyedLegsCount: number): boolean { + return isQuadruped && destroyedLegsCount === 2; + } + override evaluateLegDestroyed(location: string, hits: number): void { if (!LEG_LOCATIONS.has(location)) return; const turnState = this.unit.turnState(); @@ -727,7 +832,7 @@ export class MekRules extends UnitTypeRulesBase { } private computeMovementHeat(turnState: TurnState): number { - const moveMode = turnState.moveMode(); + const moveMode = turnState.effectiveMoveMode(); const hasXXLEngine = this.hasXXLEngine(); const superCooledMyomerActive = this.hasActiveSuperCooledMyomer(); if (moveMode === 'stationary') { @@ -873,10 +978,17 @@ export class MekRules extends UnitTypeRulesBase { const destroyedMASC = critSlots.some(slot => this.isNamedCrit(slot, 'MASC') && this.isCritUnavailable(slot)); const hasSupercharger = critSlots.some(slot => this.isNamedCrit(slot, 'Supercharger')); const destroyedSupercharger = critSlots.some(slot => this.isNamedCrit(slot, 'Supercharger') && this.isCritUnavailable(slot)); - const jumpJetsCount = critSlots.filter(slot => this.isNamedCrit(slot, 'Jump Jet') || this.isNamedCrit(slot, 'JumpJet')).length; - const destroyedJumpJetsCount = critSlots.filter(slot => (this.isNamedCrit(slot, 'Jump Jet') || this.isNamedCrit(slot, 'JumpJet')) && this.isCritUnavailable(slot)).length; - const UMUCount = critSlots.filter(slot => this.isNamedCrit(slot, 'UMU')).length; - const destroyedUMUCount = critSlots.filter(slot => this.isNamedCrit(slot, 'UMU') && this.isCritUnavailable(slot)).length; + const jumpJetSlots = critSlots.filter(slot => + this.isNamedCrit(slot, 'Jump Jet') || this.isNamedCrit(slot, 'JumpJet')); + const UMUSlots = critSlots.filter(slot => this.isNamedCrit(slot, 'UMU')); + const jumpJetsCount = new Set(jumpJetSlots.map(slot => slot.id)).size; + const destroyedJumpJetsCount = new Set(jumpJetSlots + .filter(slot => this.isCritUnavailable(slot)) + .map(slot => slot.id)).size; + const UMUCount = new Set(UMUSlots.map(slot => slot.id)).size; + const destroyedUMUCount = new Set(UMUSlots + .filter(slot => this.isCritUnavailable(slot)) + .map(slot => slot.id)).size; const hasPartialWings = critSlots.some(slot => slot.eq?.hasFlag('F_PARTIAL_WING')); const destroyedPartialWingsCount = hasPartialWings ? critSlots.filter(slot => slot.eq?.hasFlag('F_PARTIAL_WING') && this.isCritUnavailable(slot)).length : 0; const partialWingsHeatBonus = hasPartialWings ? Math.max(0, 3 - destroyedPartialWingsCount) : 0; @@ -1022,24 +1134,15 @@ export class MekRules extends UnitTypeRulesBase { let preExisting = 0; const modifiers: PSRCheck[] = []; - const internalLocations = this.unit.locations?.internal; - const config = inferMekConfigFromLocations(internalLocations?.keys() ?? []); - let undamagedLegs = true; + const { config, destroyedLegs } = this.currentLegState(); + const undamagedLegs = destroyedLegs.length === 0; // Calculate pre-existing leg destruction modifiers. If a leg is gone, is gone. - for (const loc of getMekLegLocations(config)) { - if (!internalLocations?.has(loc)) continue; - if (this.unit.isInternalLocDestroyed(loc)) { - undamagedLegs = false; - ignoreLeg.add(loc); // Track destroyed legs, we ignore further modifiers on that leg - const modifier = this.destroyedLegPSR(config === 'Quad').pilotCheck; - preExisting += modifier; - modifiers.push({ - pilotCheck: modifier, - loc, - reason: 'Leg Destroyed', - }); - } + for (const loc of destroyedLegs) { + ignoreLeg.add(loc); // Track destroyed legs, we ignore further modifiers on that leg } + const destroyedLegModifiers = this.getPreExistingDestroyedLegPSRModifiers(config, destroyedLegs); + preExisting += destroyedLegModifiers.reduce((total, modifier) => total + (modifier.pilotCheck ?? 0), 0); + modifiers.push(...destroyedLegModifiers); if (undamagedLegs) { if (config === 'Tripod') { preExisting -= 1; // Tripod unit with all legs intact gets -1 modifier @@ -1097,16 +1200,12 @@ export class MekRules extends UnitTypeRulesBase { reason: "Mounts Hardened Armor" }); } - const modularArmorPanelsCount = critSlots.filter(slot => this.isNamedCrit(slot, 'Modular Armor')).length; - if (modularArmorPanelsCount > 0) { - const destroyedModularArmorPanelsCount = critSlots.filter(slot => this.isNamedCrit(slot, 'Modular Armor') && (slot.destroyed || ((slot.consumed ?? 0) >= 10))).length; - if (destroyedModularArmorPanelsCount < modularArmorPanelsCount) { - preExisting += 1; // Modular armor gives +1 modifier (until destroyed or fully consumed) - modifiers.push({ - pilotCheck: 1, - reason: "Mounts Modular Armor" - }); - } + if (this.modularArmorState().active) { + preExisting += 1; // Modular armor gives +1 modifier (until destroyed or fully consumed) + modifiers.push({ + pilotCheck: 1, + reason: "Mounts Modular Armor" + }); } const hasSmallOrTorsoCockpit = critSlots.some(slot => slot.loc && ((this.isNamedCrit(slot, 'Cockpit') && this.isNamedCrit(slot, 'Small')) @@ -1140,6 +1239,34 @@ export class MekRules extends UnitTypeRulesBase { return { modifier: finalModifier, modifiers: sortPSRModifiers(modifiers) }; }); + protected getPreExistingDestroyedLegPSRModifiers( + config: MekConfig, + destroyedLegs: readonly string[], + ): PSRCheck[] { + if (config !== 'Quad') { + const modifier = this.destroyedLegPSR(false).pilotCheck; + return destroyedLegs.map(loc => ({ + pilotCheck: modifier, + loc, + reason: 'Leg Destroyed', + })); + } + + let modifier = 0; + if (destroyedLegs.length <= 2) { + modifier = destroyedLegs.length; + } else if (destroyedLegs.length === 3) { + modifier = this.destroyedLegPSR(false).pilotCheck; + } + if (modifier === 0) return []; + return [{ + pilotCheck: modifier, + ...(destroyedLegs.length === 1 && { loc: destroyedLegs[0] }), + reason: 'Leg Destroyed', + ...(destroyedLegs.length > 1 && { modifierReason: `Legs Destroyed (${destroyedLegs.length})` }), + }]; + } + protected getPreExistingLegActuatorPSRModifiers( critSlots: readonly CriticalSlot[], ignoreLeg: Set, @@ -1158,7 +1285,6 @@ export class MekRules extends UnitTypeRulesBase { for (const [loc, slots] of slotsByLocation) { const destroyedHipsCount = slots.filter(slot => this.isNamedCrit(slot, 'Hip')).length; const destroyedLegActuatorsCount = slots.filter(slot => this.isNamedCrit(slot, 'Leg')).length; - const destroyedFeetCount = slots.filter(slot => this.isNamedCrit(slot, 'Foot')).length; if (destroyedHipsCount > 0) { modifiers.push({ pilotCheck: destroyedHipsCount * this.hipPSRModifier, @@ -1176,16 +1302,6 @@ export class MekRules extends UnitTypeRulesBase { : `Leg Actuators Destroyed (${destroyedLegActuatorsCount})`, }); } - if (destroyedFeetCount > 0) { - modifiers.push({ - pilotCheck: destroyedFeetCount, - loc, - reason: 'Foot Actuator(s) Destroyed', - modifierReason: destroyedFeetCount === 1 - ? 'Foot Actuator Destroyed' - : `Foot Actuators Destroyed (${destroyedFeetCount})`, - }); - } } return { modifier: modifiers.reduce((total, modifier) => total + (modifier.pilotCheck ?? 0), 0), @@ -1232,13 +1348,84 @@ export class MekRules extends UnitTypeRulesBase { } override isMotiveModeAvailable(moveMode: MotiveModes): boolean { - return moveMode !== 'run' || this.computeBaseMovementProfile()?.runDisabled !== true; + if (this.immobile()) return moveMode === 'stationary'; + const movement = this.movementState(); + if (moveMode === 'walk') return (movement?.walk ?? 0) > 0; + if (moveMode === 'run') { + return (movement?.run ?? 0) > 0 || this.getRunningMinimumMovementDistance() > 0; + } + if (moveMode === 'jump') return (movement?.jump ?? 0) > 0; + if (moveMode === 'UMU') return (movement?.UMU ?? 0) > 0; + return true; + } + + protected getRunningMinimumMovementDistance(): number { + return 0; + } + + protected destroyedLegStandThreshold(config: MekConfig): number { + return config === 'Quad' ? 3 : 1; + } + + protected isMovementPSRFoldedIntoStandAttempt(_turnState: TurnState): boolean { + return false; + } + + protected isDestroyedLegStandException(config: MekConfig, destroyedLegs: number): boolean { + return destroyedLegs === this.destroyedLegStandThreshold(config); + } + + override canStandUp(turnState: TurnState): boolean { + if (turnState.carefulStand()) return false; + if (!turnState.unitState.hasCondition('prone')) return false; + if (this.immobile()) return false; + if (this.gyroPSRModifierHitCount() >= this.gyroDestructionHitThreshold()) return false; + // Standing normally costs MP, and Minimum Movement can only reduce that + // cost when the Mek still has at least 1 usable Walking MP. + if ((this.movementState()?.walk ?? 0) < 1) return false; + const { config, destroyedLegs, destroyedArms, hasIntactLeg } = this.currentLegState(); + if (!hasIntactLeg || destroyedLegs.length > this.destroyedLegStandThreshold(config)) return false; + return config === 'Quad' || destroyedLegs.length !== 1 || destroyedArms.length !== 2; + } + + override canStandWithoutPSR(_turnState: TurnState): boolean { + const { config, allLegsIntact } = this.currentLegState(); + return config === 'Quad' && allLegsIntact; + } + + override canCarefulStand(turnState: TurnState): boolean { + if (!this.supportsCarefulStand) return false; + if (!this.canStandUp(turnState)) return false; + const walkingMp = this.movementState()?.walk ?? 0; + const standAttemptMp = Math.max(0, turnState.standAttempts() ?? 0) * 2; + return walkingMp - standAttemptMp >= 3; + } + + override getStandAttemptMovementMode(turnState: TurnState): MotiveModes | null { + const { config, destroyedLegs } = this.currentLegState(); + if (this.isDestroyedLegStandException(config, destroyedLegs.length) + || this.movementState()?.walk === 1) { + return 'run'; + } + return turnState.moveMode() === 'run' ? 'run' : 'walk'; + } + + override getMovementPointsSpent(turnState: TurnState): number { + const standAttemptMp = Math.max(0, turnState.standAttempts() ?? 0) * 2; + const moveMode = turnState.moveMode(); + const movementCapacity = moveMode === null + ? 0 + : this.getEffectiveMaxDistanceForMoveMode(moveMode, turnState) ?? 0; + return turnState.carefulStand() + ? movementCapacity + : standAttemptMp; // We return the full value so the user can clearly see they over-attempted } override getEffectiveMaxDistanceForMoveMode(moveMode: MotiveModes, turnState: TurnState): number | null { if (moveMode !== 'run') return this.getMaxDistanceForMoveMode(moveMode); const movement = this.movementState(); - if (!movement || movement.run === 0) return 0; + if (!movement) return 0; + if (movement.run === 0) return this.getRunningMinimumMovementDistance(); const runValueCoeff = 1.5 + this.unit.getRunMovementMultiplierBonus(turnState); const armorModifierOnRun = (this.unit.getUnit().armorType === 'Hardened') ? -1 : 0; @@ -1499,16 +1686,32 @@ export class MekRules extends UnitTypeRulesBase { const unit = this.unit.getUnit(); if (!unit) return null; - let walkValue = unit.walk; - let jumpValue = unit.jump; - let UMUValue = unit.umu; + const systemsStatus = this.systemsStatus(); + const mobilityEquipment = this.mobilityEquipmentState(); + const restoredWalk = unit.walk + this.restoredEquipmentWalkMP(mobilityEquipment); + const restoredRun = Math.max( + 0, + Math.round(restoredWalk * 1.5) + (unit.armorType === 'Hardened' ? -1 : 0), + ); + const baselineJump = this.equipmentAdjustedJumpBaseline( + unit.jump, + this.installedComponentQuantity('F_JUMP_JET'), + mobilityEquipment, + ); + const baselineUMU = this.equipmentAdjustedUMUBaseline( + unit.umu, + this.installedComponentQuantity('F_UMU'), + mobilityEquipment, + ); + let walkValue = restoredWalk; + let jumpValue = baselineJump; + let UMUValue = baselineUMU; let moveImpaired = false; - const systemsStatus = this.systemsStatus(); const internalLocations = systemsStatus.internalLocations; const legMovement = this.applyLegDamageToMovement( walkValue, - unit.run, + restoredRun, systemsStatus, internalLocations.has('LL') && internalLocations.has('RL'), internalLocations.has('RLL') && internalLocations.has('FLL') @@ -1516,12 +1719,12 @@ export class MekRules extends UnitTypeRulesBase { ); walkValue = legMovement.walk; moveImpaired = legMovement.moveImpaired; + const actuatorMovementReduction = this.legActuatorMovementReduction(); if (legMovement.applyActuatorDamage) { - walkValue -= systemsStatus.destroyedLegActuatorsCount; - walkValue -= systemsStatus.destroyedFeetCount; + walkValue -= actuatorMovementReduction; } - walkValue = Math.max(0, Math.min(unit.walk, walkValue)); - if (systemsStatus.destroyedLegActuatorsCount != 0 || systemsStatus.destroyedFeetCount != 0) { + walkValue = Math.max(0, Math.min(restoredWalk, walkValue)); + if (actuatorMovementReduction !== 0) { moveImpaired = true; } @@ -1531,7 +1734,10 @@ export class MekRules extends UnitTypeRulesBase { } else { jumpValue = Math.max(0, jumpValue - systemsStatus.destroyedJumpJetsCount); if (systemsStatus.hasPartialWings) { - jumpValue -= this.partialWingJumpBonus(0) - this.partialWingJumpBonus(); + jumpValue = Math.max( + 0, + jumpValue - (this.partialWingJumpBonus(0) - this.partialWingJumpBonus()), + ); } } @@ -1542,17 +1748,107 @@ export class MekRules extends UnitTypeRulesBase { } return { + baselineWalk: restoredWalk, + baselineJump, + baselineUMU, walk: walkValue, runDisabled: legMovement.runDisabled, runCap: legMovement.runCap, jump: jumpValue, UMU: UMUValue, moveImpaired, - jumpImpaired: (jumpValue < unit.jump), - UMUImpaired: (UMUValue < unit.umu), + jumpImpaired: jumpValue < baselineJump, + UMUImpaired: UMUValue < baselineUMU, }; } + protected legActuatorMovementReduction(): number { + const systemsStatus = this.systemsStatus(); + return systemsStatus.destroyedLegActuatorsCount + systemsStatus.destroyedFeetCount; + } + + private restoredEquipmentWalkMP(equipment: MekMobilityEquipmentState): number { + return (equipment.modularArmorInstalled && !equipment.modularArmorActive ? 1 : 0) + + equipment.mediumShieldsInstalled - equipment.mediumShieldsActive + + equipment.largeShieldsInstalled - equipment.largeShieldsActive; + } + + private installedComponentQuantity(flag: EquipmentFlag): number { + return this.unit.getUnit().comp.reduce((total, component) => + total + (component.eq?.hasFlag(flag) ? component.q : 0), 0); + } + + private equipmentAdjustedJumpBaseline( + storedJump: number, + installedJumpJets: number, + equipment: MekMobilityEquipmentState, + ): number { + if (equipment.largeShieldsActive > 0) return 0; + + const hasInstalledPenalty = equipment.modularArmorInstalled + || equipment.mediumShieldsInstalled > 0 + || equipment.largeShieldsInstalled > 0; + if (!hasInstalledPenalty) return storedJump; + if (installedJumpJets === 0) return 0; + + const partialWingBonus = this.partialWingJumpBonus(0); + const activePenalty = equipment.mediumShieldsActive + + (equipment.modularArmorActive ? 1 : 0); + return Math.max(0, installedJumpJets + partialWingBonus - activePenalty); + } + + private equipmentAdjustedUMUBaseline( + storedUMU: number, + installedUMUs: number, + equipment: MekMobilityEquipmentState, + ): number { + if (equipment.largeShieldsActive > 0) return 0; + if (equipment.largeShieldsInstalled === 0) return storedUMU; + return installedUMUs; + } + + private mobilityEquipmentState(): MekMobilityEquipmentState { + const modularArmor = this.modularArmorState(); + const config = inferMekConfigFromLocations(this.unit.locations?.internal.keys() ?? []); + const shields = config === 'Quad' + ? [] + : this.unit.getInventory().filter(entry => + entry.equipment?.hasFlag('F_SHIELD') + && entry.equipment.hasAnyFlag(['S_SHIELD_LARGE', 'S_SHIELD_MEDIUM'])); + const mediumShields = shields.filter(entry => entry.equipment?.hasFlag('S_SHIELD_MEDIUM')); + const largeShields = shields.filter(entry => entry.equipment?.hasFlag('S_SHIELD_LARGE')); + return { + modularArmorInstalled: modularArmor.installed, + modularArmorActive: modularArmor.active, + mediumShieldsInstalled: mediumShields.length, + mediumShieldsActive: mediumShields.filter(entry => this.shieldRetainsMobilityPenalty(entry)).length, + largeShieldsInstalled: largeShields.length, + largeShieldsActive: largeShields.filter(entry => this.shieldRetainsMobilityPenalty(entry)).length, + }; + } + + private modularArmorState(): { installed: boolean; active: boolean } { + const panels = this.unit.getCritSlots().filter(slot => + slot.eq?.hasFlag('F_MODULAR_ARMOR') || this.isNamedCrit(slot, 'Modular Armor')); + return { + installed: panels.length > 0, + active: panels.some(slot => !this.isCritUnavailable(slot) && (slot.consumed ?? 0) < 10), + }; + } + + protected shieldRetainsMobilityPenalty(entry: MountedEquipment): boolean { + // Core removes the Mobility Modifier as soon as either live shield + // track reaches 0, or when the shield itself has no surviving slot. + if (entry.committedDestroyed() || this.allShieldCriticalsUnavailable(entry)) return false; + const state = this.getShieldDamageState(entry); + return state !== null && state.absorption > 0 && state.capacity > 0; + } + + protected allShieldCriticalsUnavailable(entry: MountedEquipment): boolean { + const criticals = this.entryCriticalSlots(entry); + return criticals.length > 0 && criticals.every(slot => !this.unit.isEquipmentOperational(slot)); + } + protected applyLegDamageToMovement( walk: number, unitRun: number, @@ -1588,6 +1884,7 @@ export class MekRules extends UnitTypeRulesBase { } else if (damage.destroyedLegsCount === 3) { walk = Math.min(walk, 1); runCap = Math.min(unitRun, 2); + applyActuatorDamage = false; } else { walk = 0; runDisabled = true; @@ -1617,9 +1914,9 @@ export class MekRules extends UnitTypeRulesBase { maxWalk: 0, run: 0, maxRun: 0, - jumpImpaired: unit.jump > 0, + jumpImpaired: baseMovement.baselineJump > 0, jump: 0, - UMUImpaired: unit.umu > 0, + UMUImpaired: baseMovement.baselineUMU > 0, UMU: 0, }; } @@ -1672,7 +1969,7 @@ export class MekRules extends UnitTypeRulesBase { } return { - moveImpaired: baseMovement.moveImpaired || (walkValue < unit.walk), + moveImpaired: baseMovement.moveImpaired || (walkValue < baseMovement.baselineWalk), walk: walkValue, maxWalk: maxWalkValue, run: runValue, @@ -1841,7 +2138,7 @@ export class MekRules extends UnitTypeRulesBase { ? { damage, text: `+${damage}`, weakened: false } : { damage: null, text: '—', weakened: false }; } - const damage = this.currentShieldDamageAbsorption(entry, location, profile); + const damage = this.getShieldDamageState(entry)?.absorption ?? 0; return { damage, text: `${damage}`, @@ -2229,45 +2526,93 @@ export class MekRules extends UnitTypeRulesBase { for (const entry of this.unit.getMountedEquipmentByFlag('F_SHIELD')) { const profile = resolveShieldProfile(entry.equipment); if (!profile || !this.shieldMountsAt(entry, loc)) continue; - // Equipment status is committed-only and already owns mount, location, critical, and shield-capacity - // destruction. In particular, a pending hit must not remove the bonus before damage is committed. - if (this.unit.getEquipmentStatus(entry) === 'destroyed') continue; + // Pending damage must not remove the committed bonus early. + const state = this.getShieldDamageState(entry); + if (!state || state.absorption === 0 || state.capacity === 0) continue; return profile.bashBonus; } return 0; } - private currentShieldDamageAbsorption( + /** + * Effective damage shown on a shield DA/DC track. Critical-slot and arm + * actuator losses are derived rather than persisted as ordinary shield + * hits, so repairing a critical cannot accidentally repair combat damage. + */ + getShieldTrackHits(trackLocation: string, includePending = false): number | null { + const match = /^(DA|DC)(LA|RA)$/.exec(trackLocation); + if (!match) return null; + + const track = match[1] as 'DA' | 'DC'; + const loc = match[2] as ArmLocation; + const entry = this.unit.getMountedEquipmentByFlag('F_SHIELD') + .find(candidate => this.shieldMountsAt(candidate, loc)); + if (!entry) return null; + + const profile = resolveShieldProfile(entry.equipment); + const state = this.getShieldDamageState(entry, includePending); + if (!profile || !state) return null; + const maximum = track === 'DA' ? profile.damageAbsorption : profile.damageCapacity; + const remaining = track === 'DA' ? state.absorption : state.capacity; + return Math.min(maximum, Math.max(0, maximum - remaining)); + } + + private getShieldDamageState( entry: MountedEquipment, - loc: ArmLocation | undefined, - profile: ShieldProfile, - ): number { - return this.shieldDamageState( - profile, - this.shieldCriticalSlots(entry, loc).filter(slot => !!slot.destroyed).length, - loc, - ).absorption; + includePending = false, + ): { absorption: number; capacity: number } | null { + const profile = resolveShieldProfile(entry.equipment); + if (!profile) return null; + + const loc = this.shieldArmLocation(entry); + const mountUnavailable = entry.committedDestroyed() || (includePending && entry.isDestroying()); + const locationUnavailable = loc !== undefined && (includePending + ? this.unit.isInternalLocDestroyed(loc) + : this.unit.isInternalLocCommittedDestroyed(loc)); + if (mountUnavailable || locationUnavailable) return { absorption: 0, capacity: 0 }; + + const destroyedCriticalCount = this.shieldCriticalSlots(entry, loc) + .filter(slot => includePending ? this.isDestroyedOrDestroyingCrit(slot) : !!slot.destroyed) + .length; + return this.shieldDamageState(profile, destroyedCriticalCount, loc, includePending); } private shieldDamageState( profile: ShieldProfile, destroyedCriticalCount: number, loc: ArmLocation | undefined, + includePending = false, ): { absorption: number; capacity: number } { - let actuatorPenalty = 0; - if (loc) { - const armStatus = this.systemsStatus().locationModifiers[loc]; - if (armStatus?.destroyedShoulder) actuatorPenalty += 2; - if (armStatus?.destroyedUpperArms) actuatorPenalty++; - if (armStatus?.destroyedLowerArms) actuatorPenalty++; - if (armStatus?.destroyedHand) actuatorPenalty++; - } + const actuatorPenalty = loc ? this.shieldActuatorPenalty(loc, includePending) : 0; + const absorptionHits = loc + ? (includePending ? this.unit.getArmorHits(`DA${loc}`) : this.unit.getCommittedArmorHits(`DA${loc}`)) + : 0; + const capacityHits = loc + ? (includePending ? this.unit.getArmorHits(`DC${loc}`) : this.unit.getCommittedArmorHits(`DC${loc}`)) + : 0; return { - absorption: Math.max(0, profile.damageAbsorption - destroyedCriticalCount - actuatorPenalty), - capacity: Math.max(0, profile.damageCapacity - (destroyedCriticalCount * 5) - actuatorPenalty), + absorption: Math.max(0, profile.damageAbsorption - destroyedCriticalCount - actuatorPenalty - absorptionHits), + capacity: Math.max(0, profile.damageCapacity - (destroyedCriticalCount * 5) - actuatorPenalty - capacityHits), }; } + private shieldActuatorPenalty(loc: ArmLocation, includePending: boolean): number { + if (!includePending) { + const armStatus = this.systemsStatus().locationModifiers[loc]; + return (armStatus?.destroyedShoulder ? 2 : 0) + + (armStatus?.destroyedUpperArms ? 1 : 0) + + (armStatus?.destroyedLowerArms ? 1 : 0) + + (armStatus?.destroyedHand ? 1 : 0); + } + + const unavailable = (name: string) => this.unit.getCritSlots().some(slot => + slot.loc === loc && this.isNamedCrit(slot, name) && this.isDestroyedOrDestroyingCrit(slot)); + return (unavailable('Shoulder') ? 2 : 0) + + (unavailable('Upper Arm') ? 1 : 0) + + (unavailable('Lower Arm') ? 1 : 0) + + (unavailable('Hand') ? 1 : 0); + } + private shieldCriticalSlots(entry: MountedEquipment, loc?: string): CriticalSlot[] { return this.entryCriticalSlots(entry).filter(slot => !loc || slot.loc === loc); } diff --git a/src/app/services/unit-svg-mek.service.ts b/src/app/services/unit-svg-mek.service.ts index 44281d7c8..c992fe63b 100644 --- a/src/app/services/unit-svg-mek.service.ts +++ b/src/app/services/unit-svg-mek.service.ts @@ -42,10 +42,27 @@ export class UnitSvgMekService extends UnitSvgService { } private updateLifeSupportPilotDamageWarning(heat: HeatProfile): void { - const warning = this.unit.svg()?.getElementById('lifeSupportPilotDamageWarning'); - if (!warning) return; + const svg = this.unit.svg(); + if (!svg) return; const heatHits = this.mekRules.heatLifeSupportPilotHits(heat.next ?? heat.current); + const builtInWarning = svg.getElementById('heatLifeSupportWarning'); + if (builtInWarning) { + if (heatHits === 0) { + builtInWarning.setAttribute('display', 'none'); + builtInWarning.removeAttribute('aria-label'); + builtInWarning.removeAttribute('data-pilot-hits'); + return; + } + builtInWarning.setAttribute('aria-label', `${heatHits} Life Support heat pilot hit${heatHits === 1 ? '' : 's'}`); + builtInWarning.setAttribute('data-pilot-hits', heatHits.toString()); + builtInWarning.removeAttribute('display'); + return; + } + + const warning = svg.getElementById('lifeSupportPilotDamageWarning'); + if (!warning) return; + const oxygenHits = this.mekRules.submergedLifeSupportPilotHits(); const iconKinds: ('heat' | 'oxygen')[] = []; for (let i = 0; i < heatHits; i++) iconKinds.push('heat'); @@ -275,7 +292,9 @@ export class UnitSvgMekService extends UnitSvgService { const movement = this.mekRules.movementState(); if (!movement) return; - const runWarning = movement.maxRun > 0 ? this.unit.rules.getCommittedDamageMovementModePSRCheck('run') : null; + const runWarning = this.unit.rules.isMotiveModeAvailable('run') + ? this.unit.rules.getCommittedDamageMovementModePSRCheck('run') + : null; const jumpWarning = movement.jump > 0 ? this.unit.rules.getCommittedDamageMovementModePSRCheck('jump') : null; const jumpMoveElementId = svg.getElementById('mpJump') ? 'mpJump' : (svg.getElementById('mp_2') ? 'mp_2' : null); @@ -289,7 +308,8 @@ export class UnitSvgMekService extends UnitSvgService { const warningEl = svg.getElementById(`${moveElementId}-psr-warning`) as SVGTextElement | null; if (!warningEl) return; - const currentMoveMode = this.unit.turnState().moveMode(); + const turnState = this.unit.turnState(); + const currentMoveMode = turnState.effectiveMoveMode(); let selectedMoveElementId: string | null = null; if (currentMoveMode === 'walk' || currentMoveMode === 'stationary') { selectedMoveElementId = 'mpWalk'; @@ -310,9 +330,12 @@ export class UnitSvgMekService extends UnitSvgService { warningEl.style.display = 'block'; const warningMoveMode = moveElementId === 'mpRun' ? 'run' : 'jump'; const isCurrentMoveMode = currentMoveMode === warningMoveMode; - const moveDistance = this.unit.turnState().moveDistance(); - const triggersPsr = moveDistance !== null && (warningMoveMode === 'jump' || moveDistance > 0); - warningEl.classList.toggle('noPsrCheck', !isCurrentMoveMode || !triggersPsr); + const triggersPsr = isCurrentMoveMode + && this.unit.rules.getCommittedDamageMovementModePSRCheck( + warningMoveMode, + turnState.moveDistance(), + ) !== null; + warningEl.classList.toggle('noPsrCheck', !triggersPsr); if (!selectedMoveElementId) { warningEl.classList.remove('currentMoveMode', 'unusedMoveMode'); @@ -448,7 +471,12 @@ export class UnitSvgMekService extends UnitSvgService { if (!loc || !linkedLoc) return; if (!shieldInfo[loc]) { const d = locations[loc]; - shieldInfo[loc] = { committed: d?.armor ?? 0, total: (d?.armor ?? 0) + (d?.pendingArmor ?? 0), idx: 0 }; + shieldInfo[loc] = { + committed: this.mekRules.getShieldTrackHits(loc) ?? d?.armor ?? 0, + total: this.mekRules.getShieldTrackHits(loc, true) + ?? (d?.armor ?? 0) + (d?.pendingArmor ?? 0), + idx: 0, + }; } const s = shieldInfo[loc]; this.updatePip(pip, ++s.idx, s.committed, s.total, initial); @@ -458,7 +486,13 @@ export class UnitSvgMekService extends UnitSvgService { this.unit.locations?.armor.forEach(entry => { const el = svg.querySelector(`.shield:not(.pip)[loc="${entry.loc}"]`); if (!el) return; - const shieldExhausted = this.unit.isArmorLocDestroyed('DC' + entry.loc) || this.unit.isArmorLocDestroyed('DA' + entry.loc); + const shieldExhausted = ['DA', 'DC'].some(prefix => { + const trackLoc = `${prefix}${entry.loc}`; + const points = this.unit.getArmorPoints(trackLoc); + const hits = this.mekRules.getShieldTrackHits(trackLoc, true) + ?? this.unit.getArmorHits(trackLoc); + return points > 0 && hits >= points; + }); if (shieldExhausted || this.unit.isInternalLocDestroyed(entry.loc)) { el.classList.add('damaged'); } else { diff --git a/src/app/services/unit-svg.service.ts b/src/app/services/unit-svg.service.ts index 7b461a5a6..dae04ae38 100644 --- a/src/app/services/unit-svg.service.ts +++ b/src/app/services/unit-svg.service.ts @@ -25,6 +25,7 @@ import type { ToHitResolution } from '../models/rules/game-rules'; import type { InventoryControlRuntimeEntryState, InventoryControlRuntimeRangeKey, InventoryControlRuntimeTarget } from '../models/inventory-control-runtime-state.model'; import { isRiscLaserPulseModule, RISC_LASER_PULSE_MODE, selectedRiscLaserMode } from '../equipment-handlers/risc-laser-pulse-module.handler'; import { getSvgTextLines, measureSvgTextCanvas, writeSvgTextLines } from '../utils/svg-text.util'; +import { buildHeatSummaryRows } from '../utils/heat-summary.util'; const INVENTORY_CONTROL_SELECTION_COLOR_PROPERTY = '--inventory-control-selection-color'; const HEAT_PROJECTION_ORIGINAL_OVERFLOW_STROKE = 'data-heat-projection-original-stroke'; @@ -632,8 +633,8 @@ export class UnitSvgService { const projection = this.unit.turnState().heatProjection(); const manualTarget = heat.next; const hasUserTarget = manualTarget !== undefined; - const automationsEnabled = this.unit.useAutomations(); - const showProjection = automationsEnabled + const heatAutomationMode = this.unit.automationMode('heatAndDissipation'); + const showProjection = heatAutomationMode !== 'no' && !hasUserTarget && this.unit.turnState().hasPendingHeatResolution(); const heatDataPanel = svg.querySelector('#heatDataPanel'); @@ -816,10 +817,14 @@ export class UnitSvgService { svg.querySelector('#projection-arrow')?.remove(); } if (!this.unit.readOnly()) { - if (automationsEnabled) { + if (heatAutomationMode !== 'no') { svg.querySelector('#heat-projection-target-marker')?.remove(); svg.querySelector('#heat-selected-weapons-target-marker')?.remove(); - this.updateHeatProjectionPreview(heat); + if (hasUserTarget) { + this.clearHeatProjectionPreview(heatScale); + } else { + this.updateHeatProjectionPreview(heat); + } } else { this.clearHeatProjectionPreview(heatScale); this.updateManualHeatProjectionMarkers(heatScale, heat); @@ -1711,7 +1716,7 @@ export class UnitSvgService { summaryProjection.projected ); // Update move mode display - const moveMode = turnState.moveMode(); + const moveMode = turnState.effectiveMoveMode(); const moveModifier = turnState.getAttackMovementModifier(); let el: SVGElement | null = null; const mpWalkEl = svg.getElementById('mpWalk') as SVGElement | null; @@ -1787,29 +1792,19 @@ export class UnitSvgService { ): void { const heatSourcesText = svg.getElementById('damagedEngineHeatText') as SVGTextElement | null; if (!heatSourcesText) return; - - const positiveSources = sources.filter(source => source.value > 0 && source.id !== 'heat-dissipation-deficit'); - const normalizedBalance = Number.isFinite(dissipationBalance) ? dissipationBalance : 0; - const normalizedConsumption = Number.isFinite(consumedDissipation) ? Math.max(0, consumedDissipation) : 0; - const hasResidualAfterClipping = normalizedBalance > 0 - && normalizedConsumption < normalizedBalance - && projectedHeat === 0; - const dissipationLabelSuffix = hasResidualAfterClipping ? ` (-${normalizedBalance})` : ''; - const dissipationText = hasResidualAfterClipping - ? `-${normalizedConsumption}` - : `${normalizedBalance > 0 ? '-' : '+'}${Math.abs(normalizedBalance)}`; - const showDissipation = normalizedBalance < 0 - || (normalizedBalance > 0 && normalizedConsumption > 0); - const lines: Array<{ text: string; fill?: string }> = [ - ...positiveSources.map(source => ({ - text: `${this.heatSourceSummaryLabel(source)}: ${this.formatSignedModifier(source.value)}`, - fill: source.inventorySelection ? 'orange' : undefined, - })), - ...(showDissipation ? [{ - text: `Sink${dissipationLabelSuffix}: ${dissipationText}`, - fill: normalizedBalance > 0 ? '#2070d1' : '#f00', - }] : []), - ]; + const lines = buildHeatSummaryRows( + sources, + dissipationBalance, + consumedDissipation, + projectedHeat, + ).map(row => ({ + text: `${row.label}: ${this.formatSignedModifier(row.value)}`, + fill: row.inventorySelection + ? 'orange' + : row.kind === 'sink' + ? (row.value < 0 ? '#2070d1' : '#f00') + : undefined, + })); if (lines.length === 0) { heatSourcesText.textContent = ''; heatSourcesText.setAttribute('display', 'none'); @@ -1834,11 +1829,6 @@ export class UnitSvgService { }); } - private heatSourceSummaryLabel(source: UnitHeatSource): string { - if (source.id === 'damaged-engine') return 'Engine'; - return source.label; - } - private updateHeatProjectionPreview(heat: HeatProfile): void { const svg = this.unit.svg(); const heatScale = svg?.getElementById('heatScale') as SVGGElement | null; diff --git a/src/app/utils/heat-effects.util.spec.ts b/src/app/utils/heat-effects.util.spec.ts new file mode 100644 index 000000000..f9fcf3908 --- /dev/null +++ b/src/app/utils/heat-effects.util.spec.ts @@ -0,0 +1,139 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import { AeroRules } from '../models/rules/aero-rules'; +import { MekRules } from '../models/rules/mek-rules'; +import { getHeatEffectDescriptors } from './heat-effects.util'; + +describe('heat effects', () => { + function createUnit(options: { + type?: 'Mek' | 'Aero'; + shutdown?: boolean; + randomMovement?: boolean; + outOfControl?: boolean; + heatControlRecovery?: boolean; + pilotState?: string; + activePilotCrewId?: number | null; + lifeSupportDamaged?: boolean; + lifeSupportHits?: number; + drowningHits?: number; + } = {}): CBTForceUnit { + const type = options.type ?? 'Mek'; + const lifeSupportDamaged = options.lifeSupportDamaged ?? (options.lifeSupportHits ?? 0) > 0; + return { + rules: { + heatScale: type === 'Aero' ? AeroRules.HEAT_SCALE : MekRules.HEAT_SCALE, + hasDamagedLifeSupport: () => lifeSupportDamaged, + heatLifeSupportPilotHits: () => lifeSupportDamaged ? options.lifeSupportHits ?? 0 : 0, + submergedLifeSupportPilotHits: () => options.drowningHits ?? 0, + getActivePilotCrewId: () => options.activePilotCrewId !== undefined + ? options.activePilotCrewId + : options.pilotState === 'unconscious' ? null : 0, + }, + getCrewMember: () => ({ getState: () => options.pilotState ?? 'healthy' }), + getCondition: (condition: string) => condition === 'shutdown' + ? options.shutdown ?? false + : condition === 'random-movement' + ? options.randomMovement ?? false + : condition === 'out-of-control' ? options.outOfControl ?? false : false, + turnState: () => ({ + getPendingUnitChecks: () => options.heatControlRecovery + ? [{ kind: 'aero-control-recovery', cause: 'heat-random-movement' }] + : [], + }), + getUnit: () => ({ type }), + getCritSlots: () => [], + getInventory: () => [], + } as unknown as CBTForceUnit; + } + + it('does not turn movement or fire modifiers into queued checks', () => { + expect(getHeatEffectDescriptors(createUnit(), 13)).toEqual([]); + }); + + it('uses only the highest applicable Mek shutdown threshold', () => { + expect(getHeatEffectDescriptors(createUnit(), 26)).toEqual([ + jasmine.objectContaining({ kind: 'heat-shutdown', target: 10 }), + ]); + }); + + it('represents heat-30 shutdown as an automatic failure', () => { + expect(getHeatEffectDescriptors(createUnit(), 30)).toEqual([ + jasmine.objectContaining({ + kind: 'heat-shutdown', + automaticOutcome: 'failed', + }), + ]); + }); + + it('allows a heat shutdown roll when an alternate crew member is piloting', () => { + expect(getHeatEffectDescriptors(createUnit({ + pilotState: 'unconscious', + activePilotCrewId: 2, + }), 18)).toEqual([ + jasmine.objectContaining({ kind: 'heat-shutdown', target: 6 }), + ]); + }); + + it('uses the independent aerospace random-movement and pilot-damage thresholds', () => { + expect(getHeatEffectDescriptors(createUnit({ type: 'Aero' }), 27)).toEqual([ + jasmine.objectContaining({ kind: 'heat-shutdown', target: 10 }), + jasmine.objectContaining({ kind: 'heat-random-movement', target: 10 }), + jasmine.objectContaining({ kind: 'heat-pilot-damage', target: 9, hits: 1 }), + ]); + }); + + it('automatically clears heat shutdown and heat-sourced random movement below their lowest thresholds', () => { + expect(getHeatEffectDescriptors(createUnit({ + type: 'Aero', + shutdown: true, + heatControlRecovery: true, + }), 4)).toEqual([ + jasmine.objectContaining({ kind: 'heat-shutdown', automaticOutcome: 'success' }), + jasmine.objectContaining({ kind: 'heat-random-movement', automaticOutcome: 'success' }), + ]); + }); + + it('does not clear random movement from a non-heat source when heat drops below 5', () => { + expect(getHeatEffectDescriptors(createUnit({ type: 'Aero', randomMovement: true }), 4)).toEqual([]); + }); + + it('does not mistake an unrelated out-of-control condition for random movement', () => { + expect(getHeatEffectDescriptors(createUnit({ type: 'Aero', outOfControl: true }), 4)).toEqual([]); + }); + + it('ends a persisted heat-control recovery when heat drops below 5', () => { + expect(getHeatEffectDescriptors(createUnit({ type: 'Aero', heatControlRecovery: true }), 4)).toEqual([ + jasmine.objectContaining({ kind: 'heat-random-movement', automaticOutcome: 'success' }), + ]); + }); + + it('captures deterministic Life Support damage in the generated effect', () => { + expect(getHeatEffectDescriptors(createUnit({ lifeSupportHits: 2 }), 20)).toContain( + jasmine.objectContaining({ + kind: 'heat-life-support', + automaticOutcome: 'failed', + hits: 2, + }), + ); + }); + + it('does not apply potential Life Support heat hits while Life Support is operational', () => { + expect(getHeatEffectDescriptors(createUnit({ + lifeSupportDamaged: false, + lifeSupportHits: 2, + }), 20).some(effect => effect.kind === 'heat-life-support')).toBeFalse(); + }); + + it('keeps submerged Life Support damage as its own deterministic End Phase effect', () => { + expect(getHeatEffectDescriptors(createUnit({ drowningHits: 1 }), 0)).toEqual([ + jasmine.objectContaining({ + kind: 'life-support-drowning', + automaticOutcome: 'failed', + hits: 1, + }), + ]); + }); +}); diff --git a/src/app/utils/heat-effects.util.ts b/src/app/utils/heat-effects.util.ts new file mode 100644 index 000000000..344bd40c7 --- /dev/null +++ b/src/app/utils/heat-effects.util.ts @@ -0,0 +1,181 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import { AmmoEquipment } from '../models/equipment.model'; +import type { CriticalSlot } from '../models/force-serialization'; +import type { MountedEquipment } from '../models/mounted-equipment.model'; +import { resolveHeatScaleEffects } from '../models/rules/heat-management'; +import { + ammoExplosionDamagePerShot, + ammoRackSize, + criticalSlotTotalAmmo, +} from './mek-critical-hit.util'; + +export type HeatEffectKind = + | 'heat-shutdown' + | 'heat-ammo-explosion' + | 'heat-random-movement' + | 'heat-pilot-damage' + | 'heat-life-support' + | 'life-support-drowning'; + +export interface HeatEffectDescriptor { + readonly kind: HeatEffectKind; + readonly description: string; + readonly target?: number; + readonly automaticOutcome?: 'success' | 'failed'; + readonly hits?: number; +} + +export function isPilotHitHeatEffect(descriptor: HeatEffectDescriptor): boolean { + return descriptor.kind === 'heat-pilot-damage' + || descriptor.kind === 'heat-life-support' + || descriptor.kind === 'life-support-drowning'; +} + +export interface HeatAmmoExplosionCandidate { + readonly id: string; + readonly equipment: string; + readonly location?: string; + readonly damagePerShot: number; + readonly shots: number; + readonly rawDamage: number; + readonly slot?: CriticalSlot; + readonly entry?: MountedEquipment; +} + +export function getHeatEffectDescriptors(unit: CBTForceUnit, heat: number): HeatEffectDescriptor[] { + const effects = resolveHeatScaleEffects(unit.rules.heatScale, heat); + const descriptors: HeatEffectDescriptor[] = []; + if (effects.shutdownTarget !== undefined) { + const consciousPilot = unit.rules.getActivePilotCrewId() !== null; + descriptors.push({ + kind: 'heat-shutdown', + description: effects.shutdownTarget >= 100 + ? `Automatic shutdown!` + : `Avoid shutdown at heat ${heat}.`, + ...(effects.shutdownTarget >= 100 || !consciousPilot + ? { automaticOutcome: 'failed' as const } + : { target: effects.shutdownTarget }), + }); + } else if (unit.getCondition('shutdown') && heat < 14) { + descriptors.push({ + kind: 'heat-shutdown', + description: `Heat ${heat} permits an automatic restart.`, + automaticOutcome: 'success', + }); + } + if (effects.ammoExplosionTarget !== undefined && getHeatAmmoExplosionCandidates(unit).length > 0) { + descriptors.push({ + kind: 'heat-ammo-explosion', + description: `Avoid an ammunition explosion at heat ${heat}.`, + target: effects.ammoExplosionTarget, + }); + } + if (effects.randomMovementTarget !== undefined) { + descriptors.push({ + kind: 'heat-random-movement', + description: `Keep the navigation and piloting systems online at heat ${heat}.`, + target: effects.randomMovementTarget, + }); + } else if (heat < 5 && unit.turnState().getPendingUnitChecks().some(check => + check.kind === 'aero-control-recovery' + && check.cause === 'heat-random-movement')) { + descriptors.push({ + kind: 'heat-random-movement', + description: `Heat ${heat} ends the heat-induced random-movement effect.`, + automaticOutcome: 'success', + }); + } + if (effects.pilotDamageTarget !== undefined) { + descriptors.push({ + kind: 'heat-pilot-damage', + description: `Avoid pilot damage from heat ${heat}.`, + target: effects.pilotDamageTarget, + hits: 1, + }); + } + const lifeSupportHits = unit.rules.heatLifeSupportPilotHits(heat); + if (lifeSupportHits > 0) { + descriptors.push({ + kind: 'heat-life-support', + description: `Damaged life support (${lifeSupportHits} pilot hit${lifeSupportHits === 1 ? '' : 's'})`, + automaticOutcome: 'failed', + hits: lifeSupportHits, + }); + } + const drowningHits = unit.rules.submergedLifeSupportPilotHits(); + if (drowningHits > 0) { + descriptors.push({ + kind: 'life-support-drowning', + description: 'Damaged life support (1 pilot hit).', + automaticOutcome: 'failed', + hits: drowningHits, + }); + } + return descriptors; +} + +/** Candidates tied after both mandated comparisons remain a controller choice. */ +export function getPreferredHeatAmmoExplosionCandidates(unit: CBTForceUnit): HeatAmmoExplosionCandidate[] { + const candidates = getHeatAmmoExplosionCandidates(unit); + if (candidates.length <= 1) return candidates; + const highestDamage = Math.max(...candidates.map(candidate => candidate.damagePerShot)); + const mostDestructive = candidates.filter(candidate => candidate.damagePerShot === highestDamage); + const mostShots = Math.max(...mostDestructive.map(candidate => candidate.shots)); + return mostDestructive.filter(candidate => candidate.shots === mostShots); +} + +export function getHeatAmmoExplosionCandidates(unit: CBTForceUnit): HeatAmmoExplosionCandidate[] { + return unit.getUnit().type === 'Aero' + ? getAeroHeatAmmoExplosionCandidates(unit) + : getMekHeatAmmoExplosionCandidates(unit); +} + +function getMekHeatAmmoExplosionCandidates(unit: CBTForceUnit): HeatAmmoExplosionCandidate[] { + return unit.getCritSlots().flatMap(slot => { + const ammo = slot.eq; + if (!(ammo instanceof AmmoEquipment) + || !ammo.isExplosive() + || slot.destroyed + || slot.destroying + || (slot.loc && unit.isInternalLocDestroyed(slot.loc))) return []; + const shots = Math.max(0, criticalSlotTotalAmmo(unit, slot, ammo) - (slot.consumed ?? 0)); + const damagePerShot = ammoRackSize(ammo) * ammoExplosionDamagePerShot(ammo); + const rawDamage = shots * damagePerShot; + return shots > 0 && rawDamage > 0 ? [{ + id: slot.id, + equipment: ammo.name, + location: slot.loc, + damagePerShot, + shots, + rawDamage, + slot, + }] : []; + }); +} + +function getAeroHeatAmmoExplosionCandidates(unit: CBTForceUnit): HeatAmmoExplosionCandidate[] { + return unit.getInventory().flatMap(entry => { + const ammo = entry.equipment; + if (!(ammo instanceof AmmoEquipment) + || !ammo.isExplosive() + || entry.committedDestroyed() + || entry.isDestroying()) return []; + const shots = Math.max(0, (entry.totalAmmo ?? ammo.getShots(unit.gameRules, unit.getEquipmentRegistry())) + - (entry.consumed ?? 0)); + const damagePerShot = ammoRackSize(ammo) * ammoExplosionDamagePerShot(ammo); + const rawDamage = shots * damagePerShot; + return shots > 0 && rawDamage > 0 ? [{ + id: entry.id, + equipment: entry.getDisplayName(), + location: Array.from(entry.locations ?? [])[0], + damagePerShot, + shots, + rawDamage, + entry, + }] : []; + }); +} diff --git a/src/app/utils/heat-summary.util.spec.ts b/src/app/utils/heat-summary.util.spec.ts new file mode 100644 index 000000000..b275cd68f --- /dev/null +++ b/src/app/utils/heat-summary.util.spec.ts @@ -0,0 +1,26 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { buildHeatSummaryRows } from './heat-summary.util'; + +describe('buildHeatSummaryRows', () => { + it('lists each heat source and the cooling actually consumed', () => { + expect(buildHeatSummaryRows([ + { id: 'movement', label: 'Movement', value: 2 }, + { id: 'weapons', label: 'Weapons', value: 12 }, + { id: 'damaged-engine', label: 'Damaged Engine', value: 5 }, + ], 10, 10, 13)).toEqual([ + { id: 'movement', label: 'Movement', value: 2, kind: 'source' }, + { id: 'weapons', label: 'Weapons', value: 12, kind: 'source' }, + { id: 'damaged-engine', label: 'Engine', value: 5, kind: 'source' }, + { id: 'heat-sink', label: 'Sink', value: -10, kind: 'sink' }, + ]); + }); + + it('shows unused sink capacity without pretending it removed heat below zero', () => { + expect(buildHeatSummaryRows([], 28, 22, 0)).toEqual([ + { id: 'heat-sink', label: 'Sink (-28)', value: -22, kind: 'sink' }, + ]); + }); +}); diff --git a/src/app/utils/heat-summary.util.ts b/src/app/utils/heat-summary.util.ts new file mode 100644 index 000000000..9a6803001 --- /dev/null +++ b/src/app/utils/heat-summary.util.ts @@ -0,0 +1,47 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { UnitHeatSource } from '../models/rules/unit-type-rules'; + +const HEAT_DISSIPATION_DEFICIT_SOURCE_ID = 'heat-dissipation-deficit'; + +export interface HeatSummaryRow { + readonly id: string; + readonly label: string; + readonly value: number; + readonly kind: 'source' | 'sink'; + readonly inventorySelection?: boolean; +} + +/** Builds the exact source/sink rows used to explain a heat projection. */ +export function buildHeatSummaryRows( + sources: readonly UnitHeatSource[], + dissipationBalance: number, + consumedDissipation: number, + projectedHeat: number, +): HeatSummaryRow[] { + const rows: HeatSummaryRow[] = sources + .filter(source => source.value > 0 && source.id !== HEAT_DISSIPATION_DEFICIT_SOURCE_ID) + .map(source => ({ + id: source.id, + label: source.id === 'damaged-engine' ? 'Engine' : source.label, + value: source.value, + kind: 'source', + ...(source.inventorySelection ? { inventorySelection: true } : {}), + })); + const balance = Number.isFinite(dissipationBalance) ? dissipationBalance : 0; + const consumed = Number.isFinite(consumedDissipation) ? Math.max(0, consumedDissipation) : 0; + const clippedAtZero = balance > 0 && consumed < balance && projectedHeat === 0; + + if (balance < 0 || (balance > 0 && consumed > 0)) { + rows.push({ + id: 'heat-sink', + label: clippedAtZero ? `Sink (-${balance})` : 'Sink', + value: balance > 0 ? -(clippedAtZero ? consumed : balance) : Math.abs(balance), + kind: 'sink', + }); + } + + return rows; +} From b90846651d5b2c4af68d15c92705a067bb387685 Mon Sep 17 00:00:00 2001 From: exeea Date: Sat, 22 Aug 2026 00:37:35 +0200 Subject: [PATCH 14/87] options --- .../options-dialog/options-dialog.component.spec.ts | 2 ++ .../components/options-dialog/options-dialog.component.ts | 5 +++++ src/app/models/options.model.ts | 1 + src/app/services/options.service.spec.ts | 1 + src/app/services/options.service.ts | 6 ++++++ 5 files changed, 15 insertions(+) diff --git a/src/app/components/options-dialog/options-dialog.component.spec.ts b/src/app/components/options-dialog/options-dialog.component.spec.ts index 17f6b862b..0362c40b4 100644 --- a/src/app/components/options-dialog/options-dialog.component.spec.ts +++ b/src/app/components/options-dialog/options-dialog.component.spec.ts @@ -79,6 +79,7 @@ describe('OptionsDialogComponent', () => { options: () => ({ unitServers: [], cbtAutomationOptions: { + pilotSkillCheck: 'ask', heatAndDissipation: 'yes', heatEffects: 'ask', pilotHitsAndConsciousness: 'ask', @@ -94,6 +95,7 @@ describe('OptionsDialogComponent', () => { component.onCbtAutomationModeChange('heatAndDissipation', 'ask'); expect(setOption).toHaveBeenCalledOnceWith('cbtAutomationOptions', { + pilotSkillCheck: 'ask', heatAndDissipation: 'ask', heatEffects: 'ask', pilotHitsAndConsciousness: 'ask', diff --git a/src/app/components/options-dialog/options-dialog.component.ts b/src/app/components/options-dialog/options-dialog.component.ts index 53ec54c92..4566e7fac 100644 --- a/src/app/components/options-dialog/options-dialog.component.ts +++ b/src/app/components/options-dialog/options-dialog.component.ts @@ -105,6 +105,11 @@ const CBT_AUTOMATION_MODES: ReadonlyArray<{ value: AutomationMode; label: string { value: 'no', label: 'No' }, ]; const CBT_AUTOMATION_OPTIONS: readonly CBTAutomationOptionDefinition[] = [ + { + key: 'pilotSkillCheck', + label: 'Piloting skill checks', + description: 'Resolve end-of-phase Piloting Skill Rolls. No keeps the warnings available but skips them when the phase closes.', + }, { key: 'heatAndDissipation', label: 'Heat and dissipation', diff --git a/src/app/models/options.model.ts b/src/app/models/options.model.ts index e4df498c3..02e235c10 100644 --- a/src/app/models/options.model.ts +++ b/src/app/models/options.model.ts @@ -31,6 +31,7 @@ export type UnitSearchViewMode = typeof OPTION_VALUES.unitSearchViewMode[number] export type AutomationMode = typeof OPTION_VALUES.automationMode[number]; export interface CBTAutomationOptions { + pilotSkillCheck: AutomationMode; heatAndDissipation: AutomationMode; heatEffects: AutomationMode; pilotHitsAndConsciousness: AutomationMode; diff --git a/src/app/services/options.service.spec.ts b/src/app/services/options.service.spec.ts index e8f04f8aa..361f1d512 100644 --- a/src/app/services/options.service.spec.ts +++ b/src/app/services/options.service.spec.ts @@ -51,6 +51,7 @@ describe('OptionsService', () => { const service = await createService(); expect(service.options().cbtAutomationOptions).toEqual({ + pilotSkillCheck: 'ask', heatAndDissipation: 'no', heatEffects: 'ask', pilotHitsAndConsciousness: 'ask', diff --git a/src/app/services/options.service.ts b/src/app/services/options.service.ts index d373065d2..33809987a 100644 --- a/src/app/services/options.service.ts +++ b/src/app/services/options.service.ts @@ -43,6 +43,7 @@ const DEFAULT_OPTIONS: Options = { syncZoomBetweenSheets: true, trackPhaseAndTurn: true, cbtAutomationOptions: { + pilotSkillCheck: 'ask', heatAndDissipation: 'no', heatEffects: 'ask', pilotHitsAndConsciousness: 'ask', @@ -195,6 +196,11 @@ function resolveCBTOptionalRules(saved: Options | null | undefined): CBTOptional function resolveCBTAutomationOptions(saved: Options | null | undefined): CBTAutomationOptions { const defaults = DEFAULT_OPTIONS.cbtAutomationOptions; return { + pilotSkillCheck: resolveSavedValue( + saved?.cbtAutomationOptions?.pilotSkillCheck, + defaults.pilotSkillCheck, + OPTION_VALUES.automationMode, + ), heatAndDissipation: resolveSavedValue( saved?.cbtAutomationOptions?.heatAndDissipation, defaults.heatAndDissipation, From 5cfaf22a66bacfa293924d252e94dde0195c8eaa Mon Sep 17 00:00:00 2001 From: exeea Date: Sat, 22 Aug 2026 00:40:37 +0200 Subject: [PATCH 15/87] . --- src/app/models/rules/mek-rules.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/models/rules/mek-rules.ts b/src/app/models/rules/mek-rules.ts index 3f9f78841..9c8e52142 100644 --- a/src/app/models/rules/mek-rules.ts +++ b/src/app/models/rules/mek-rules.ts @@ -822,7 +822,7 @@ export class MekRules extends UnitTypeRulesBase { if (damagedEngineHeat > 0) { sources.push({ id: 'damaged-engine', - label: 'Damaged Engine', + label: 'Engine', value: damagedEngineHeat, signature: this.damagedEngineSignature(), }); From 47073dfbae20ccf4a8ce3aa439cecd01bae42f7b Mon Sep 17 00:00:00 2001 From: exeea Date: Sat, 22 Aug 2026 00:41:33 +0200 Subject: [PATCH 16/87] . --- src/app/models/rules/mek-rules.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/models/rules/mek-rules.ts b/src/app/models/rules/mek-rules.ts index 9c8e52142..3f9f78841 100644 --- a/src/app/models/rules/mek-rules.ts +++ b/src/app/models/rules/mek-rules.ts @@ -822,7 +822,7 @@ export class MekRules extends UnitTypeRulesBase { if (damagedEngineHeat > 0) { sources.push({ id: 'damaged-engine', - label: 'Engine', + label: 'Damaged Engine', value: damagedEngineHeat, signature: this.damagedEngineSignature(), }); From 627ffa85eeec1c9be1b4a5fa65b798b5c9d02b1e Mon Sep 17 00:00:00 2001 From: exeea Date: Sun, 23 Aug 2026 00:43:46 +0200 Subject: [PATCH 17/87] styles --- .../options-dialog.component.ts | 2 +- src/styles.scss | 41 ++++++++++++------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/app/components/options-dialog/options-dialog.component.ts b/src/app/components/options-dialog/options-dialog.component.ts index 4566e7fac..5e32b6a29 100644 --- a/src/app/components/options-dialog/options-dialog.component.ts +++ b/src/app/components/options-dialog/options-dialog.component.ts @@ -108,7 +108,7 @@ const CBT_AUTOMATION_OPTIONS: readonly CBTAutomationOptionDefinition[] = [ { key: 'pilotSkillCheck', label: 'Piloting skill checks', - description: 'Resolve end-of-phase Piloting Skill Rolls. No keeps the warnings available but skips them when the phase closes.', + description: 'Resolve end-of-phase Piloting Skill Rolls. "No" keeps the warnings available but skips them when the phase closes.', }, { key: 'heatAndDissipation', diff --git a/src/styles.scss b/src/styles.scss index 7db0d11dd..6af4ee0e0 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -660,28 +660,41 @@ hr { --btn-text-color: #000; } - &.success:not(.selected) { - --btn-text-color: #7dcc80; - --btn-border-color: #7dcc80; + &.success { + &.selected { + background-color: #0f0; + } - &:hover { - background-color: #030; - --btn-text-color: #54ef59; - --btn-border-color: #54ef59; + &:not(.selected) { + --btn-text-color: #7dcc80; + --btn-border-color: #7dcc80; + &:hover { + background-color: #030; + --btn-text-color: #54ef59; + --btn-border-color: #54ef59; + } } } - &.danger:not(.selected) { - --btn-text-color: #d00; - --btn-border-color: #a00; + &.danger { + &.selected { + background-color: #f00; + color: #fff; + } - &:hover { - background-color: #300; - --btn-text-color: red; - --btn-border-color: red; + &:not(.selected) { + --btn-text-color: #d00; + --btn-border-color: #a00; + + &:hover { + background-color: #300; + --btn-text-color: red; + --btn-border-color: red; + } } } + &.warning { --btn-text-color: rgb(222, 144, 0); From e3b1a00a156802ef13a9eecb2f12bdd7c6dbd1f2 Mon Sep 17 00:00:00 2001 From: exeea Date: Sun, 23 Aug 2026 10:50:02 +0200 Subject: [PATCH 18/87] as summary print portrait --- src/app/utils/as-print-reference.util.ts | 2 ++ src/app/utils/as-summary-print.util.spec.ts | 6 ------ src/app/utils/as-summary-print.util.ts | 1 - 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/app/utils/as-print-reference.util.ts b/src/app/utils/as-print-reference.util.ts index 2e4e88129..7f029cb7d 100644 --- a/src/app/utils/as-print-reference.util.ts +++ b/src/app/utils/as-print-reference.util.ts @@ -325,6 +325,8 @@ export function getASPrintRulesReferenceStyles(): string { font-size: 10pt; text-transform: uppercase; letter-spacing: 0.02em; + break-after: avoid; + page-break-after: avoid; } .as-reference-formation, diff --git a/src/app/utils/as-summary-print.util.spec.ts b/src/app/utils/as-summary-print.util.spec.ts index 517b5928b..1fa289a14 100644 --- a/src/app/utils/as-summary-print.util.spec.ts +++ b/src/app/utils/as-summary-print.util.spec.ts @@ -6,12 +6,6 @@ import type { PrintAllOptions } from '../models/print-options.model'; import { ASSummaryPrintUtil } from './as-summary-print.util'; describe('ASSummaryPrintUtil', () => { - it('prints the summary packet in landscape', () => { - const styles = getPrintStyles('none'); - - expect(styles).toContain('size: landscape'); - }); - it('builds a summary-only print container with its rules reference', async () => { const unit = { id: 'u1', diff --git a/src/app/utils/as-summary-print.util.ts b/src/app/utils/as-summary-print.util.ts index 130ef4e0c..2eb544159 100644 --- a/src/app/utils/as-summary-print.util.ts +++ b/src/app/utils/as-summary-print.util.ts @@ -226,7 +226,6 @@ export class ASSummaryPrintUtil { } @page { - size: landscape; margin: ${printMargin === 'none' ? '0in' : '0.25in'} !important; } } From 004c6978e759a32299c4c966c6bbadd298d1ca46 Mon Sep 17 00:00:00 2001 From: exeea Date: Sun, 23 Aug 2026 12:17:59 +0200 Subject: [PATCH 19/87] manifest --- public/manifest.webmanifest | 6 +++--- src/index.html | 16 ++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index b8ec3f191..eb41ab79e 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -1,8 +1,8 @@ { "lang": "en-US", - "name": "MekBay", + "name": "MekBay: BattleTech Force Builder", "short_name": "MekBay", - "description": "A web-based application for managing forces and viewing record sheets.", + "description": "Build and manage BattleTech forces for Classic and Alpha Strike with interactive or printable record sheets.", "theme_color": "#292929", "background_color": "#292929", "display": "standalone", @@ -34,4 +34,4 @@ "launch_handler": { "client_mode": "auto" } -} \ No newline at end of file +} diff --git a/src/index.html b/src/index.html index a944c3301..fabc6caf9 100644 --- a/src/index.html +++ b/src/index.html @@ -2,27 +2,27 @@ - MekBay + MekBay: BattleTech Force Builder & Record Sheets - - - + + + - + - - + + - + From 5f89a67660113405dbe13c4388c43250c26d9af4 Mon Sep 17 00:00:00 2001 From: exeea Date: Sun, 23 Aug 2026 12:22:26 +0200 Subject: [PATCH 20/87] ld --- src/index.html | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/index.html b/src/index.html index fabc6caf9..f77430fc9 100644 --- a/src/index.html +++ b/src/index.html @@ -28,6 +28,42 @@ + From 177c194e5c8405acfd906abf253ae3643890a200 Mon Sep 17 00:00:00 2001 From: exeea Date: Sun, 23 Aug 2026 12:30:16 +0200 Subject: [PATCH 21/87] canonical --- src/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.html b/src/index.html index f77430fc9..d35334111 100644 --- a/src/index.html +++ b/src/index.html @@ -7,6 +7,7 @@ + From 8b8a43c2f14bc56d5c37b66c548b84ae4c3520b8 Mon Sep 17 00:00:00 2001 From: exeea Date: Sun, 23 Aug 2026 12:47:05 +0200 Subject: [PATCH 22/87] noindex --- public/privacy.html | 3 ++- public/robots.txt | 4 ++++ public/sitemap.xml | 6 ++++++ public/terms.html | 3 ++- src/app/app.html | 2 +- 5 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 public/robots.txt create mode 100644 public/sitemap.xml diff --git a/public/privacy.html b/public/privacy.html index e1ae5d564..0f57d90a8 100644 --- a/public/privacy.html +++ b/public/privacy.html @@ -5,6 +5,7 @@ MekBay Privacy Policy + @@ -125,4 +126,4 @@

Contact

MekBay is a fan-made project and is not affiliated with Catalyst Game Labs, The Topps Company, Inc., or Microsoft.

- \ No newline at end of file + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 000000000..ee1706e59 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://mekbay.com/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 000000000..c897e9f16 --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,6 @@ + + + + https://mekbay.com/ + + diff --git a/public/terms.html b/public/terms.html index 84ecc3989..9b2d600d6 100644 --- a/public/terms.html +++ b/public/terms.html @@ -5,6 +5,7 @@ MekBay Terms of Service + @@ -123,4 +124,4 @@

Contact

MekBay is a fan-made project and is not affiliated with Catalyst Game Labs, The Topps Company, Inc., or Microsoft.

- \ No newline at end of file + diff --git a/src/app/app.html b/src/app/app.html index edd0ca26f..780e38cfb 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -183,7 +183,7 @@ - -
+
@@ -163,7 +168,12 @@ export interface MekCriticalChanceDialogData {
Criticals
@for (manualResult of manualResults; track manualResult.label) { - }
@@ -212,6 +222,9 @@ export class MekCriticalChanceDialogComponent { } onFinished(event: { readonly results: readonly number[] }): void { + if (event.results.length === 2) { + this.data.onRollChange?.([event.results[0], event.results[1]]); + } this.resolveRoll(event.results.reduce((total, die) => total + die, 0)); } diff --git a/src/app/components/page-viewer/mek-critical-dialog.component.scss b/src/app/components/page-viewer/mek-critical-dialog.component.scss index 6dd343c07..912b06347 100644 --- a/src/app/components/page-viewer/mek-critical-dialog.component.scss +++ b/src/app/components/page-viewer/mek-critical-dialog.component.scss @@ -19,28 +19,30 @@ text-align: center; } -.critical-dialog-body p { - margin: 0; -} - .critical-random-row { display: flex; align-items: center; justify-content: center; gap: 10px; -} - -.critical-dice-trigger { cursor: pointer; -} -.critical-dice-trigger:focus-visible { - outline: 2px solid var(--bt-yellow); - outline-offset: 4px; + &:not(.roll-disabled):hover .random-button, + &:not(.roll-disabled):focus-within .random-button { + opacity: 1; + } + + &.roll-disabled { + cursor: not-allowed; + } } -.critical-dice-trigger[aria-disabled="true"] { - cursor: default; +.critical-dice-trigger { + cursor: inherit; + + &:focus-visible { + outline: 2px solid var(--bt-yellow); + outline-offset: 4px; + } } .critical-table-hint { @@ -135,20 +137,27 @@ .guided-progress, .critical-result { - color: var(--text-color); font-weight: 700; } -.explosion-protection { +.guided-progress { + color: var(--text-color); +} + +.explosion-protection, +.case-ii-check { align-self: stretch; display: flex; - align-items: flex-start; flex-direction: column; gap: 4px; padding: 0.65rem 0.75rem; + text-align: left; +} + +.explosion-protection { + align-items: flex-start; border: 1px solid var(--border-color); background: color-mix(in srgb, var(--background-highlight-bright) 12%, transparent); - text-align: left; } .protection-badge { @@ -168,17 +177,11 @@ } .case-ii-check { - align-self: stretch; - display: flex; - flex-direction: column; - gap: 4px; - padding: 0.65rem 0.75rem; border: 1px solid #e8a64a; background: rgba(120, 80, 0, 0.2); color: var(--text-color-secondary); font-size: 0.88em; line-height: 1.35; - text-align: left; } .case-ii-check strong { @@ -206,14 +209,14 @@ .critical-result { font-size: 1.05em; color: var(--danger); -} -.critical-result.no-critical { - color: var(--success); -} + &.no-critical { + color: var(--success); + } -.critical-result.reroll { - color: var(--text-color); + &.reroll { + color: var(--text-color); + } } .explosion-result { @@ -222,22 +225,22 @@ border: 1px solid var(--danger); background: rgba(120, 0, 0, 0.25); color: var(--text-color); -} -.explosion-result.pending-explosion { - border-color: #e8a64a; - background: rgba(120, 80, 0, 0.25); + &.pending-explosion { + border-color: #e8a64a; + background: rgba(120, 80, 0, 0.25); + } } .actions { gap: 4px; flex-wrap: wrap; -} -.actions .bt-button { - flex: 1 1 0; - width: auto; - min-width: 0; + .bt-button { + flex: 1 1 0; + width: auto; + min-width: 0; + } } .critical-manual-results { @@ -279,9 +282,40 @@ .critical-slot-options { align-self: stretch; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 6px; +} + +.critical-slot-block { + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + gap: 2px; + min-width: 0; +} + +.critical-slot-single-block { + grid-template-columns: minmax(0, 1fr); +} + +.critical-slot-first-die { + display: grid; + place-items: center; + min-width: 0; + border: 1px solid var(--border-color); + background: var(--border-color); + color: var(--text-color); + font-size: 0.75em; + font-weight: 800; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; +} + +.critical-slot-block-rows { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1px; + min-width: 0; } .critical-slot-option { @@ -308,12 +342,20 @@ align-self: stretch; display: flex; align-items: center; + gap: 5px; min-width: 0; padding: 4px 8px; border-right: 1px solid var(--border-color); color: var(--text-color-secondary); } +.critical-slot-explosion-icon { + flex: 0 0 auto; + width: 16px; + height: 16px; + fill: #f60; +} + .critical-slot-hit-button { width: 100%; min-width: 0; @@ -348,6 +390,11 @@ line-height: 1; } +.critical-slot-option.critical-slot-collapsed .critical-slot-explosion-icon { + width: 9px; + height: 9px; +} + .critical-slot-option.critical-slot-hit .critical-slot-number { background-color: var(--danger); font-size: 1.2em; diff --git a/src/app/components/page-viewer/mek-critical-roll-dialog.component.spec.ts b/src/app/components/page-viewer/mek-critical-roll-dialog.component.spec.ts index 911cab76b..53a14813b 100644 --- a/src/app/components/page-viewer/mek-critical-roll-dialog.component.spec.ts +++ b/src/app/components/page-viewer/mek-critical-roll-dialog.component.spec.ts @@ -8,6 +8,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; import { MiscEquipment, WeaponEquipment } from '../../models/equipment.model'; import type { CriticalSlot, SerializedPendingUnitCheck } from '../../models/force-serialization'; +import type { MountedEquipment } from '../../models/mounted-equipment.model'; import { CORE_2026_GAME_RULES, TW_GAME_RULES } from '../../models/rules/game-rules'; import { MekCriticalHitAutomationService } from '../../services/mek-critical-hit-automation.service'; import { MekCriticalRollDialogComponent } from './mek-critical-roll-dialog.component'; @@ -16,8 +17,10 @@ describe('MekCriticalRollDialogComponent', () => { let fixture: ComponentFixture; let caseIISlot: CriticalSlot; let criticalSlots: CriticalSlot[]; + let inventoryEntries: MountedEquipment[]; let dialogRef: { close: jasmine.Spy }; let previewRoll: jasmine.Spy; + let previewSlot: jasmine.Spy; let applyRoll: jasmine.Spy; let dialogData: { unit: CBTForceUnit; @@ -30,6 +33,7 @@ describe('MekCriticalRollDialogComponent', () => { caseIICheckRequired?: boolean; caseIICheckPassed?: boolean; caseIICheckResult?: 'resolve' | 'discard'; + caseIICheckRoll?: readonly [number, number]; canUndoToChance?: boolean; }; let getPendingCriticalHit: jasmine.Spy; @@ -68,8 +72,10 @@ describe('MekCriticalRollDialogComponent', () => { { id: 'small-laser@LT', name: secondWeapon.name, loc: 'LT', slot: 2, eq: secondWeapon }, { id: 'destroyed-laser@LT', name: weapon.name, loc: 'LT', slot: 3, eq: weapon, hits: 1, destroyed: 1 }, ]; + inventoryEntries = []; dialogRef = { close: jasmine.createSpy('close') }; previewRoll = jasmine.createSpy('previewRoll').and.returnValue(null); + previewSlot = jasmine.createSpy('previewSlot').and.returnValue(null); applyRoll = jasmine.createSpy('applyRoll').and.resolveTo({ cancelled: false, outcome: { @@ -110,6 +116,7 @@ describe('MekCriticalRollDialogComponent', () => { slotsVersion(); return criticalSlots.find(candidate => candidate.loc === location && candidate.slot === slot) ?? null; }, + getInventory: () => inventoryEntries, getUnit: () => ({ comp: [] }), } as unknown as CBTForceUnit; dialogData = { @@ -125,7 +132,7 @@ describe('MekCriticalRollDialogComponent', () => { providers: [ provideZonelessChangeDetection(), { provide: DialogRef, useValue: dialogRef }, - { provide: MekCriticalHitAutomationService, useValue: { previewRoll, applyRoll } }, + { provide: MekCriticalHitAutomationService, useValue: { previewRoll, previewSlot, applyRoll } }, { provide: DIALOG_DATA, useValue: dialogData }, ], }).compileComponents(); @@ -147,16 +154,32 @@ describe('MekCriticalRollDialogComponent', () => { expect(element.querySelector('.protection-note')?.textContent).toContain('Caps internal damage at 1'); }); - it('animates to dice faces for a valid critical slot', () => { + it('rolls from the full random row and animates to valid critical-slot dice faces', () => { spyOn(Math, 'random').and.returnValue(0); const roller = fixture.componentInstance.roller()!; const roll = spyOn(roller, 'roll'); - fixture.componentInstance.roll(); + (fixture.nativeElement.querySelector('.critical-random-row') as HTMLElement).click(); expect(roll).toHaveBeenCalledOnceWith([1, 1]); }); + it('shows a not-allowed cursor across the random row when rolling is unavailable', () => { + const row = fixture.nativeElement.querySelector('.critical-random-row') as HTMLElement; + const diceTrigger = row.querySelector('.critical-dice-trigger') as HTMLElement; + const randomButton = row.querySelector('.random-button') as HTMLButtonElement; + + expect(getComputedStyle(row).cursor).toBe('pointer'); + + fixture.componentInstance.resolving.set(true); + fixture.detectChanges(); + + expect(row.classList).toContain('roll-disabled'); + expect(getComputedStyle(row).cursor).toBe('not-allowed'); + expect(getComputedStyle(diceTrigger).cursor).toBe('not-allowed'); + expect(randomButton.disabled).toBeTrue(); + }); + it('omits sequence UNDO when a queued critical has no chance step to return to', () => { const actions = fixture.nativeElement.querySelectorAll( '.actions .bt-button', @@ -169,6 +192,44 @@ describe('MekCriticalRollDialogComponent', () => { expect(fixture.nativeElement.querySelector('.critical-sequence-undo')).toBeNull(); }); + it('automatically selects the only slot when opened from critical chance', () => { + fixture.destroy(); + criticalSlots[2].destroyed = 1; + slotsVersion.update(version => version + 1); + dialogData.manual = true; + dialogData.pendingCriticalId = undefined; + dialogData.canUndoToChance = true; + fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture.detectChanges(); + + expect(fixture.componentInstance.selectedSlotIndex()).toBe(0); + const hitButtons = fixture.nativeElement.querySelectorAll( + '.critical-slot-hit-button', + ) as NodeListOf; + expect(hitButtons).toHaveSize(1); + expect(hitButtons[0].textContent).toContain('UNDO'); + expect(fixture.componentInstance.primaryLabel()).toBe('APPLY'); + }); + + it('does not automatically select the only slot for a manual critical hit', () => { + fixture.destroy(); + criticalSlots[2].destroyed = 1; + slotsVersion.update(version => version + 1); + dialogData.manual = true; + dialogData.pendingCriticalId = undefined; + fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture.detectChanges(); + + expect(fixture.componentInstance.selectedSlotIndex()).toBeNull(); + const hitButtons = fixture.nativeElement.querySelectorAll( + '.critical-slot-hit-button', + ) as NodeListOf; + expect(hitButtons).toHaveSize(1); + expect(hitButtons[0].textContent).toContain('HIT'); + expect((fixture.nativeElement.querySelector('.actions .bt-button.primary') as HTMLButtonElement).disabled) + .toBeTrue(); + }); + it('uses CANCEL for a transient manual critical without touching pending events', () => { fixture.destroy(); dialogData.manual = true; @@ -376,6 +437,114 @@ describe('MekCriticalRollDialogComponent', () => { expect(fixture.nativeElement.querySelector('.critical-result')?.textContent).toContain('Medium Laser'); }); + it('shows an inline explosion SVG only beside slots whose critical hit can explode', () => { + previewSlot.and.callFake((_unit: CBTForceUnit, slot: CriticalSlot) => + slot.id === 'laser@LT' ? { explosion: {} } : null); + fixture.destroy(); + fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture.detectChanges(); + + const choices = fixture.nativeElement.querySelectorAll( + '.critical-slot-option', + ) as NodeListOf; + const explosiveIcon = choices[0].querySelector( + '.critical-slot-explosion-icon', + ) as SVGElement; + + expect(explosiveIcon).not.toBeNull(); + expect(explosiveIcon.getAttribute('aria-label')).toBe('Can explode'); + expect(choices[1].querySelector('.critical-slot-explosion-icon')).toBeNull(); + expect(choices[0].querySelector('.critical-slot-name')?.textContent?.trim()).toBe('Medium Laser'); + }); + + it('uses the mounted equipment display name instead of the raw critical-slot id', () => { + criticalSlots[0].name = 'ISMediumLaser'; + const getDisplayName = jasmine.createSpy('getDisplayName').and.returnValue('Medium Laser (R)'); + inventoryEntries = [{ + critSlots: [{ ...criticalSlots[0] }], + getDisplayName, + } as unknown as MountedEquipment]; + fixture.destroy(); + fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture.detectChanges(); + + const firstSlotName = fixture.nativeElement.querySelector( + '.critical-slot-name', + ) as HTMLElement; + expect(firstSlotName.textContent?.trim()).toBe('Medium Laser (R)'); + expect(getDisplayName).toHaveBeenCalledWith('Medium Laser'); + }); + + it('splits twelve-slot locations into 1–3 and 4–6 blocks numbered 1 through 6', () => { + const equipment = criticalSlots[0].eq; + criticalSlots = Array.from({ length: 12 }, (_, slot) => ({ + id: `slot-${slot}@LT`, + name: `Slot ${slot + 1}`, + loc: 'LT', + slot, + eq: equipment, + })); + slotsVersion.update(version => version + 1); + fixture.destroy(); + fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture.detectChanges(); + + const blocks = fixture.nativeElement.querySelectorAll( + '.critical-slot-block', + ) as NodeListOf; + expect(blocks).toHaveSize(2); + expect(Array.from(blocks, block => block.querySelector('.critical-slot-first-die')?.textContent?.trim())) + .toEqual(['1–3', '4–6']); + expect(Array.from(blocks[0].querySelectorAll('.critical-slot-number'), number => number.textContent?.trim())) + .toEqual(['1', '2', '3', '4', '5', '6']); + expect(Array.from(blocks[1].querySelectorAll('.critical-slot-number'), number => number.textContent?.trim())) + .toEqual(['1', '2', '3', '4', '5', '6']); + }); + + it('uses NEXT for a non-final critical and immediately advances to the next slot choice', async () => { + fixture.destroy(); + dialogData.requiredHits = 2; + applyRoll.and.resolveTo({ + cancelled: false, + outcome: { + applied: true, + slotNumber: 1, + equipment: 'Medium Laser', + armoredAbsorption: false, + }, + }); + fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture.detectChanges(); + + let hitButtons = fixture.nativeElement.querySelectorAll( + '.critical-slot-hit-button', + ) as NodeListOf; + hitButtons[0].click(); + fixture.detectChanges(); + + let primary = fixture.nativeElement.querySelector('.actions .bt-button.primary') as HTMLButtonElement; + expect(primary.textContent).toContain('NEXT'); + + primary.click(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.componentInstance.appliedHits()).toBe(1); + expect(fixture.componentInstance.complete()).toBeFalse(); + expect(fixture.nativeElement.querySelector('.critical-result')).toBeNull(); + expect(dialogRef.close).not.toHaveBeenCalled(); + hitButtons = fixture.nativeElement.querySelectorAll( + '.critical-slot-hit-button', + ) as NodeListOf; + expect(Array.from(hitButtons, button => button.textContent?.trim())).toEqual(['HIT', 'HIT']); + + hitButtons[1].click(); + fixture.detectChanges(); + + primary = fixture.nativeElement.querySelector('.actions .bt-button.primary') as HTMLButtonElement; + expect(primary.textContent).toContain('APPLY'); + }); + it('stages a dice result through the same row-level UNDO path', () => { fixture.componentInstance.onFinished({ results: [1, 1] }); fixture.detectChanges(); @@ -593,6 +762,7 @@ describe('MekCriticalRollDialogComponent', () => { expect(setPendingCriticalCaseIICheckResult).toHaveBeenCalledOnceWith( dialogData.pendingCriticalId, 'discard', + [4, 4], ); expect(resolvePendingCriticalHit).not.toHaveBeenCalled(); expect(fixture.nativeElement.querySelector('.actions .bt-button.danger')?.textContent) @@ -601,6 +771,15 @@ describe('MekCriticalRollDialogComponent', () => { fixture.componentInstance.close(); expect(dialogRef.close).toHaveBeenCalledOnceWith({ completed: false }); expect(resolvePendingCriticalHit).not.toHaveBeenCalled(); + + fixture.destroy(); + dialogData.caseIICheckResult = 'discard'; + dialogData.caseIICheckRoll = [4, 4]; + fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture.detectChanges(); + + expect(fixture.componentInstance.caseIIRoller()!.diceResults()).toEqual([4, 4]); + expect(fixture.componentInstance.caseIIRoller()!.rollFinished()).toBeTrue(); }); it('records a physical CASE II discard as one resolved pending critical', () => { @@ -616,9 +795,8 @@ describe('MekCriticalRollDialogComponent', () => { expect(resolvePendingCriticalHit).toHaveBeenCalledOnceWith(dialogData.pendingCriticalId); expect(fixture.componentInstance.discardedHits()).toBe(1); expect(fixture.componentInstance.complete()).toBeFalse(); - expect(fixture.nativeElement.querySelector('.critical-result')?.textContent) - .toContain('CASE II discarded'); - expect(fixture.componentInstance.primaryLabel()).toBe('NEXT'); + expect(fixture.nativeElement.querySelector('.critical-result')).toBeNull(); + expect(fixture.nativeElement.querySelector('.case-ii-check')).not.toBeNull(); }); it('decrements persisted work only after the critical is applied', async () => { @@ -690,6 +868,9 @@ describe('MekCriticalRollDialogComponent', () => { expect(fixture.componentInstance.primaryLabel()).toBe('DISCARD'); expect(fixture.componentInstance.complete()).toBeFalse(); + expect(fixture.nativeElement.querySelector('.critical-result')?.textContent) + .toContain('No valid critical slots remain'); + expect(fixture.nativeElement.querySelector('.critical-random-row')).toBeNull(); const primaryButton = fixture.nativeElement.querySelector('.bt-button.primary') as HTMLButtonElement; expect(primaryButton.disabled).toBeFalse(); diff --git a/src/app/components/page-viewer/mek-critical-roll-dialog.component.ts b/src/app/components/page-viewer/mek-critical-roll-dialog.component.ts index df2776c8f..fda6c777d 100644 --- a/src/app/components/page-viewer/mek-critical-roll-dialog.component.ts +++ b/src/app/components/page-viewer/mek-critical-roll-dialog.component.ts @@ -13,6 +13,7 @@ import { mekCriticalRollDiceCount, mekCriticalRollForSlot, mekCriticalRollLocation, + mekCriticalSlotDisplayName, mekCriticalSlotRollability, mekCriticalSlotIndexForRoll, randomValidMekCriticalRoll, @@ -36,6 +37,7 @@ export interface MekCriticalRollDialogData { readonly caseIICheckRequired?: boolean; readonly caseIICheckPassed?: boolean; readonly caseIICheckResult?: 'resolve' | 'discard'; + readonly caseIICheckRoll?: readonly [number, number]; readonly pilotDamageGroup?: string; readonly canUndoToChance?: boolean; } @@ -51,6 +53,13 @@ interface CriticalSlotRow { readonly slotIndex: number; readonly slot: CriticalSlot | null; readonly destroyed: boolean; + readonly explosive: boolean; +} + +interface CriticalSlotBlock { + readonly key: 'single' | 'upper' | 'lower'; + readonly firstDieRange: string | null; + readonly rows: readonly CriticalSlotRow[]; } interface CriticalExplosionDisplay { @@ -92,14 +101,17 @@ interface CriticalExplosionDisplay { CASE II critical check Roll 2D6 for this critical: resolve it on 2–7; discard it on 8+.
-
+
- +
@if (caseIICheckResult(); as checkResult) { @@ -128,15 +141,18 @@ interface CriticalExplosionDisplay { (click)="applyCaseIICheck('discard')">8+ · DISCARD CRITICAL
} - } @else if (selectedSlotIndex() === null) { -
+ } @else if (selectedSlotIndex() === null && hasRollableSlot()) { +
@@ -216,44 +232,73 @@ interface CriticalExplosionDisplay { } @if (showManualSlots()) {
- @for (row of manualSlotRows(); track row.slotIndex) { - @if (row.slot; as slot) { -
- {{ slotNumber(slot) }} - {{ slotLabel(slot) }} - @if (isSelectedSlot(slot)) { - - } @else if (selectedSlotIndex() === null) { - + @for (block of manualSlotBlocks(); track block.key) { +
+ @if (block.firstDieRange; as firstDieRange) { + + } +
+ @for (row of block.rows; track row.slotIndex) { + @if (row.slot; as slot) { +
+ {{ tableSlotNumber(row.slotIndex) }} + + {{ slotLabel(slot) }} + @if (row.explosive) { + + + + + } + + @if (isSelectedSlot(slot)) { + + } @else if (selectedSlotIndex() === null) { + + } +
+ } @else { + + } }
- } @else { - - } +
}
@if (canDiscardNonExplosiveResult()) { @@ -317,7 +362,7 @@ export class MekCriticalRollDialogComponent { readonly caseIICheckPassed = signal(this.data.caseIICheckPassed ?? false); readonly caseIICheckResult = signal<'resolve' | 'discard' | null>(this.data.caseIICheckResult ?? null); readonly currentDiscardReason = signal<'case-ii' | 'non-explosive' | null>(null); - private readonly restoredRoll = this.data.pendingCriticalId + readonly restoredRoll = this.data.pendingCriticalId ? this.data.unit.turnState().getPendingCriticalHit(this.data.pendingCriticalId)?.roll : undefined; readonly selectedSlotIndex = signal(this.restoredRoll @@ -383,6 +428,15 @@ export class MekCriticalRollDialogComponent { ); readonly manualSlotRows = computed(() => this.lockedSlotRows() ?? this.createSlotRows(this.availableSlots())); + readonly manualSlotBlocks = computed(() => { + const rows = this.manualSlotRows(); + return this.diceCount === 1 + ? [{ key: 'single', firstDieRange: null, rows }] + : [ + { key: 'upper', firstDieRange: '1–3', rows: rows.slice(0, 6) }, + { key: 'lower', firstDieRange: '4–6', rows: rows.slice(6, 12) }, + ]; + }); readonly hasRollableSlot = computed(() => this.availableSlots().length > 0); readonly isRolling = computed(() => this.roller()?.isRolling() ?? false); readonly isCaseIIRolling = computed(() => this.caseIIRoller()?.isRolling() ?? false); @@ -424,7 +478,7 @@ export class MekCriticalRollDialogComponent { || !!this.currentDiscardReason() || !this.hasRollableSlot())); readonly hasInterruptingConsciousness = computed(() => - this.data.unit.gameRules.id === 'tw' + !this.data.unit.gameRules.aggregatedEndPhaseConsciousRolls && this.data.unit.turnState().actionablePendingUnitChecks() .some(check => check.kind === 'consciousness')); // Keep the protection that applied when rolling began visible after an explosion destroys the location. @@ -432,6 +486,11 @@ export class MekCriticalRollDialogComponent { readonly explosionProtectionLabel = this.explosionProtection === 'case-ii' ? '[CASE II]' : '[CASE]'; readonly explosionProtectionNote = this.data.unit.gameRules.getMekExplosionProtectionNote(this.explosionProtection); + constructor() { + const slots = this.availableSlots(); + if (this.data.canUndoToChance && slots.length === 1) this.selectSlot(slots[0]); + } + roll(): void { if (!this.canStartRoll()) return; this.lockManualSlots(); @@ -458,7 +517,13 @@ export class MekCriticalRollDialogComponent { : 'resolve'; this.caseIICheckResult.set(result); const pendingId = this.data.pendingCriticalId; - if (pendingId) this.data.unit.turnState().setPendingCriticalCaseIICheckResult(pendingId, result); + if (pendingId) { + this.data.unit.turnState().setPendingCriticalCaseIICheckResult( + pendingId, + result, + event.results, + ); + } } applyCaseIICheck(result: 'resolve' | 'discard'): void { @@ -529,7 +594,11 @@ export class MekCriticalRollDialogComponent { this.resolvePersistedHit(); this.outcome.set(outcome); this.discardedHits.update(value => value + 1); - if (this.complete()) this.completeDialog(); + if (this.complete()) { + this.completeDialog(); + } else { + this.advanceToNextCritical(); + } return; } this.clearPersistedRoll(); @@ -541,7 +610,11 @@ export class MekCriticalRollDialogComponent { this.resolvePersistedHit(); this.outcome.set(outcome); this.appliedHits.update(value => value + 1); - if (this.complete()) this.completeDialog(this.hasInterruptingConsciousness()); + if (this.complete()) { + this.completeDialog(this.hasInterruptingConsciousness()); + } else if (!this.hasInterruptingConsciousness()) { + this.advanceToNextCritical(); + } } primaryLabel(): string { @@ -549,6 +622,9 @@ export class MekCriticalRollDialogComponent { if (caseIICheckResult === 'resolve') return 'RESOLVE'; if (caseIICheckResult === 'discard') return 'DISCARD'; if (this.outcome() && this.hasInterruptingConsciousness()) return 'CONTINUE'; + if (this.pendingResults()) { + return this.resolvedHits() + 1 < this.data.requiredHits ? 'NEXT' : 'APPLY'; + } if (!this.hasRollableSlot()) return 'DISCARD'; if (this.outcome() || this.currentDiscardReason()) return 'NEXT'; return 'APPLY'; @@ -607,8 +683,12 @@ export class MekCriticalRollDialogComponent { return (slot.slot ?? 0) + 1; } + tableSlotNumber(slotIndex: number): number { + return slotIndex % 6 + 1; + } + slotLabel(slot: CriticalSlot): string { - return slot.name?.trim() || slot.eq?.name || 'Equipment'; + return mekCriticalSlotDisplayName(this.data.unit, slot); } isHighlightedSlot(slot: CriticalSlot): boolean { @@ -665,7 +745,19 @@ export class MekCriticalRollDialogComponent { this.caseIICheckResult.set(null); this.currentDiscardReason.set(reason); this.discardedHits.update(value => value + 1); - if (this.complete()) this.completeDialog(); + if (this.complete()) { + this.completeDialog(); + } else { + this.advanceToNextCritical(); + } + } + + private advanceToNextCritical(): void { + this.outcome.set(null); + this.currentDiscardReason.set(null); + this.caseIICheckPassed.set(false); + this.caseIICheckResult.set(null); + this.unlockManualSlots(); } private createSlotRows(slots: readonly CriticalSlot[]): readonly CriticalSlotRow[] { @@ -677,6 +769,12 @@ export class MekCriticalRollDialogComponent { return { slotIndex, slot, + explosive: slot !== null + && this.criticalHitAutomation.previewSlot( + this.data.unit, + slot, + this.criticalRollOptions, + )?.explosion !== undefined, // Color reflects why the underlying table slot is unavailable. // Explosion-only filtering must not hide that a component was already destroyed. destroyed: slot === null && mekCriticalSlotRollability( 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 24a39e540..b5d87a686 100644 --- a/src/app/components/page-viewer/svg-interaction.service.spec.ts +++ b/src/app/components/page-viewer/svg-interaction.service.spec.ts @@ -31,6 +31,7 @@ import { MekCriticalHitAutomationService } from '../../services/mek-critical-hit import { MekCriticalResolutionService } from '../../services/mek-critical-resolution.service'; import { UnitCheckResolutionService } from '../../services/unit-check-resolution.service'; import { FallingResolutionService } from '../../services/falling-resolution.service'; +import { CBTPhaseResolutionService } from '../../services/cbt-phase-resolution.service'; type SvgInteractionServicePrivate = { addSvgTapHandler( @@ -42,6 +43,7 @@ type SvgInteractionServicePrivate = { updateUnit(unit: any): void; setupInteractions(svg: SVGSVGElement): void; setupReadOnlyInteractions(svg: SVGSVGElement): void; + setupCrewHitInteractions(svg: SVGSVGElement, signal: AbortSignal): void; cleanup(): void; getHeatDiffMarkerData(): { el: SVGElement | null; heat: number; baselineHeat: number; containerRect: DOMRect } | null; updateHeatHighlight(heatValue: number): void; @@ -112,6 +114,7 @@ describe('SvgInteractionService', () => { let criticalOpenManual: jasmine.Spy; let openUnitChecks: jasmine.Spy; let openFalling: jasmine.Spy; + let phaseIsResolving: jasmine.Spy; beforeEach(() => { zoomPanService = { @@ -167,6 +170,7 @@ describe('SvgInteractionService', () => { criticalOpenManual = jasmine.createSpy('openManual').and.resolveTo(); openUnitChecks = jasmine.createSpy('open').and.resolveTo(); openFalling = jasmine.createSpy('open').and.resolveTo(); + phaseIsResolving = jasmine.createSpy('isResolving').and.returnValue(false); options = { pickerStyle: 'default', colorScheme: 'default', @@ -220,6 +224,7 @@ describe('SvgInteractionService', () => { }, { provide: UnitCheckResolutionService, useValue: { open: openUnitChecks } }, { provide: FallingResolutionService, useValue: { open: openFalling } }, + { provide: CBTPhaseResolutionService, useValue: { isResolving: phaseIsResolving } }, { provide: ToastService, useValue: { showToast: jasmine.createSpy('showToast') } } ] }); @@ -361,11 +366,11 @@ describe('SvgInteractionService', () => { const armChoices = service.locationConditionDropdownChoices(unit, 'LA'); expect(torsoChoices.filter(choice => !choice.isBreak).map(choice => choice.key)) - .toEqual(['flooded', 'critical-chance', 'critical-roll']); + .toEqual(['flooded', 'critical-chance', 'critical-hit']); expect(armChoices.filter(choice => !choice.isBreak).map(choice => choice.key)) - .toEqual(['flooded', 'blown-off', 'critical-chance', 'critical-roll']); + .toEqual(['flooded', 'blown-off', 'critical-chance', 'critical-hit']); expect(torsoChoices.filter(choice => choice.action).map(choice => choice.key)) - .toEqual(['critical-chance', 'critical-roll']); + .toEqual(['critical-chance', 'critical-hit']); expect(torsoChoices.filter(choice => choice.isBreak).length).toBe(1); }); @@ -431,6 +436,44 @@ describe('SvgInteractionService', () => { expect(openUnitChecks).toHaveBeenCalledOnceWith([unit]); }); + it('opens checks after adding crew damage from a sheet pip, but not after removing it', async () => { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const crewHit = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + crewHit.classList.add('crewHit'); + crewHit.setAttribute('crewId', '0'); + crewHit.setAttribute('hit', '2'); + svg.appendChild(crewHit); + + let hits = 0; + const automationTriggers = new Subject(); + const setCrewHits = jasmine.createSpy('setCrewHits').and.callFake((_crewId: number, nextHits: number) => { + const addedDamage = nextHits > hits; + hits = nextHits; + if (addedDamage) automationTriggers.next({ kind: 'pending-unit-check' }); + return true; + }); + const unit = createSvgInteractionUnit({ + id: 'unit-a', + automationTriggers, + getCrewMember: () => ({ getHits: () => hits }), + setCrewHits, + }); + service.updateUnit(unit); + service.setupCrewHitInteractions(svg, new AbortController().signal); + + tap(crewHit, 71); + await service.automationQueue; + + expect(setCrewHits).toHaveBeenCalledOnceWith(0, 2); + expect(openUnitChecks).toHaveBeenCalledOnceWith([unit]); + + tap(crewHit, 72); + await service.automationQueue; + + expect(setCrewHits.calls.allArgs()).toEqual([[0, 2], [0, 1]]); + expect(openUnitChecks).toHaveBeenCalledTimes(1); + }); + it('opens seatbelt work only after falling resolution releases it', async () => { let finishFalling!: () => void; const automationTriggers = new Subject(); @@ -463,6 +506,26 @@ describe('SvgInteractionService', () => { expect(openUnitChecks).toHaveBeenCalledOnceWith([unit]); }); + it('leaves triggers emitted during END PHASE to the phase coordinator', async () => { + const automationTriggers = new Subject(); + const unit = createSvgInteractionUnit({ + id: 'unit-a', + automationTriggers, + }); + service.updateUnit(unit); + phaseIsResolving.and.returnValue(true); + + automationTriggers.next({ + kind: 'falling', + id: 'fall:phase-owned', + source: 'psr', + levelsFallen: 0, + }); + await service.automationQueue; + + expect(openFalling).not.toHaveBeenCalled(); + }); + it('waits for the current workflow before processing an automation emitted from inside it', async () => { let finishFirst!: () => void; const automationTriggers = new Subject(); diff --git a/src/app/components/page-viewer/svg-interaction.service.ts b/src/app/components/page-viewer/svg-interaction.service.ts index 00fed85e8..d1572696e 100644 --- a/src/app/components/page-viewer/svg-interaction.service.ts +++ b/src/app/components/page-viewer/svg-interaction.service.ts @@ -51,6 +51,7 @@ import { MekCriticalHitAutomationService } from '../../services/mek-critical-hit import { MekCriticalResolutionService } from '../../services/mek-critical-resolution.service'; import { UnitCheckResolutionService } from '../../services/unit-check-resolution.service'; import { FallingResolutionService } from '../../services/falling-resolution.service'; +import { CBTPhaseResolutionService } from '../../services/cbt-phase-resolution.service'; import type { AutomationReviewEvent } from '../../models/automation-review.model'; import { uuidv7 } from '../../utils/uuid.util'; @@ -84,7 +85,7 @@ const SVG_CONDITIONS_DROPDOWN_OVERLAY_KEY = 'svg-conditions-dropdown'; const SVG_CREW_STATE_DROPDOWN_OVERLAY_KEY = 'svg-crew-state-dropdown'; const SVG_LOCATION_CONDITIONS_DROPDOWN_OVERLAY_KEY = 'svg-location-conditions-dropdown'; const CRITICAL_CHANCE_ACTION = 'critical-chance'; -const CRITICAL_ROLL_ACTION = 'critical-roll'; +const CRITICAL_HIT_ACTION = 'critical-hit'; const EQUIPMENT_HOVER_SECONDARY_CLASS = 'equipment-hover-secondary'; const ARMOR_OVERFLOW_CHOICE_COLORS = { normal: '#8B0000', @@ -123,6 +124,7 @@ export class SvgInteractionService { private criticalResolution = inject(MekCriticalResolutionService); private unitCheckResolution = inject(UnitCheckResolutionService); private fallingResolution = inject(FallingResolutionService); + private phaseResolution = inject(CBTPhaseResolutionService); // Zoom-pan service passed via initialize() private zoomPanService!: ZoomPanServiceInterface; @@ -1694,7 +1696,7 @@ export class SvgInteractionService { updateChoices(); outputToObservable(componentRef.instance.selected).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(state => { - if (state === CRITICAL_CHANCE_ACTION || state === CRITICAL_ROLL_ACTION) { + if (state === CRITICAL_CHANCE_ACTION || state === CRITICAL_HIT_ACTION) { this.overlayManager.closeManagedOverlay(SVG_LOCATION_CONDITIONS_DROPDOWN_OVERLAY_KEY); if (state === CRITICAL_CHANCE_ACTION) { this.openMekCriticalChanceDialog(unit, loc); @@ -1750,12 +1752,16 @@ export class SvgInteractionService { ...conditions, { key: 'critical-actions-break', label: '', color: '', active: false, isBreak: true }, { key: CRITICAL_CHANCE_ACTION, label: 'Critical Chance', color: '#444', active: false, action: true }, - { key: CRITICAL_ROLL_ACTION, label: 'Critical Roll', color: '#444', active: false, action: true }, + { key: CRITICAL_HIT_ACTION, label: 'Critical Hit', color: '#444', active: false, action: true }, ]; } private scheduleAutomation(unit: CBTForceUnit, trigger: CBTUnitAutomationTrigger): void { - let task: () => Promise; + // Events emitted while END PHASE is draining are already represented in + // the unit queue and belong to that awaited workflow. + if (this.phaseResolution.isResolving(unit)) return; + + let task: () => Promise; if (trigger.kind === 'critical-hit-chance') { task = () => this.criticalResolution.resumeChance(unit, trigger.id); } else if (trigger.kind === 'pending-unit-check') { @@ -1766,7 +1772,11 @@ export class SvgInteractionService { task = () => this.handleBreachAndFloodTrigger(unit, trigger); } - this.queueAutomation(task); + this.queueAutomation(async () => { + // END PHASE may have started after the trigger was scheduled. + if (this.phaseResolution.isResolving(unit)) return; + await task(); + }); } private queueAutomation(task: () => Promise): void { diff --git a/src/app/components/unit-block/unit-block.component.html b/src/app/components/unit-block/unit-block.component.html index bb982459d..57021b902 100644 --- a/src/app/components/unit-block/unit-block.component.html +++ b/src/app/components/unit-block/unit-block.component.html @@ -1,5 +1,6 @@ @let unitDisplayName = optionsService.options().unitDisplayName; @let fu = forceUnit(); +@let badgeUnit = notificationUnit(); @if (compactMode()) {
@if (dirty()) { @@ -46,6 +47,9 @@ }
+ @if (badgeUnit) { + + }
} @else { @@ -156,17 +160,22 @@
- @if (unitDisplayName === 'chassisModel' - || unitDisplayName === 'both' - || !alias) { - {{ chassis }} - @if (unitDisplayName === 'both' && alias) { - ({{ alias }}) - } - } - @if (unitDisplayName === 'alias') { -
{{ alias }}
- } +
+ @if (unitDisplayName === 'chassisModel' + || unitDisplayName === 'both' + || !alias) { + {{ chassis }} + @if (unitDisplayName === 'both' && alias) { + ({{ alias }}) + } + } + @if (unitDisplayName === 'alias') { + {{ alias }} + } + @if (badgeUnit) { + + } +
@@ -237,4 +246,4 @@
-} \ No newline at end of file +} diff --git a/src/app/components/unit-block/unit-block.component.scss b/src/app/components/unit-block/unit-block.component.scss index d2e671234..3a09fb67d 100644 --- a/src/app/components/unit-block/unit-block.component.scss +++ b/src/app/components/unit-block/unit-block.component.scss @@ -43,6 +43,12 @@ align-items: center; position: relative; z-index: 2; + + .compact-notification-row { + width: 100%; + flex-wrap: wrap; + justify-content: center; + } } &.destroyed { @@ -200,6 +206,21 @@ font-weight: bold; } + .unit-name-line { + display: flex; + min-width: 0; + align-items: center; + gap: 4px; + line-height: 1.2; + + .unit-alias, + .chassis { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + } + .unit-role { width: 15px; -webkit-writing-mode: vertical-lr; diff --git a/src/app/components/unit-block/unit-block.component.spec.ts b/src/app/components/unit-block/unit-block.component.spec.ts index b5638ec89..538a16a1b 100644 --- a/src/app/components/unit-block/unit-block.component.spec.ts +++ b/src/app/components/unit-block/unit-block.component.spec.ts @@ -2,13 +2,15 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { provideZonelessChangeDetection } from '@angular/core'; +import { Overlay } from '@angular/cdk/overlay'; +import { provideZonelessChangeDetection, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { CBTForceUnit } from '../../models/cbt-force-unit.model'; import type { CrewMemberState } from '../../models/crew-member.model'; import type { CrewStateDefinition } from '../../models/rules/unit-type-rules'; import { VEHICLE_CREW_STATE_DISPLAYS } from '../../models/rules/vehicle-rules'; import { OptionsService } from '../../services/options.service'; +import { SpriteStorageService } from '../../services/sprite-storage.service'; import { UnitBlockComponent } from './unit-block.component'; describe('UnitBlockComponent', () => { @@ -17,10 +19,16 @@ describe('UnitBlockComponent', () => { imports: [UnitBlockComponent], providers: [ provideZonelessChangeDetection(), - { provide: OptionsService, useValue: { options: () => ({}) } }, + { + provide: OptionsService, + useValue: { options: () => ({ trackPhaseAndTurn: true, unitDisplayName: 'chassisModel' }) }, + }, + { provide: Overlay, useValue: {} }, + { + provide: SpriteStorageService, + useValue: { loading: signal(false) }, + }, ], - }).overrideComponent(UnitBlockComponent, { - set: { template: '' }, }); }); @@ -53,4 +61,83 @@ describe('UnitBlockComponent', () => { { key: 'location-narc', label: 'NARC', color: '#f00' }, ]); }); + + it('renders compact notifications as a normal-flow row inside the unit content', () => { + const forceUnit = Object.create(CBTForceUnit.prototype) as CBTForceUnit; + const turnState = { + dirty: () => false, + autoFall: () => false, + actionablePSRRollsCount: () => 0, + pendingCriticalChanceCount: () => 3, + pendingCriticalHitCount: () => 1, + getPendingCriticalChances: () => [{ + type: 'mek-critical-chance' as const, + id: 'chance:1', + location: 'CT', + }], + getPendingCriticalHits: () => [{ + type: 'mek-critical-hit' as const, + id: 'critical:1', + location: 'LT', + targetLocation: 'LT', + remainingHits: 1, + }], + getPendingEvents: () => [{ + type: 'mek-critical-hit' as const, + id: 'critical:1', + location: 'LT', + targetLocation: 'LT', + remainingHits: 1, + }, { + type: 'mek-critical-chance' as const, + id: 'chance:1', + location: 'CT', + }], + pendingUnitCheckCount: () => 0, + }; + Object.defineProperty(forceUnit, 'force', { + value: { gameSystem: 'cbt' }, + configurable: true, + }); + Object.defineProperty(forceUnit, 'rules', { + value: { controlRollFullLabel: 'Piloting Skill Rolls' }, + configurable: true, + }); + Object.defineProperty(forceUnit, 'destroyed', { + value: false, + configurable: true, + }); + Object.assign(forceUnit, { + gameRules: { aggregatedEndPhaseConsciousRolls: true }, + getUnit: () => ({ chassis: 'Atlas', model: 'AS7-D' }), + commander: () => false, + alias: () => '', + getPilotStats: () => '4/5', + pendingFallCount: () => 0, + turnState: () => turnState, + }); + + const fixture = TestBed.createComponent(UnitBlockComponent); + fixture.componentRef.setInput('forceUnit', forceUnit); + fixture.componentRef.setInput('compactMode', true); + fixture.detectChanges(); + + const square = fixture.nativeElement.querySelector('.unit-square') as HTMLElement; + const content = square.querySelector('.unit-content') as HTMLElement; + const badges = content.querySelector('unit-notification-badges') as HTMLElement; + expect(fixture.componentInstance.compactMode()).toBeTrue(); + expect(content.contains(badges)).toBeTrue(); + expect(badges.classList).toContain('compact-notification-row'); + expect(badges.classList).not.toContain('compact'); + expect(badges.querySelector('.critical-chance-warning')?.textContent).toContain('3'); + expect(badges.querySelector('.critical-hit-warning')?.textContent).toContain('1'); + expect(Array.from(badges.querySelectorAll( + '.critical-chance-warning, .critical-hit-warning', + )).map(badge => badge.classList[1])).toEqual([ + 'critical-hit-warning', + 'critical-chance-warning', + ]); + expect(getComputedStyle(content).flexDirection).toBe('column'); + expect(getComputedStyle(badges).position).toBe('static'); + }); }); diff --git a/src/app/components/unit-block/unit-block.component.ts b/src/app/components/unit-block/unit-block.component.ts index a81b7ddb4..daa4f61e7 100644 --- a/src/app/components/unit-block/unit-block.component.ts +++ b/src/app/components/unit-block/unit-block.component.ts @@ -20,6 +20,7 @@ import { GameSystem } from '../../models/common.model'; import { formatMovement, formatMovementWithAlternate } from '../../utils/as-common.util'; import { getUnitConditionDefinition, unitConditionSortIndex } from '../../models/rules/unit-type-rules'; import { formatBvPv } from '../../utils/force-viewer-bv-pv-display.util'; +import { UnitNotificationBadgesComponent } from '../unit-notification-badges/unit-notification-badges.component'; interface UnitConditionDisplay { key: string; @@ -39,7 +40,14 @@ export interface UnitBlockPilotEditEvent { @Component({ selector: 'unit-block', standalone: true, - imports: [CdkMenuModule, FormatTonsPipe, UnitIconComponent, TooltipDirective, UpperCasePipe], + imports: [ + CdkMenuModule, + FormatTonsPipe, + UnitIconComponent, + UnitNotificationBadgesComponent, + TooltipDirective, + UpperCasePipe, + ], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './unit-block.component.html', styleUrls: ['./unit-block.component.scss'], @@ -130,6 +138,12 @@ export class UnitBlockComponent { return false; }); + notificationUnit = computed(() => { + if (!this.optionsService.options().trackPhaseAndTurn) return null; + const unit = this.forceUnit(); + return unit instanceof CBTForceUnit ? unit : null; + }); + activeConditions = computed(() => { const forceUnit = this.forceUnit(); if (!forceUnit) return []; diff --git a/src/app/utils/heat-summary.util.spec.ts b/src/app/utils/heat-summary.util.spec.ts index b275cd68f..51b609a97 100644 --- a/src/app/utils/heat-summary.util.spec.ts +++ b/src/app/utils/heat-summary.util.spec.ts @@ -9,7 +9,7 @@ describe('buildHeatSummaryRows', () => { expect(buildHeatSummaryRows([ { id: 'movement', label: 'Movement', value: 2 }, { id: 'weapons', label: 'Weapons', value: 12 }, - { id: 'damaged-engine', label: 'Damaged Engine', value: 5 }, + { id: 'damaged-engine', label: 'Engine', value: 5 }, ], 10, 10, 13)).toEqual([ { id: 'movement', label: 'Movement', value: 2, kind: 'source' }, { id: 'weapons', label: 'Weapons', value: 12, kind: 'source' }, @@ -20,7 +20,7 @@ describe('buildHeatSummaryRows', () => { it('shows unused sink capacity without pretending it removed heat below zero', () => { expect(buildHeatSummaryRows([], 28, 22, 0)).toEqual([ - { id: 'heat-sink', label: 'Sink (-28)', value: -22, kind: 'sink' }, + { id: 'heat-sink', label: 'Sink (28)', value: -22, kind: 'sink' }, ]); }); }); diff --git a/src/app/utils/heat-summary.util.ts b/src/app/utils/heat-summary.util.ts index 9a6803001..6a61a90d0 100644 --- a/src/app/utils/heat-summary.util.ts +++ b/src/app/utils/heat-summary.util.ts @@ -37,7 +37,7 @@ export function buildHeatSummaryRows( if (balance < 0 || (balance > 0 && consumed > 0)) { rows.push({ id: 'heat-sink', - label: clippedAtZero ? `Sink (-${balance})` : 'Sink', + label: clippedAtZero ? `Sink (${balance})` : 'Sink', value: balance > 0 ? -(clippedAtZero ? consumed : balance) : Math.abs(balance), kind: 'sink', }); From 3561a7c2b442ecc0ca5c39605b00f07f058fade2 Mon Sep 17 00:00:00 2001 From: exeea Date: Wed, 26 Aug 2026 18:44:47 +0200 Subject: [PATCH 30/87] aggregated consciousness --- src/app/models/rules/game-rules.spec.ts | 5 +++++ src/app/models/rules/game-rules.ts | 3 +++ 2 files changed, 8 insertions(+) diff --git a/src/app/models/rules/game-rules.spec.ts b/src/app/models/rules/game-rules.spec.ts index 180795844..15d95fefc 100644 --- a/src/app/models/rules/game-rules.spec.ts +++ b/src/app/models/rules/game-rules.spec.ts @@ -300,6 +300,11 @@ function coreTagBvContext(options: { } describe('game rules', () => { + it('declares whether consciousness rolls accumulate phase damage', () => { + expect(CORE_2026_GAME_RULES.aggregatedEndPhaseConsciousRolls).toBeTrue(); + expect(TW_GAME_RULES.aggregatedEndPhaseConsciousRolls).toBeFalse(); + }); + describe('escalating failure targets', () => { it('uses the standardized numeric Core sequence for every checked component', () => { const standard = [3, 5, 7, 10, 11] as const; diff --git a/src/app/models/rules/game-rules.ts b/src/app/models/rules/game-rules.ts index 3df5577d5..25b38dad9 100644 --- a/src/app/models/rules/game-rules.ts +++ b/src/app/models/rules/game-rules.ts @@ -183,6 +183,7 @@ export function separateHeatFireModifier(resolution: ToHitResolution): ToHitHeat export abstract class CBTGameRules { abstract readonly id: 'core2026' | 'tw'; + abstract readonly aggregatedEndPhaseConsciousRolls: boolean; abstract readonly c3DegradationLabel: C3DegradationLabel; abstract readonly escalatingFailureTargets: readonly number[]; abstract readonly radicalHeatSinkFailureTargets: readonly number[]; @@ -473,6 +474,7 @@ export abstract class CBTGameRules { export class GameRules extends CBTGameRules { readonly id = 'core2026' as const; + readonly aggregatedEndPhaseConsciousRolls = true; readonly c3DegradationLabel = 'DEGRADED' as const; readonly physicalBaseHitModifiers = { punch: -1, @@ -601,6 +603,7 @@ export class GameRules extends CBTGameRules { export class TWGameRules extends CBTGameRules { readonly id = 'tw' as const; + readonly aggregatedEndPhaseConsciousRolls = false; readonly c3DegradationLabel = 'JAMMED' as const; readonly physicalBaseHitModifiers = { punch: 0, From b52eb6d6d66038c25efd18c9a5deacc54997ac4b Mon Sep 17 00:00:00 2001 From: exeea Date: Wed, 26 Aug 2026 19:05:23 +0200 Subject: [PATCH 31/87] chance/hit --- .../mek-critical-chance-dialog.component.ts | 2 +- ...mek-critical-hit-dialog.component.spec.ts} | 38 +++++++++---------- ...s => mek-critical-hit-dialog.component.ts} | 18 ++++----- .../svg-interaction.service.spec.ts | 2 +- 4 files changed, 30 insertions(+), 30 deletions(-) rename src/app/components/page-viewer/{mek-critical-roll-dialog.component.spec.ts => mek-critical-hit-dialog.component.spec.ts} (96%) rename src/app/components/page-viewer/{mek-critical-roll-dialog.component.ts => mek-critical-hit-dialog.component.ts} (98%) diff --git a/src/app/components/page-viewer/mek-critical-chance-dialog.component.ts b/src/app/components/page-viewer/mek-critical-chance-dialog.component.ts index 867e7c476..b72238393 100644 --- a/src/app/components/page-viewer/mek-critical-chance-dialog.component.ts +++ b/src/app/components/page-viewer/mek-critical-chance-dialog.component.ts @@ -30,7 +30,7 @@ export interface MekCriticalChanceDialogData { changeDetection: ChangeDetectionStrategy.OnPush, template: `
-
Critical Chance · {{ data.locationLabel }}
+
Critical Chance: {{ data.locationLabel }}
diff --git a/src/app/components/page-viewer/mek-critical-roll-dialog.component.spec.ts b/src/app/components/page-viewer/mek-critical-hit-dialog.component.spec.ts similarity index 96% rename from src/app/components/page-viewer/mek-critical-roll-dialog.component.spec.ts rename to src/app/components/page-viewer/mek-critical-hit-dialog.component.spec.ts index 53a14813b..8b3397e26 100644 --- a/src/app/components/page-viewer/mek-critical-roll-dialog.component.spec.ts +++ b/src/app/components/page-viewer/mek-critical-hit-dialog.component.spec.ts @@ -11,10 +11,10 @@ import type { CriticalSlot, SerializedPendingUnitCheck } from '../../models/forc import type { MountedEquipment } from '../../models/mounted-equipment.model'; import { CORE_2026_GAME_RULES, TW_GAME_RULES } from '../../models/rules/game-rules'; import { MekCriticalHitAutomationService } from '../../services/mek-critical-hit-automation.service'; -import { MekCriticalRollDialogComponent } from './mek-critical-roll-dialog.component'; +import { MekCriticalHitDialogComponent } from './mek-critical-hit-dialog.component'; -describe('MekCriticalRollDialogComponent', () => { - let fixture: ComponentFixture; +describe('MekCriticalHitDialogComponent', () => { + let fixture: ComponentFixture; let caseIISlot: CriticalSlot; let criticalSlots: CriticalSlot[]; let inventoryEntries: MountedEquipment[]; @@ -128,7 +128,7 @@ describe('MekCriticalRollDialogComponent', () => { }; await TestBed.configureTestingModule({ - imports: [MekCriticalRollDialogComponent], + imports: [MekCriticalHitDialogComponent], providers: [ provideZonelessChangeDetection(), { provide: DialogRef, useValue: dialogRef }, @@ -136,7 +136,7 @@ describe('MekCriticalRollDialogComponent', () => { { provide: DIALOG_DATA, useValue: dialogData }, ], }).compileComponents(); - fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture = TestBed.createComponent(MekCriticalHitDialogComponent); fixture.detectChanges(); }); @@ -199,7 +199,7 @@ describe('MekCriticalRollDialogComponent', () => { dialogData.manual = true; dialogData.pendingCriticalId = undefined; dialogData.canUndoToChance = true; - fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture = TestBed.createComponent(MekCriticalHitDialogComponent); fixture.detectChanges(); expect(fixture.componentInstance.selectedSlotIndex()).toBe(0); @@ -217,7 +217,7 @@ describe('MekCriticalRollDialogComponent', () => { slotsVersion.update(version => version + 1); dialogData.manual = true; dialogData.pendingCriticalId = undefined; - fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture = TestBed.createComponent(MekCriticalHitDialogComponent); fixture.detectChanges(); expect(fixture.componentInstance.selectedSlotIndex()).toBeNull(); @@ -234,7 +234,7 @@ describe('MekCriticalRollDialogComponent', () => { fixture.destroy(); dialogData.manual = true; dialogData.pendingCriticalId = undefined; - fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture = TestBed.createComponent(MekCriticalHitDialogComponent); fixture.detectChanges(); const actions = fixture.nativeElement.querySelectorAll( @@ -266,7 +266,7 @@ describe('MekCriticalRollDialogComponent', () => { armoredAbsorption: false, }, }); - fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture = TestBed.createComponent(MekCriticalHitDialogComponent); fixture.detectChanges(); fixture.componentInstance.onFinished({ results: [1, 1] }); @@ -441,7 +441,7 @@ describe('MekCriticalRollDialogComponent', () => { previewSlot.and.callFake((_unit: CBTForceUnit, slot: CriticalSlot) => slot.id === 'laser@LT' ? { explosion: {} } : null); fixture.destroy(); - fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture = TestBed.createComponent(MekCriticalHitDialogComponent); fixture.detectChanges(); const choices = fixture.nativeElement.querySelectorAll( @@ -465,7 +465,7 @@ describe('MekCriticalRollDialogComponent', () => { getDisplayName, } as unknown as MountedEquipment]; fixture.destroy(); - fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture = TestBed.createComponent(MekCriticalHitDialogComponent); fixture.detectChanges(); const firstSlotName = fixture.nativeElement.querySelector( @@ -486,7 +486,7 @@ describe('MekCriticalRollDialogComponent', () => { })); slotsVersion.update(version => version + 1); fixture.destroy(); - fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture = TestBed.createComponent(MekCriticalHitDialogComponent); fixture.detectChanges(); const blocks = fixture.nativeElement.querySelectorAll( @@ -513,7 +513,7 @@ describe('MekCriticalRollDialogComponent', () => { armoredAbsorption: false, }, }); - fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture = TestBed.createComponent(MekCriticalHitDialogComponent); fixture.detectChanges(); let hitButtons = fixture.nativeElement.querySelectorAll( @@ -730,7 +730,7 @@ describe('MekCriticalRollDialogComponent', () => { it('offers physical CASE II outcomes before exposing critical slots', () => { fixture.destroy(); dialogData.caseIICheckRequired = true; - fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture = TestBed.createComponent(MekCriticalHitDialogComponent); fixture.detectChanges(); const options = fixture.nativeElement.querySelectorAll( @@ -753,7 +753,7 @@ describe('MekCriticalRollDialogComponent', () => { it('persists a virtual CASE II result until it is explicitly applied', () => { fixture.destroy(); dialogData.caseIICheckRequired = true; - fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture = TestBed.createComponent(MekCriticalHitDialogComponent); fixture.detectChanges(); fixture.componentInstance.onCaseIIFinished({ results: [4, 4] }); @@ -775,7 +775,7 @@ describe('MekCriticalRollDialogComponent', () => { fixture.destroy(); dialogData.caseIICheckResult = 'discard'; dialogData.caseIICheckRoll = [4, 4]; - fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture = TestBed.createComponent(MekCriticalHitDialogComponent); fixture.detectChanges(); expect(fixture.componentInstance.caseIIRoller()!.diceResults()).toEqual([4, 4]); @@ -786,7 +786,7 @@ describe('MekCriticalRollDialogComponent', () => { fixture.destroy(); dialogData.caseIICheckRequired = true; dialogData.requiredHits = 2; - fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture = TestBed.createComponent(MekCriticalHitDialogComponent); fixture.detectChanges(); fixture.componentInstance.applyCaseIICheck('discard'); @@ -830,7 +830,7 @@ describe('MekCriticalRollDialogComponent', () => { remainingHits: 1, roll: [3, 4], }); - fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture = TestBed.createComponent(MekCriticalHitDialogComponent); fixture.detectChanges(); expect(fixture.componentInstance.primaryLabel()).toBe('APPLY'); @@ -843,7 +843,7 @@ describe('MekCriticalRollDialogComponent', () => { dialogData.manual = true; dialogData.pendingCriticalId = undefined; dialogData.canUndoToChance = true; - fixture = TestBed.createComponent(MekCriticalRollDialogComponent); + fixture = TestBed.createComponent(MekCriticalHitDialogComponent); fixture.detectChanges(); const undo = fixture.nativeElement.querySelector('.critical-sequence-undo') as HTMLButtonElement; diff --git a/src/app/components/page-viewer/mek-critical-roll-dialog.component.ts b/src/app/components/page-viewer/mek-critical-hit-dialog.component.ts similarity index 98% rename from src/app/components/page-viewer/mek-critical-roll-dialog.component.ts rename to src/app/components/page-viewer/mek-critical-hit-dialog.component.ts index fda6c777d..15f174518 100644 --- a/src/app/components/page-viewer/mek-critical-roll-dialog.component.ts +++ b/src/app/components/page-viewer/mek-critical-hit-dialog.component.ts @@ -19,13 +19,13 @@ import { randomValidMekCriticalRoll, type MekCriticalHitPreview, type MekExplosionLocationDamage, - type MekCriticalRollOptions, + type MekCriticalHitOptions, type MekCriticalRollOutcome, } from '../../utils/mek-critical-hit.util'; import { MekCriticalHitAutomationService } from '../../services/mek-critical-hit-automation.service'; import { DiceRollerComponent } from '../dice-roller/dice-roller.component'; -export interface MekCriticalRollDialogData { +export interface MekCriticalHitDialogData { readonly unit: CBTForceUnit; readonly location: string; readonly targetLocation?: string; @@ -42,7 +42,7 @@ export interface MekCriticalRollDialogData { readonly canUndoToChance?: boolean; } -export interface MekCriticalRollDialogResult { +export interface MekCriticalHitDialogResult { readonly completed: boolean; readonly interruptedForConsciousness?: boolean; readonly remainingHits?: number; @@ -72,13 +72,13 @@ interface CriticalExplosionDisplay { } @Component({ - selector: 'mek-critical-roll-dialog', + selector: 'mek-critical-hit-dialog', standalone: true, imports: [DiceRollerComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
-
Critical Roll · {{ locationLabel }}
+
Critical Hit: {{ locationLabel }}
@@ -334,13 +334,13 @@ interface CriticalExplosionDisplay { './mek-critical-dialog.component.scss', ], }) -export class MekCriticalRollDialogComponent { - private readonly dialogRef = inject(DialogRef); +export class MekCriticalHitDialogComponent { + private readonly dialogRef = inject(DialogRef); private readonly criticalHitAutomation = inject(MekCriticalHitAutomationService); - readonly data = inject(DIALOG_DATA); + readonly data = inject(DIALOG_DATA); readonly roller = viewChild('roller'); readonly caseIIRoller = viewChild('caseIIRoller'); - readonly criticalRollOptions: MekCriticalRollOptions = { + readonly criticalRollOptions: MekCriticalHitOptions = { transfer: false, ...(this.data.locationDestroyed && { explosiveSlotsOnly: true }), ...(this.data.pilotDamageGroup 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 b5d87a686..5e9295cda 100644 --- a/src/app/components/page-viewer/svg-interaction.service.spec.ts +++ b/src/app/components/page-viewer/svg-interaction.service.spec.ts @@ -381,7 +381,7 @@ describe('SvgInteractionService', () => { expect(criticalOpenManualChance).toHaveBeenCalledOnceWith(unit, 'LA', false); }); - it('opens a manual critical roll without queueing it', () => { + it('opens a manual critical hit without queueing it', () => { const unit = createSvgInteractionUnit({}); service.openMekCriticalRollDialog(unit, 'LT'); From 1fc295da09e2647cb53daf07ce57815d7cd1f688 Mon Sep 17 00:00:00 2001 From: exeea Date: Thu, 27 Aug 2026 13:53:59 +0200 Subject: [PATCH 32/87] automations --- scripts/lib/bfs-generation.ts | 2 +- scripts/ratgenerator_build_table.test.ts | 40 - .../automation-review-dialog.component.html | 88 ++ .../automation-review-dialog.component.scss | 184 +++ ...automation-review-dialog.component.spec.ts | 116 ++ .../automation-review-dialog.component.ts | 101 ++ .../dice-roller/dice-roller.component.scss | 20 +- .../falling-damage-dialog.component.html | 112 ++ .../falling-damage-dialog.component.scss | 259 ++++ .../falling-damage-dialog.component.spec.ts | 75 + .../falling-damage-dialog.component.ts | 214 +++ .../falling-notice-dialog.component.html | 16 + .../falling-notice-dialog.component.scss | 46 + .../falling-notice-dialog.component.ts | 35 + .../options-dialog.component.html | 19 +- .../options-dialog.component.spec.ts | 44 +- .../options-dialog.component.ts | 27 +- .../mek-critical-chance-dialog.component.ts | 2 +- .../mek-critical-dialog.component.scss | 1 + .../mek-critical-hit-dialog.component.spec.ts | 2 +- .../mek-critical-hit-dialog.component.ts | 13 +- ...ek-floating-critical-dialog.component.scss | 84 ++ ...floating-critical-dialog.component.spec.ts | 127 ++ .../mek-floating-critical-dialog.component.ts | 253 ++++ .../page-interaction-overlay.component.html | 33 +- .../page-interaction-overlay.component.scss | 27 +- ...page-interaction-overlay.component.spec.ts | 107 ++ .../page-interaction-overlay.component.ts | 113 +- .../page-psr-warning-panel.component.html | 32 +- .../page-psr-warning-panel.component.scss | 45 +- .../page-psr-warning-panel.component.spec.ts | 326 ++++- .../page-psr-warning-panel.component.ts | 228 ++- .../page-standing-up-panel.component.html | 130 +- .../page-standing-up-panel.component.scss | 10 + .../page-standing-up-panel.component.spec.ts | 197 ++- .../page-standing-up-panel.component.ts | 87 +- .../page-turn-summary-panel.component.spec.ts | 10 + .../page-turn-summary-panel.component.ts | 3 + .../page-viewer/page-viewer.component.ts | 17 +- .../svg-interaction.service.spec.ts | 52 +- .../page-viewer/svg-interaction.service.ts | 28 +- .../pending-unit-check-dialog.component.scss | 276 ++++ ...ending-unit-check-dialog.component.spec.ts | 333 +++++ .../pending-unit-check-dialog.component.ts | 165 +++ .../pending-unit-check-row.component.ts | 287 ++++ .../unit-block/unit-block.component.spec.ts | 78 - .../unit-notification-badges.component.html | 66 + .../unit-notification-badges.component.scss | 168 +++ ...unit-notification-badges.component.spec.ts | 227 +++ .../unit-notification-badges.component.ts | 75 + .../unit-notification-tooltip.util.ts | 279 ++++ src/app/models/automation-review.model.ts | 30 + src/app/models/cbt-force-unit-state.model.ts | 45 +- src/app/models/cbt-force-unit.model.spec.ts | 1171 ++++++++++++--- src/app/models/cbt-force-unit.model.ts | 659 ++++++++- src/app/models/crew-member.model.ts | 27 +- src/app/models/force-serialization.spec.ts | 253 +++- src/app/models/force-serialization.ts | 481 ++++++- src/app/models/options.model.ts | 15 +- src/app/models/rules/aero-rules.ts | 3 +- src/app/models/rules/mek-rules.spec.ts | 36 +- src/app/models/rules/mek-rules.ts | 138 +- src/app/models/rules/tw-rules.spec.ts | 63 + src/app/models/rules/tw-rules.ts | 74 +- src/app/models/rules/unit-type-rules.ts | 24 +- src/app/models/rules/vehicle-rules.ts | 2 +- src/app/models/turn-state.model.spec.ts | 1278 ++++++++++++++++- src/app/models/turn-state.model.ts | 758 +++++++++- src/app/models/unit-check.model.spec.ts | 84 ++ src/app/models/unit-check.model.ts | 380 +++++ .../automation-review.service.spec.ts | 60 + src/app/services/automation-review.service.ts | 47 + .../cbt-automation-toast.service.spec.ts | 57 + .../services/cbt-automation-toast.service.ts | 38 + .../services/cbt-automation.service.spec.ts | 57 + src/app/services/cbt-automation.service.ts | 37 + src/app/services/cbt-end-turn.service.spec.ts | 515 +++++++ src/app/services/cbt-end-turn.service.ts | 387 +++++ .../cbt-phase-resolution.service.spec.ts | 389 +++++ .../services/cbt-phase-resolution.service.ts | 246 ++++ .../falling-resolution.service.spec.ts | 397 +++++ .../services/falling-resolution.service.ts | 289 ++++ ...ek-critical-hit-automation.service.spec.ts | 165 +++ .../mek-critical-hit-automation.service.ts | 169 +++ .../mek-critical-resolution.service.spec.ts | 1057 ++++++++++++++ .../mek-critical-resolution.service.ts | 714 +++++++++ src/app/services/options.service.spec.ts | 38 +- src/app/services/options.service.ts | 96 +- src/app/services/toast.service.ts | 4 +- .../unit-check-resolution.service.spec.ts | 1168 +++++++++++++++ .../services/unit-check-resolution.service.ts | 579 ++++++++ src/app/services/unit-svg.service.ts | 2 +- src/app/utils/heat-effects.util.spec.ts | 105 +- src/app/utils/heat-effects.util.ts | 87 +- src/app/utils/mek-critical-hit.util.spec.ts | 417 +++++- src/app/utils/mek-critical-hit.util.ts | 556 ++++++- src/app/utils/mek-falling.util.spec.ts | 285 ++++ src/app/utils/mek-falling.util.ts | 310 ++++ src/app/utils/pilot-damage-group.util.ts | 67 + src/app/utils/record-sheet-reference-table.ts | 160 ++- src/app/utils/unit-check.util.ts | 500 +++++++ src/styles.scss | 8 + 102 files changed, 18683 insertions(+), 1123 deletions(-) delete mode 100644 scripts/ratgenerator_build_table.test.ts create mode 100644 src/app/components/automation-review-dialog/automation-review-dialog.component.html create mode 100644 src/app/components/automation-review-dialog/automation-review-dialog.component.scss create mode 100644 src/app/components/automation-review-dialog/automation-review-dialog.component.spec.ts create mode 100644 src/app/components/automation-review-dialog/automation-review-dialog.component.ts create mode 100644 src/app/components/falling-damage-dialog/falling-damage-dialog.component.html create mode 100644 src/app/components/falling-damage-dialog/falling-damage-dialog.component.scss create mode 100644 src/app/components/falling-damage-dialog/falling-damage-dialog.component.spec.ts create mode 100644 src/app/components/falling-damage-dialog/falling-damage-dialog.component.ts create mode 100644 src/app/components/falling-notice-dialog/falling-notice-dialog.component.html create mode 100644 src/app/components/falling-notice-dialog/falling-notice-dialog.component.scss create mode 100644 src/app/components/falling-notice-dialog/falling-notice-dialog.component.ts create mode 100644 src/app/components/page-viewer/mek-floating-critical-dialog.component.scss create mode 100644 src/app/components/page-viewer/mek-floating-critical-dialog.component.spec.ts create mode 100644 src/app/components/page-viewer/mek-floating-critical-dialog.component.ts create mode 100644 src/app/components/page-viewer/overlay/page-interaction-overlay.component.spec.ts create mode 100644 src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.scss create mode 100644 src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.spec.ts create mode 100644 src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.ts create mode 100644 src/app/components/pending-unit-check-dialog/pending-unit-check-row.component.ts create mode 100644 src/app/components/unit-notification-badges/unit-notification-badges.component.html create mode 100644 src/app/components/unit-notification-badges/unit-notification-badges.component.scss create mode 100644 src/app/components/unit-notification-badges/unit-notification-badges.component.spec.ts create mode 100644 src/app/components/unit-notification-badges/unit-notification-badges.component.ts create mode 100644 src/app/components/unit-notification-badges/unit-notification-tooltip.util.ts create mode 100644 src/app/models/automation-review.model.ts create mode 100644 src/app/models/unit-check.model.spec.ts create mode 100644 src/app/models/unit-check.model.ts create mode 100644 src/app/services/automation-review.service.spec.ts create mode 100644 src/app/services/automation-review.service.ts create mode 100644 src/app/services/cbt-automation-toast.service.spec.ts create mode 100644 src/app/services/cbt-automation-toast.service.ts create mode 100644 src/app/services/cbt-automation.service.spec.ts create mode 100644 src/app/services/cbt-automation.service.ts create mode 100644 src/app/services/cbt-end-turn.service.spec.ts create mode 100644 src/app/services/cbt-end-turn.service.ts create mode 100644 src/app/services/cbt-phase-resolution.service.spec.ts create mode 100644 src/app/services/cbt-phase-resolution.service.ts create mode 100644 src/app/services/falling-resolution.service.spec.ts create mode 100644 src/app/services/falling-resolution.service.ts create mode 100644 src/app/services/mek-critical-hit-automation.service.spec.ts create mode 100644 src/app/services/mek-critical-hit-automation.service.ts create mode 100644 src/app/services/mek-critical-resolution.service.spec.ts create mode 100644 src/app/services/mek-critical-resolution.service.ts create mode 100644 src/app/services/unit-check-resolution.service.spec.ts create mode 100644 src/app/services/unit-check-resolution.service.ts create mode 100644 src/app/utils/mek-falling.util.spec.ts create mode 100644 src/app/utils/mek-falling.util.ts create mode 100644 src/app/utils/pilot-damage-group.util.ts create mode 100644 src/app/utils/unit-check.util.ts diff --git a/scripts/lib/bfs-generation.ts b/scripts/lib/bfs-generation.ts index c32a73472..b1036cb87 100644 --- a/scripts/lib/bfs-generation.ts +++ b/scripts/lib/bfs-generation.ts @@ -257,7 +257,7 @@ export function renderBfsGenerationReport(files: readonly PlannedBfsFile[], aero `- Supported definitions: **${files.length}** (${files.length - existing} new, ${existing} existing)`, `- Linked MTF/BLK definitions: **${linked}**`, `- Standalone emplacements: **${files.length - linked}**`, - `- skipped Aerospace: **${aerospace.length}**`, + `- Aerospace rows blocked: **${aerospace.length}**`, '', '## Generated definitions', '', diff --git a/scripts/ratgenerator_build_table.test.ts b/scripts/ratgenerator_build_table.test.ts deleted file mode 100644 index 7452bc58c..000000000 --- a/scripts/ratgenerator_build_table.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; - -import { buildRatGeneratorCsv } from './ratgenerator_build_table'; - -const APP_ROOT = path.resolve(__dirname, '..'); -const FIXTURE_PATH = path.join(APP_ROOT, 'scripts', 'fixtures', 'ratgenerator_reference.csv'); - -function findFirstDiffLine(expected: string, actual: string): string { - const expectedLines = expected.split('\n'); - const actualLines = actual.split('\n'); - const max = Math.max(expectedLines.length, actualLines.length); - for (let index = 0; index < max; index += 1) { - if (expectedLines[index] !== actualLines[index]) { - return `line ${index + 1}\nexpected: ${expectedLines[index] ?? ''}\nactual: ${actualLines[index] ?? ''}`; - } - } - return 'unknown diff'; -} - -async function main(): Promise { - const expected = fs.readFileSync(FIXTURE_PATH, 'utf8').replace(/\r\n/g, '\n'); - const outputFilePath = path.join(APP_ROOT, 'tmp', 'ratgenerator.test.csv'); - const { csv } = await buildRatGeneratorCsv({ outputFilePath }); - const actual = csv.replace(/\r\n/g, '\n'); - - assert.equal( - actual, - expected, - `Generated CSV differs from fixture at ${findFirstDiffLine(expected, actual)}`, - ); - - console.log('[ratgenerator] fixture parity passed'); -} - -main().catch((error: unknown) => { - console.error('[ratgenerator] fixture parity failed', error); - process.exitCode = 1; -}); \ No newline at end of file diff --git a/src/app/components/automation-review-dialog/automation-review-dialog.component.html b/src/app/components/automation-review-dialog/automation-review-dialog.component.html new file mode 100644 index 000000000..41d55d86c --- /dev/null +++ b/src/app/components/automation-review-dialog/automation-review-dialog.component.html @@ -0,0 +1,88 @@ +
+
{{ data.title }}
+
+

{{ data.message }}

+ +
+ @for (item of data.events; track item.id) { +
+
+

{{ item.subject }}

+
{{ item.event }}
+
+ {{ item.description }} + @if (item.delta) { + + ({{ signed(item.delta) }}) + + } +
+ @if (item.effects?.length) { +
+ @for (effect of item.effects; track effect) { + {{ effect }} + } +
+ } +
+ @if (item.breakdown?.length || data.events.length > 1) { +
+ @if (item.breakdown?.length) { +
+ @for (detail of item.breakdown; track detail.id) { +
+ {{ detail.label }} + + {{ signed(detail.value) }} + +
+ } +
+ } + @if (data.events.length > 1) { +
+ + +
+ } +
+ } +
+ } +
+
+ +
+ @if (data.events.length === 1) { + + + } @else { + + } + @if (data.allowCancel) { + + } +
+
diff --git a/src/app/components/automation-review-dialog/automation-review-dialog.component.scss b/src/app/components/automation-review-dialog/automation-review-dialog.component.scss new file mode 100644 index 000000000..17209dc8e --- /dev/null +++ b/src/app/components/automation-review-dialog/automation-review-dialog.component.scss @@ -0,0 +1,184 @@ +:host { + display: block; +} + +.panel { + width: min(520px, calc(100vw - 24px)); +} + +.automation-review-body { + display: flex; + flex-direction: column; + gap: 8px; +} + +.automation-review-message { + margin: 0 8px 4px; + color: var(--text-color-secondary); + font-size: 0.9em; + line-height: 1.35; +} + +.automation-event-list { + display: flex; + flex-direction: column; + gap: 6px; + padding: 0 4px; +} + +.automation-event { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(230px, 1fr); + align-items: stretch; + gap: 8px; + padding: 8px; + border: 1px solid var(--border-color); + border-left: 3px solid var(--bt-yellow); + background: var(--background-input); + transition: opacity 0.2s, border-color 0.2s; +} + +.automation-event.accepted { + border-left-color: #9a9aff; +} + +.automation-event.rejected { + border-left-color: #666; + opacity: 0.8; +} + +.automation-event.single-event { + grid-template-columns: minmax(0, 1fr); +} + +.automation-event-info { + min-width: 0; +} + +.automation-event h3 { + margin: 0; + color: var(--text-color); + font-size: 0.95em; + line-height: 1.25; +} + +.automation-event-name { + color: var(--bt-yellow); + font-size: 0.82em; + font-weight: 600; +} + +.automation-event-description { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 4px; + margin-top: 2px; + color: var(--text-color-secondary); + font-size: 0.82em; +} + +.automation-event-delta, +.automation-event-breakdown-row strong { + color: var(--text-color-secondary); + font-variant-numeric: tabular-nums; +} + +.automation-event-delta.cooling, +.automation-event-breakdown-row strong.cooling { + color: #2070d1; +} + +.automation-event-delta.heating, +.automation-event-breakdown-row strong.heating { + color: var(--danger); +} + +.automation-event-effects { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; +} + +.automation-event-effects span { + padding: 2px 5px; + border: 1px solid var(--danger); + background: #311; + color: var(--danger); + font-size: 0.78em; + line-height: 1.2; +} + +.automation-event-side { + align-self: stretch; + display: flex; + min-width: 0; + flex-direction: column; + justify-content: flex-end; + gap: 8px; +} + +.automation-event-breakdown { + display: grid; + gap: 1px; +} + +.automation-event-breakdown-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 52px; + background: var(--background-highlight); + color: var(--text-color-secondary); + font-size: 0.78em; +} + +.automation-event-breakdown-row > span { + align-self: center; + min-width: 0; + padding: 1px 4px; + border-left: 1px solid var(--border-color); +} + +.automation-event-breakdown-row > strong { + display: grid; + place-items: center; + border-left: 1px solid var(--border-color); +} + +.automation-event-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + align-self: stretch; +} + +.automation-event-actions .bt-button { + width: 100%; + min-width: 0; +} + +.automation-event-actions .bt-button.success.selected { + background-color: var(--success); +} + +.automation-event-actions .bt-button.danger.selected { + background-color: var(--danger); + --btn-text-color: #fff; +} + +.automation-review-actions { + gap: 8px; + flex-wrap: wrap; +} + +.automation-review-actions .bt-button { + flex: 1 1 0; + width: auto; + min-width: 120px; +} + +@media (max-width: 440px) { + .automation-event { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/src/app/components/automation-review-dialog/automation-review-dialog.component.spec.ts b/src/app/components/automation-review-dialog/automation-review-dialog.component.spec.ts new file mode 100644 index 000000000..5569bdf8e --- /dev/null +++ b/src/app/components/automation-review-dialog/automation-review-dialog.component.spec.ts @@ -0,0 +1,116 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { provideZonelessChangeDetection } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type { AutomationReviewDialogData } from '../../models/automation-review.model'; +import { AutomationReviewDialogComponent } from './automation-review-dialog.component'; + +describe('AutomationReviewDialogComponent', () => { + let close: jasmine.Spy; + let component: AutomationReviewDialogComponent; + const data: AutomationReviewDialogData = { + title: 'Review', + message: 'Choose', + allowCancel: true, + events: [ + { id: 'one', subject: 'Atlas', event: 'Heat', description: 'Heat 1 → 19' }, + { id: 'two', subject: 'Marauder', event: 'Heat', description: 'Heat 3 → 1' }, + ], + }; + + beforeEach(async () => { + close = jasmine.createSpy('close'); + await TestBed.configureTestingModule({ + imports: [AutomationReviewDialogComponent], + providers: [ + provideZonelessChangeDetection(), + { provide: DIALOG_DATA, useValue: data }, + { provide: DialogRef, useValue: { close } }, + ], + }).compileComponents(); + component = TestBed.createComponent(AutomationReviewDialogComponent).componentInstance; + }); + + it('accepts every event when no rejection has been selected', () => { + component.choose('one', true); + + expect(component.reviewAction().kind).toBe('accept-all'); + component.performReviewAction(); + + expect(close).toHaveBeenCalledOnceWith({ acceptedEventIds: ['one', 'two'] }); + }); + + it('waits for every mixed decision before applying only accepted events', () => { + component.choose('one', false); + + expect(component.reviewAction()).toEqual(jasmine.objectContaining({ + kind: 'apply-choices', + disabled: true, + })); + component.performReviewAction(); + expect(close).not.toHaveBeenCalled(); + + component.choose('two', true); + component.performReviewAction(); + + expect(close).toHaveBeenCalledOnceWith({ acceptedEventIds: ['two'] }); + }); + + it('skips every event when all decisions are rejected', () => { + component.choose('one', false); + component.choose('two', false); + + expect(component.reviewAction().kind).toBe('skip-all'); + component.performReviewAction(); + + expect(close).toHaveBeenCalledOnceWith({ acceptedEventIds: [] }); + }); + + it('cancels without a decision when cancellation is allowed', () => { + component.cancel(); + + expect(close).toHaveBeenCalledOnceWith(undefined); + }); +}); + +describe('AutomationReviewDialogComponent with one event', () => { + it('resolves the single event directly in either direction', async () => { + const close = jasmine.createSpy('close'); + const data: AutomationReviewDialogData = { + title: 'Review', + message: 'Choose', + allowCancel: false, + events: [{ id: 'one', subject: 'Atlas', event: 'Heat', description: 'Heat 1 → 19' }], + }; + await TestBed.configureTestingModule({ + imports: [AutomationReviewDialogComponent], + providers: [ + provideZonelessChangeDetection(), + { provide: DIALOG_DATA, useValue: data }, + { provide: DialogRef, useValue: { close } }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(AutomationReviewDialogComponent); + fixture.detectChanges(); + const component = fixture.componentInstance; + const actionButtons = fixture.nativeElement.querySelectorAll( + '.automation-review-actions button', + ) as NodeListOf; + + expect(Array.from(actionButtons, button => button.textContent?.trim())).toEqual([ + 'ACCEPT', + 'SKIP', + ]); + + component.resolveSingle(true); + component.resolveSingle(false); + + expect(close.calls.allArgs()).toEqual([ + [{ acceptedEventIds: ['one'] }], + [{ acceptedEventIds: [] }], + ]); + }); +}); diff --git a/src/app/components/automation-review-dialog/automation-review-dialog.component.ts b/src/app/components/automation-review-dialog/automation-review-dialog.component.ts new file mode 100644 index 000000000..19861a2bf --- /dev/null +++ b/src/app/components/automation-review-dialog/automation-review-dialog.component.ts @@ -0,0 +1,101 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { DialogRef, DIALOG_DATA } from '@angular/cdk/dialog'; +import type { AutomationReviewDialogData, AutomationReviewResult } from '../../models/automation-review.model'; + +interface ReviewActionData { + kind: 'accept-all' | 'skip-all' | 'apply-choices', + label: string, + tone: 'primary' | 'success' | 'danger' | 'muted' | undefined; + disabled: boolean; +} + +@Component({ + selector: 'automation-review-dialog', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './automation-review-dialog.component.html', + styleUrls: [ + '../page-viewer/overlay/page-psr-warning-panel.component.scss', + './automation-review-dialog.component.scss', + ], +}) +export class AutomationReviewDialogComponent { + readonly data = inject(DIALOG_DATA); + private readonly dialogRef = inject>(DialogRef); + private readonly decisions = signal>(new Map()); + + readonly allDecided = computed(() => this.decisions().size === this.data.events.length); + readonly reviewAction = computed(() => { + const decisions = this.decisions(); + const rejectedCount = this.data.events.reduce( + (count, event) => count + (decisions.get(event.id) === false ? 1 : 0), + 0, + ); + + if (rejectedCount === 0) { + return { kind: 'accept-all', label: 'ACCEPT ALL', tone: 'success', disabled: false } as const; + } + if (rejectedCount === this.data.events.length) { + return { kind: 'skip-all', label: 'SKIP ALL', tone: 'danger', disabled: false } as const; + } + return { + kind: 'apply-choices', + label: 'APPLY CHOICES', + tone: 'primary', + disabled: !this.allDecided(), + }; + }); + + decision(eventId: string): boolean | undefined { + return this.decisions().get(eventId); + } + + signed(value: number): string { + return value > 0 ? `+${value}` : String(value); + } + + choose(eventId: string, accepted: boolean): void { + const next = new Map(this.decisions()); + next.set(eventId, accepted); + this.decisions.set(next); + } + + acceptAll(): void { + this.closeWithAccepted(this.data.events.map(event => event.id)); + } + + performReviewAction(): void { + const action = this.reviewAction(); + if (action.disabled) return; + if (action.kind === 'accept-all') { + this.acceptAll(); + return; + } + this.applyChoices(); + } + + applyChoices(): void { + if (!this.allDecided()) return; + const acceptedEventIds = this.data.events + .filter(event => this.decisions().get(event.id) === true) + .map(event => event.id); + this.closeWithAccepted(acceptedEventIds); + } + + resolveSingle(accepted: boolean): void { + if (this.data.events.length !== 1) return; + this.closeWithAccepted(accepted ? [this.data.events[0].id] : []); + } + + cancel(): void { + this.dialogRef.close(undefined); + } + + private closeWithAccepted(acceptedEventIds: string[]): void { + this.dialogRef.close({ acceptedEventIds }); + } +} diff --git a/src/app/components/dice-roller/dice-roller.component.scss b/src/app/components/dice-roller/dice-roller.component.scss index 27497e13a..3ef2d25fe 100644 --- a/src/app/components/dice-roller/dice-roller.component.scss +++ b/src/app/components/dice-roller/dice-roller.component.scss @@ -30,20 +30,28 @@ } } -.sum.large { - font-size: clamp(24px, 7vw, 32px); - font-weight: bold; - color: #fff; - white-space: nowrap; +.sum { display: flex; align-items: center; gap: 4px; .value { - font-size: 1.5em; + font-size: 1.4em; + } + + &.large { + font-size: clamp(24px, 7vw, 32px); + font-weight: bold; + color: #fff; + white-space: nowrap; + + .value { + font-size: 1.5em; + } } } + .plus-sign { .dice-roller-root:not(.small) & { font-size: clamp(24px, 7vw, 32px); diff --git a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.html b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.html new file mode 100644 index 000000000..929c2f270 --- /dev/null +++ b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.html @@ -0,0 +1,112 @@ +
+
Falling Damage
+
+
+
+ {{ data.unit.getNotificationDisplayName() }} + {{ sourceMessage }} +
+
+ Fall Damage + {{ totalDamage }} +
+
+ @if (armorNote) { +

{{ armorNote }}

+ } + +
+
+ +
OrientationDetermine facing and the damage arc.
+
+
+
+ @for (roll of d6Rolls; track roll) { + + } +
+
+ @if (orientation(); as result) { +
+
{{ result.facingInstruction }}{{ result.hitArcLabel }} damage
+

{{ result.rulesExplanation }}

+
+ } +
+ +
+
+ +
Hit locationsResolve each damage group separately.
+
+
+ @for (row of groupRows(); track row.index) { +
+
+
+ {{ row.damage }} damage +
+ @if (row.result?.locationLabel) { +
+
+ {{ row.result!.locationLabel }} + @if (row.result!.rear) { Rear armor } + @if (row.result!.adjustedTripodLegRoll !== undefined) { + Adjusted leg roll {{ row.result!.adjustedTripodLegRoll }} + } +
+ @if (row.result!.critical) { + Through-armor critical! + } +
+ } +
+
+
+ + @for (roll of twoD6Rolls; track roll) { + + } +
+
+ + @if (row.result?.location === null && row.result.tripodLegModifier !== undefined) { +
+ Tripod leg ({{ signed(row.result!.tripodLegModifier!) }}) +
+ @for (roll of d6Rolls; track roll) { + + } +
+
+ } + +
+ } +
+
+
+
+ + + +
+
diff --git a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.scss b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.scss new file mode 100644 index 000000000..0abb43793 --- /dev/null +++ b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.scss @@ -0,0 +1,259 @@ +:host { + display: block; +} + +.panel { + width: min(660px, calc(100vw - 24px)); + max-height: min(780px, calc(100dvh - 24px)); +} + +.random-button { + background-size: 32px; +} + +.falling-body { + display: grid; + gap: 10px; + overflow: auto; +} + +.fall-context { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 10px; + border-left: 3px solid var(--danger); + background: var(--background-input); +} + +.fall-context > div, +.fall-totals, +.step-heading > div { + display: flex; + flex-direction: column; + gap: 2px; +} + +.fall-context span, +.step-heading span, +.damage-location span, +.orientation-result p { + color: var(--text-color-secondary); + font-size: 0.78em; +} + +.fall-totals { + min-width: 96px; + align-items: end; + text-align: right; +} + +.fall-totals span { + font-size: 0.72em; + font-weight: 700; + text-transform: uppercase; +} + +.fall-totals strong { + color: var(--danger); + font-size: 2em; + line-height: 1; +} + +.fall-armor-note { + margin: 0 4px; + color: var(--bt-yellow); + font-size: 0.78em; + line-height: 1.35; +} + +.fall-step { + display: grid; + gap: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-color); +} + +.step-heading { + display: flex; + align-items: center; + gap: 8px; +} + +.tripod-roll { + display: grid; + gap: 6px; + padding: 8px; + background: var(--background-input); +} + +.roll-picker { + padding: 8px; + background: var(--background-input); +} + +.d6-buttons { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 4px; +} + +.two-d6-buttons { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 4px; +} + +.roll-grid-label { + display: grid; + min-width: 0; + place-items: center; + color: var(--text-color-secondary); + font-weight: 800; +} + +.roll-face { + width: 100%; + min-width: 0; + padding-inline: 4px; + font-variant-numeric: tabular-nums; +} + +.roll-face.selected { + border-color: var(--bt-yellow); + background: var(--background-highlight); + color: var(--bt-yellow); +} + +.orientation-result { + display: grid; + grid-template-columns: minmax(0, auto) minmax(180px, 1fr); + align-items: center; + gap: 10px; + padding: 8px 10px; + border-left: 3px solid var(--bt-yellow); + background: var(--background-input); +} + +.orientation-result > div { + display: flex; + flex-direction: column; + gap: 2px; + padding-right: 8px; + border-right: 1px solid var(--border-color); +} + +.orientation-result > div span { + color: var(--bt-yellow); + font-size: 0.72em; + font-weight: 700; + text-transform: uppercase; +} + +.orientation-result p { + margin: 0; + line-height: 1.35; +} + +.damage-groups { + display: grid; + gap: 6px; +} + +.damage-group { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 8px; + padding: 8px; + border: 1px solid var(--border-color); + border-left: 3px solid var(--border-color); + background: var(--background-input); +} + +.damage-group.resolved { + border-left-color: var(--danger); +} + +.damage-group-summary { + display: flex; + min-width: 0; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.damage-group-heading { + display: flex; + flex-direction: column; + gap: 2px; +} + +.damage-group-heading span { + color: var(--danger); + font-weight: 700; +} + +.location-roll { + min-width: 0; +} + +.damage-location .through-armor-critical { + color: var(--danger); + font-weight: 700; +} + +.tripod-roll-label { + color: var(--text-color-secondary); + font-size: 0.78em; + font-weight: 700; +} + +.damage-location { + display: flex; + min-width: 0; + flex-direction: column; + align-items: flex-end; + margin-left: auto; + text-align: right; + gap: 2px; +} + +.damage-location-primary { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: flex-end; + gap: 4px 8px; +} + +.falling-actions { + gap: 8px; +} + +.falling-actions .bt-button { + flex: 1 1 0; + width: auto; +} + +@media (max-width: 560px) { + .orientation-result { + align-items: stretch; + grid-template-columns: 1fr; + + > div { + padding-right: 0; + border-right: 0; + } + } + + .fall-totals { + align-self: end; + align-items: end; + text-align: right; + } + + .two-d6-buttons { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } +} diff --git a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.spec.ts b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.spec.ts new file mode 100644 index 000000000..87830e295 --- /dev/null +++ b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.spec.ts @@ -0,0 +1,75 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { provideZonelessChangeDetection } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; +import { + FallingDamageDialogComponent, + type FallingDamageDialogData, +} from './falling-damage-dialog.component'; + +describe('FallingDamageDialogComponent', () => { + let fixture: ComponentFixture; + let persistRolls: jasmine.Spy; + + beforeEach(async () => { + persistRolls = jasmine.createSpy('setPendingFallRolls'); + const unit = { + gameRules: { id: 'core2026' }, + getPendingFall: () => undefined, + setPendingFallRolls: persistRolls, + getNotificationDisplayName: () => 'Atlas AS7-D', + getUnit: () => ({ + type: 'Mek', + subtype: 'Biped', + comp: [], + tons: 70, + armorType: 'Standard', + }), + } as unknown as CBTForceUnit; + const data: FallingDamageDialogData = { + unit, + trigger: { + kind: 'falling', + id: 'fall:test', + source: 'psr', + levelsFallen: 0, + }, + }; + + await TestBed.configureTestingModule({ + imports: [FallingDamageDialogComponent], + providers: [ + provideZonelessChangeDetection(), + { provide: DIALOG_DATA, useValue: data }, + { provide: DialogRef, useValue: { close: jasmine.createSpy('close') } }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(FallingDamageDialogComponent); + fixture.detectChanges(); + }); + + it('rolls and persists orientation plus every damage-group location as one action', () => { + const random = spyOn(Math, 'random').and.returnValues( + 0, 0, 0, 0.999, 0.999, + 0.999, 0.5, 0.5, 0, 0, + ); + + fixture.componentInstance.rollAllResults(); + + expect(fixture.componentInstance.orientationRoll()).toBe(1); + expect(fixture.componentInstance.groupRows().map(row => row.hitLocationRoll)).toEqual([2, 12]); + expect(fixture.componentInstance.groupRows().every(row => row.result?.location !== null)).toBeTrue(); + expect(fixture.componentInstance.allResolved()).toBeTrue(); + expect(persistRolls).toHaveBeenCalled(); + + fixture.componentInstance.rollAllResults(); + + expect(fixture.componentInstance.orientationRoll()).toBe(6); + expect(fixture.componentInstance.groupRows().map(row => row.hitLocationRoll)).toEqual([8, 2]); + expect(random).toHaveBeenCalledTimes(10); + }); +}); diff --git a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.ts b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.ts new file mode 100644 index 000000000..73480ee2d --- /dev/null +++ b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.ts @@ -0,0 +1,214 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import type { + CBTForceUnit, + CBTMekFallDamageRoll, + CBTUnitAutomationTrigger, +} from '../../models/cbt-force-unit.model'; +import { + isImpactResistantArmor, + isResolvedMekFallHitLocation, + mekFallDamage, + mekFallDamageGroups, + resolveMekFallHitLocation, + resolveMekFallOrientation, + type MekFallHitLocationResult, + type MekFallOrientation, + type ResolvedMekFallDamageGroup, +} from '../../utils/mek-falling.util'; +import { clusterTableForUnit, type MekHitLocationTable } from '../../utils/record-sheet-reference-table'; + +export type FallingAutomationTrigger = Extract; + +export interface FallingDamageDialogData { + readonly unit: CBTForceUnit; + readonly trigger: FallingAutomationTrigger; +} + +export interface AcceptedFallingDamageDialogResult { + readonly action: 'accept'; + readonly orientation: MekFallOrientation; + readonly groups: readonly ResolvedMekFallDamageGroup[]; +} + +export type FallingDamageDialogResult = AcceptedFallingDamageDialogResult + | { readonly action: 'ignore' } + | { readonly action: 'close' }; + +interface FallingDamageGroupRoll { + readonly hitLocationRoll: number | null; + readonly hitLocationDice: readonly [number, number] | null; + readonly tripodLegRoll: number | null; + readonly tripodLegDice: readonly [number] | null; +} + +interface FallingDamageGroupRow extends FallingDamageGroupRoll { + readonly index: number; + readonly damage: number; + readonly result: MekFallHitLocationResult | null; +} + +@Component({ + selector: 'falling-damage-dialog', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './falling-damage-dialog.component.html', + styleUrls: [ + '../page-viewer/overlay/page-psr-warning-panel.component.scss', + './falling-damage-dialog.component.scss', + ], +}) +export class FallingDamageDialogComponent { + readonly data = inject(DIALOG_DATA); + private readonly dialogRef = inject>(DialogRef); + private readonly pending = this.data.unit.getPendingFall(this.data.trigger.id); + + readonly rulesId = this.data.unit.gameRules.id; + readonly tons = this.data.unit.getUnit().tons; + readonly levelsFallen = this.data.trigger.levelsFallen; + readonly totalDamage = mekFallDamage(this.tons, this.levelsFallen); + readonly damageGroups = mekFallDamageGroups(this.totalDamage); + readonly hitLocationTable: MekHitLocationTable = clusterTableForUnit(this.data.unit.getUnit()).hitLocationTable + ?? 'biped'; + readonly orientationRoll = signal(this.pending?.orientationRoll ?? null); + readonly orientationDice = signal(this.pending?.orientationDice ?? null); + private readonly groupRolls = signal( + this.damageGroups.map((_damage, index) => { + const pendingRoll = this.pending?.damageRolls[index]; + return { + hitLocationRoll: pendingRoll?.hitLocationRoll ?? null, + hitLocationDice: pendingRoll?.hitLocationDice ?? null, + tripodLegRoll: pendingRoll?.tripodLegRoll ?? null, + tripodLegDice: pendingRoll?.tripodLegDice ?? null, + }; + }), + ); + + readonly d6Rolls = [1, 2, 3, 4, 5, 6] as const; + readonly twoD6Rolls = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const; + readonly orientation = computed(() => { + const roll = this.orientationRoll(); + return roll === null ? null : resolveMekFallOrientation(this.rulesId, roll); + }); + readonly groupRows = computed(() => { + const orientation = this.orientation(); + return this.groupRolls().map((roll, index) => ({ + index, + damage: this.damageGroups[index], + ...roll, + result: orientation && roll.hitLocationRoll !== null + ? resolveMekFallHitLocation( + this.hitLocationTable, + orientation.hitArc, + roll.hitLocationRoll, + roll.tripodLegRoll ?? undefined, + ) + : null, + })); + }); + readonly allResolved = computed(() => { + if (!this.orientation()) return false; + return this.groupRows().every(row => row.result && isResolvedMekFallHitLocation(row.result)); + }); + readonly sourceMessage = this.data.trigger.source === 'stand-attempt' + ? 'The stand-up attempt failed, so the Mek falls again.' + : 'A failed Piloting Skill Roll caused the Mek to fall.'; + readonly armorNote = isImpactResistantArmor(this.data.unit.getUnit().armorType) + ? 'Impact-Resistant Armor halves each group that reaches intact armor, rounding down to a minimum of 1 damage.' + : null; + + setOrientationRoll(roll: number | null): void { + this.orientationRoll.set(validRoll(roll, 1, 6)); + this.orientationDice.set(null); + this.persistRolls(); + } + + setHitLocationRoll(index: number, roll: number | null): void { + this.updateGroupRoll(index, { + hitLocationRoll: validRoll(roll, 2, 12), + hitLocationDice: null, + }); + } + + setTripodLegRoll(index: number, roll: number | null): void { + this.updateGroupRoll(index, { + tripodLegRoll: validRoll(roll, 1, 6), + tripodLegDice: null, + }); + } + + rollAllResults(random: () => number = Math.random): void { + const orientationRoll = rollD6(random); + const orientation = resolveMekFallOrientation(this.rulesId, orientationRoll); + this.orientationRoll.set(orientationRoll); + this.orientationDice.set([orientationRoll]); + this.groupRolls.set(this.damageGroups.map(() => { + const hitLocationDice = [rollD6(random), rollD6(random)] as const; + const hitLocationRoll = hitLocationDice[0] + hitLocationDice[1]; + const preliminary = resolveMekFallHitLocation( + this.hitLocationTable, + orientation.hitArc, + hitLocationRoll, + ); + const needsTripodLeg = preliminary.location === null + && preliminary.tripodLegModifier !== undefined; + const tripodLegDice = needsTripodLeg ? [rollD6(random)] as const : null; + return { + hitLocationRoll, + hitLocationDice, + tripodLegRoll: tripodLegDice?.[0] ?? null, + tripodLegDice, + }; + })); + this.persistRolls(); + } + + apply(): void { + const orientation = this.orientation(); + if (!orientation || !this.allResolved()) return; + const groups = this.groupRows().map(row => ({ + ...row.result!, + damage: row.damage, + })) as ResolvedMekFallDamageGroup[]; + this.dialogRef.close({ action: 'accept', orientation, groups }); + } + + ignore(): void { + this.dialogRef.close({ action: 'ignore' }); + } + + close(): void { + this.dialogRef.close({ action: 'close' }); + } + + signed(value: number): string { + return value > 0 ? `+${value}` : String(value); + } + + private updateGroupRoll(index: number, update: Partial): void { + this.groupRolls.update(current => current.map((roll, rollIndex) => + rollIndex === index ? { ...roll, ...update } : roll)); + this.persistRolls(); + } + + private persistRolls(): void { + this.data.unit.setPendingFallRolls( + this.data.trigger.id, + this.orientationRoll(), + this.groupRolls() satisfies readonly CBTMekFallDamageRoll[], + this.orientationDice(), + ); + } +} + +function validRoll(value: number | null, min: number, max: number): number | null { + return value !== null && Number.isInteger(value) && value >= min && value <= max ? value : null; +} + +function rollD6(random: () => number): number { + return Math.floor(random() * 6) + 1; +} diff --git a/src/app/components/falling-notice-dialog/falling-notice-dialog.component.html b/src/app/components/falling-notice-dialog/falling-notice-dialog.component.html new file mode 100644 index 000000000..aefd1f6bc --- /dev/null +++ b/src/app/components/falling-notice-dialog/falling-notice-dialog.component.html @@ -0,0 +1,16 @@ +
+

{{ data.unitName }} falling direction

+
+ +

{{ data.orientation.facingInstruction }}. {{ data.orientation.rulesExplanation }}

+
+
+ +
+
diff --git a/src/app/components/falling-notice-dialog/falling-notice-dialog.component.scss b/src/app/components/falling-notice-dialog/falling-notice-dialog.component.scss new file mode 100644 index 000000000..2a6406bb0 --- /dev/null +++ b/src/app/components/falling-notice-dialog/falling-notice-dialog.component.scss @@ -0,0 +1,46 @@ +.content { + display: block; + max-width: 500px; +} + +h2 { + margin-top: 4px; + margin-bottom: 8px; +} + +.notice-content { + display: flex; + align-items: center; + gap: 16px; +} + +.notice-content p { + flex: 1 1 auto; +} + +.falling-icon { + display: block; + width: 64px; + height: 56px; + flex: 0 0 auto; + overflow: visible; +} + +.fall-background { + fill: #f00; +} + +.fall-symbol { + fill: #fff; +} + +[dialog-actions] { + display: flex; + justify-content: center; + padding-top: 8px; +} + +[dialog-actions] button { + min-width: 100px; + padding: 8px; +} diff --git a/src/app/components/falling-notice-dialog/falling-notice-dialog.component.ts b/src/app/components/falling-notice-dialog/falling-notice-dialog.component.ts new file mode 100644 index 000000000..1686fdf3d --- /dev/null +++ b/src/app/components/falling-notice-dialog/falling-notice-dialog.component.ts @@ -0,0 +1,35 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { DialogRef, DIALOG_DATA } from '@angular/cdk/dialog'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; + +export interface FallingNoticeOrientation { + readonly facingInstruction: string; + readonly rulesExplanation: string; +} + +export interface FallingNoticeDialogData { + readonly unitName: string; + readonly orientation: FallingNoticeOrientation; +} + +@Component({ + selector: 'falling-notice-dialog', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'fullscreen-dialog-host glass', + }, + templateUrl: './falling-notice-dialog.component.html', + styleUrl: './falling-notice-dialog.component.scss', +}) +export class FallingNoticeDialogComponent { + readonly data = inject(DIALOG_DATA); + private readonly dialogRef = inject>(DialogRef); + + dismiss(): void { + this.dialogRef.close(); + } +} diff --git a/src/app/components/options-dialog/options-dialog.component.html b/src/app/components/options-dialog/options-dialog.component.html index 844fe82e4..9c86d402e 100644 --- a/src/app/components/options-dialog/options-dialog.component.html +++ b/src/app/components/options-dialog/options-dialog.component.html @@ -533,8 +533,8 @@
Automations
-

Yes starts the workflow when it triggers, Ask first requests - permission, and No leaves it for manual tracking.

+

Yes automatically rolls and applies the result, Ask opens the + review and roll controls, and No leaves it for manual tracking.

@for (automation of cbtAutomationOptions; track automation.key) { @@ -548,8 +548,8 @@ @for (mode of cbtAutomationModes; track mode.value) { @@ -564,7 +564,16 @@
Optional rules
- + + +
+
+ - Careful stand (optional) - -2 - - @if (modifiersList().length > 0) { -
-
Modifiers
-
- @for (modifier of modifiersList(); track $index) { -
- - @if (modifier.loc) { {{ modifier.loc }} } @else { — } - - {{ modifier.reason }} - - {{ modifier.pilotCheck >= 0 ? '+' : '' }}{{ modifier.pilotCheck }} - + @if (!reviewOnly && lastOutcome() !== 'success' && canAttemptStand()) { +
+ + +
}
+ @if (!reviewOnly && supportsCarefulStand()) { + + } + @if (modifiersList().length > 0) { +
+
Modifiers
+
+ @for (modifier of modifiersList(); track $index) { +
+ + @if (modifier.loc) { {{ modifier.loc }} } @else { — } + + {{ modifier.reason }} + + {{ modifier.pilotCheck >= 0 ? '+' : '' }}{{ modifier.pilotCheck }} + +
+ } +
+
+ } }
@@ -67,16 +78,19 @@
- +@if (!reviewOnly && !canStandWithoutPSR()) { + +} diff --git a/src/app/components/page-viewer/overlay/page-standing-up-panel.component.scss b/src/app/components/page-viewer/overlay/page-standing-up-panel.component.scss index d59df5652..f826d41da 100644 --- a/src/app/components/page-viewer/overlay/page-standing-up-panel.component.scss +++ b/src/app/components/page-viewer/overlay/page-standing-up-panel.component.scss @@ -12,6 +12,11 @@ margin: 0; } +.careful-stand.disabled { + opacity: 0.55; + cursor: not-allowed; +} + .modifier-badge { flex: 0 0 24px; inline-size: 24px; @@ -42,6 +47,11 @@ font-weight: 700; } +.stand-attempt-limit { + font-size: 0.8em; + color: var(--bt-yellow); +} + .attempts strong { min-inline-size: 2ch; color: var(--text-color); diff --git a/src/app/components/page-viewer/overlay/page-standing-up-panel.component.spec.ts b/src/app/components/page-viewer/overlay/page-standing-up-panel.component.spec.ts index 19f4ced18..2711924d4 100644 --- a/src/app/components/page-viewer/overlay/page-standing-up-panel.component.spec.ts +++ b/src/app/components/page-viewer/overlay/page-standing-up-panel.component.spec.ts @@ -7,26 +7,48 @@ import { TestBed } from '@angular/core/testing'; import { DiceRollerComponent } from '../../dice-roller/dice-roller.component'; import { OverlayManagerService } from '../../../services/overlay-manager.service'; import { PageInteractionOverlayComponent } from './page-interaction-overlay.component'; -import { PageStandingUpPanelComponent } from './page-standing-up-panel.component'; +import { PageStandingUpPanelComponent, STANDING_UP_REVIEW_ONLY } from './page-standing-up-panel.component'; describe('PageStandingUpPanelComponent', () => { it('applies careful standing, resolves rolls, and adjusts the attempt count', () => { const attempts = signal(undefined); - const resolveStandAttempt = jasmine.createSpy('resolveStandAttempt').and.callFake(() => { + const carefulStand = signal(false); + const canStandUp = signal(true); + const canCarefulStand = signal(true); + const resolveStandAttempt = jasmine.createSpy('resolveStandAttempt').and.callFake(( + _outcome: string, + options: { carefulStand?: boolean }, + ) => { attempts.update(current => (current ?? 0) + 1); + if (options.carefulStand) { + carefulStand.set(true); + canStandUp.set(false); + } return true; }); const adjustStandAttempts = jasmine.createSpy('adjustStandAttempts').and.callFake((delta: number) => { attempts.update(current => Math.max(0, (current ?? 0) + delta)); + if (delta < 0) { + carefulStand.set(false); + canStandUp.set(true); + } }); const turnState = { standAttempts: attempts, + carefulStand, + canStandUp, + canStandWithoutPSR: signal(false), resolveStandAttempt, adjustStandAttempts, }; const unit = { id: 'unit-1', - rules: { standingUpPSRModifier: -1 }, + rules: { + standingUpPSRModifier: -1, + getStandAttemptLimit: () => 1, + supportsCarefulStand: true, + canCarefulStand: () => canCarefulStand() && !carefulStand(), + }, turnState: () => turnState, PSRTargetRoll: () => 8, PSRModifiers: () => ({ modifiers: [{ pilotCheck: 1, reason: 'Gyro damaged' }] }), @@ -47,12 +69,14 @@ describe('PageStandingUpPanelComponent', () => { .componentInstance as DiceRollerComponent; spyOn(roller, 'roll'); - expect((fixture.nativeElement.querySelector('.careful-stand .modifier-badge') as HTMLElement).textContent?.trim()).toBe('-2'); - expect((fixture.nativeElement.querySelector('.careful-stand') as HTMLElement).textContent).not.toContain('(-2 PSR)'); + expect(component.canCarefulStand()).toBeTrue(); + canCarefulStand.set(false); + expect(component.canCarefulStand()).toBeFalse(); + canCarefulStand.set(true); expect(component.targetRoll()).toBe(7); expect(component.attempts()).toBe(0); - component.carefulStand.set(true); + component.setCarefulStand({ target: { checked: true } } as unknown as Event); expect(component.targetRoll()).toBe(5); expect(component.modifiersList()).toEqual([ jasmine.objectContaining({ pilotCheck: 1, reason: 'Gyro damaged' }), @@ -64,24 +88,24 @@ describe('PageStandingUpPanelComponent', () => { component.onRollFinished({ results: [3, 3], sum: 6 }); expect(roller.roll).toHaveBeenCalledTimes(1); - expect(resolveStandAttempt).toHaveBeenCalledOnceWith('success'); - expect(component.lastOutcome()).toBe('success'); + expect(resolveStandAttempt).not.toHaveBeenCalled(); + expect(component.lastOutcome()).toBeNull(); expect(component.rolledResult()).toBe('SUCCESS'); - expect(component.attempts()).toBe(1); - fixture.detectChanges(); - const adjustmentButtons = Array.from(fixture.nativeElement.querySelectorAll('.attempts-stepper button')) as HTMLButtonElement[]; - expect(adjustmentButtons.map(button => button.textContent?.trim())).toEqual(['-', '+']); + component.onRollOverlayClosed(); - adjustmentButtons[0].click(); - fixture.detectChanges(); + expect(resolveStandAttempt).toHaveBeenCalledOnceWith('success', { carefulStand: true }); + expect(component.lastOutcome()).toBe('success'); + expect(component.rolledResult()).toBeNull(); + expect(component.attempts()).toBe(1); + + component.adjustAttempts(-1); expect(adjustStandAttempts).toHaveBeenCalledOnceWith(-1); expect(component.attempts()).toBe(0); expect(component.lastOutcome()).toBe('success'); - expect(adjustmentButtons[0].disabled).toBeTrue(); - adjustmentButtons[1].click(); + component.adjustAttempts(1); expect(adjustStandAttempts).toHaveBeenCalledWith(1); expect(component.attempts()).toBe(1); @@ -90,8 +114,18 @@ describe('PageStandingUpPanelComponent', () => { it('does not apply the Core standing modifier under TW rules', () => { const unit = { id: 'unit-1', - rules: { standingUpPSRModifier: 0 }, - turnState: () => ({ standAttempts: signal(undefined) }), + rules: { + standingUpPSRModifier: 0, + getStandAttemptLimit: () => null, + supportsCarefulStand: true, + canCarefulStand: () => false, + }, + turnState: () => ({ + standAttempts: signal(undefined), + carefulStand: signal(false), + canStandUp: signal(true), + canStandWithoutPSR: signal(false), + }), PSRTargetRoll: () => 8, PSRModifiers: () => ({ modifiers: [] }), }; @@ -108,4 +142,131 @@ describe('PageStandingUpPanelComponent', () => { expect(component.targetRoll()).toBe(8); expect(component.modifiersList()).toEqual([]); }); + + it('keeps a failed dice result visible until the roller closes, then closes before fall resolution', () => { + const resolveStandAttempt = jasmine.createSpy('resolveStandAttempt').and.returnValue(true); + const closeManagedOverlay = jasmine.createSpy('closeManagedOverlay'); + const unit = { + id: 'unit-1', + rules: { + standingUpPSRModifier: 0, + getStandAttemptLimit: () => null, + supportsCarefulStand: false, + canCarefulStand: () => false, + }, + turnState: () => ({ + standAttempts: signal(0), + carefulStand: signal(false), + canStandUp: signal(true), + canStandWithoutPSR: signal(false), + resolveStandAttempt, + adjustStandAttempts: jasmine.createSpy('adjustStandAttempts'), + }), + PSRTargetRoll: () => 8, + PSRModifiers: () => ({ modifiers: [] }), + }; + + TestBed.configureTestingModule({ + imports: [PageStandingUpPanelComponent], + providers: [ + { provide: PageInteractionOverlayComponent, useValue: { unit: signal(unit) } }, + { provide: OverlayManagerService, useValue: { closeManagedOverlay } }, + ], + }); + const component = TestBed.createComponent(PageStandingUpPanelComponent).componentInstance; + + component.onRollFinished({ results: [2, 3], sum: 5 }); + + expect(component.rolledResult()).toBe('FAILED'); + expect(component.rollOverlayCloseHint()).toContain('resolve the fall'); + expect(resolveStandAttempt).not.toHaveBeenCalled(); + expect(closeManagedOverlay).not.toHaveBeenCalled(); + + component.onRollOverlayClosed(); + + expect(resolveStandAttempt).toHaveBeenCalledOnceWith('failed', { carefulStand: false }); + expect(closeManagedOverlay).toHaveBeenCalledOnceWith('standingUp-unit-1'); + }); + + it('does not allow careful stand under Core rules', () => { + const unit = { + id: 'unit-1', + rules: { + standingUpPSRModifier: -1, + getStandAttemptLimit: () => null, + supportsCarefulStand: false, + canCarefulStand: () => false, + }, + turnState: () => ({ + standAttempts: signal(undefined), + carefulStand: signal(false), + canStandUp: signal(true), + canStandWithoutPSR: signal(false), + }), + PSRTargetRoll: () => 8, + PSRModifiers: () => ({ modifiers: [] }), + }; + + TestBed.configureTestingModule({ + imports: [PageStandingUpPanelComponent], + providers: [ + { provide: PageInteractionOverlayComponent, useValue: { unit: signal(unit) } }, + { provide: OverlayManagerService, useValue: { closeManagedOverlay: jasmine.createSpy('closeManagedOverlay') } }, + ], + }); + const component = TestBed.createComponent(PageStandingUpPanelComponent).componentInstance; + + component.setCarefulStand({ target: { checked: true } } as unknown as Event); + + expect(component.supportsCarefulStand()).toBeFalse(); + expect(component.carefulStand()).toBeFalse(); + }); + + it('allows only attempt adjustment while reviewing a completed standing attempt', () => { + const attempts = signal(2); + const carefulStand = signal(false); + const resolveStandAttempt = jasmine.createSpy('resolveStandAttempt'); + const adjustStandAttempts = jasmine.createSpy('adjustStandAttempts'); + const unit = { + id: 'unit-1', + rules: { + standingUpPSRModifier: -1, + getStandAttemptLimit: () => 1, + supportsCarefulStand: true, + canCarefulStand: () => false, + }, + turnState: () => ({ + standAttempts: attempts, + carefulStand, + canStandUp: signal(true), + canStandWithoutPSR: signal(false), + resolveStandAttempt, + adjustStandAttempts, + }), + PSRTargetRoll: () => 8, + PSRModifiers: () => ({ modifiers: [{ pilotCheck: 1, reason: 'Gyro damaged' }] }), + }; + + TestBed.configureTestingModule({ + imports: [PageStandingUpPanelComponent], + providers: [ + { provide: PageInteractionOverlayComponent, useValue: { unit: signal(unit) } }, + { provide: OverlayManagerService, useValue: { closeManagedOverlay: jasmine.createSpy('closeManagedOverlay') } }, + { provide: STANDING_UP_REVIEW_ONLY, useValue: true }, + ], + }); + const fixture = TestBed.createComponent(PageStandingUpPanelComponent); + fixture.detectChanges(); + const component = fixture.componentInstance; + + expect(component.reviewOnly).toBeTrue(); + component.adjustAttempts(1); + component.resolve('success'); + component.onRollFinished({ results: [6, 6], sum: 12 }); + component.setCarefulStand({ target: { checked: true } } as unknown as Event); + + expect(adjustStandAttempts).toHaveBeenCalledOnceWith(1); + expect(resolveStandAttempt).not.toHaveBeenCalled(); + expect(component.carefulStand()).toBeFalse(); + }); }); diff --git a/src/app/components/page-viewer/overlay/page-standing-up-panel.component.ts b/src/app/components/page-viewer/overlay/page-standing-up-panel.component.ts index 8a7199f3c..257b2f6bd 100644 --- a/src/app/components/page-viewer/overlay/page-standing-up-panel.component.ts +++ b/src/app/components/page-viewer/overlay/page-standing-up-panel.component.ts @@ -2,21 +2,28 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { ChangeDetectionStrategy, Component, computed, inject, Injector, signal, viewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, InjectionToken, Injector, signal, viewChild } from '@angular/core'; import { Overlay } from '@angular/cdk/overlay'; import { ComponentPortal } from '@angular/cdk/portal'; import { OverlayManagerService } from '../../../services/overlay-manager.service'; import { DiceRollerComponent } from '../../dice-roller/dice-roller.component'; import { PageInteractionOverlayComponent } from './page-interaction-overlay.component'; -import { displayPsrModifiers } from './page-turn-summary.util'; +import { displayPsrModifiers, openTurnSummaryChildOverlay } from './page-turn-summary.util'; import { psrRollOutcome } from './page-psr-warning-panel.component'; import type { RuleCheckOutcome } from '../../../models/force-serialization'; +export const STANDING_UP_REVIEW_ONLY = new InjectionToken('Standing up review'); + +export interface StandingUpOverlayOptions { + readonly reviewOnly?: boolean; +} + export function toggleStandingUpOverlay( parent: PageInteractionOverlayComponent, overlayManager: OverlayManagerService, injector: Injector, overlay: Overlay, + options: StandingUpOverlayOptions = {}, ): void { const unitId = parent.unit()?.id; if (!unitId) return; @@ -28,18 +35,23 @@ export function toggleStandingUpOverlay( } const customInjector = Injector.create({ - providers: [{ provide: PageInteractionOverlayComponent, useValue: parent }], + providers: [ + { provide: PageInteractionOverlayComponent, useValue: parent }, + { provide: STANDING_UP_REVIEW_ONLY, useValue: options.reviewOnly ?? false }, + ], parent: injector, }); const portal = new ComponentPortal(PageStandingUpPanelComponent, null, customInjector); - overlayManager.createManagedOverlay(overlayKey, null, portal, { - hasBackdrop: true, - backdropClass: 'cdk-overlay-dark-backdrop', - panelClass: 'standing-up-overlay-panel', - closeOnOutsideClick: true, - scrollStrategy: overlay.scrollStrategies.block(), - positions: [], - }); + openTurnSummaryChildOverlay(overlayManager, unitId, () => + overlayManager.createManagedOverlay(overlayKey, null, portal, { + hasBackdrop: true, + backdropClass: 'cdk-overlay-dark-backdrop', + panelClass: 'standing-up-overlay-panel', + closeOnOutsideClick: true, + scrollStrategy: overlay.scrollStrategies.block(), + positions: [], + }) + ); } @Component({ @@ -57,15 +69,23 @@ export class PageStandingUpPanelComponent { private readonly overlayManager = inject(OverlayManagerService); readonly diceRoller = viewChild('roller'); readonly unit = this.parent.unit; - readonly carefulStand = signal(false); + readonly reviewOnly = inject(STANDING_UP_REVIEW_ONLY, { optional: true }) ?? false; + readonly carefulStand = signal( + this.unit()?.rules.supportsCarefulStand === true + && this.unit()?.turnState().carefulStand?.() === true + ); readonly lastOutcome = signal(null); readonly rolledResult = signal(null); + private readonly pendingRolledOutcome = signal(null); readonly rolledResultTone = computed<'default' | 'success' | 'failed'>(() => { if (this.rolledResult() === 'SUCCESS') return 'success'; if (this.rolledResult() === 'FAILED') return 'failed'; return 'default'; }); readonly standingModifier = computed(() => this.unit()?.rules.standingUpPSRModifier ?? 0); + readonly rollOverlayCloseHint = computed(() => this.pendingRolledOutcome() === 'failed' + ? 'Click to apply the failure and resolve the fall' + : 'Click to apply the standing result'); readonly targetRoll = computed(() => { const target = this.unit()?.PSRTargetRoll() ?? 0; @@ -73,6 +93,17 @@ export class PageStandingUpPanelComponent { }); readonly attempts = computed(() => this.unit()?.turnState().standAttempts() ?? 0); + readonly canStandWithoutPSR = computed(() => this.unit()?.turnState().canStandWithoutPSR() ?? false); + readonly attemptLimit = computed(() => { + const unit = this.unit(); + return unit?.rules.getStandAttemptLimit(unit.turnState()) ?? null; + }); + readonly supportsCarefulStand = computed(() => this.unit()?.rules.supportsCarefulStand ?? false); + readonly canCarefulStand = computed(() => { + const unit = this.unit(); + return unit?.rules.canCarefulStand(unit.turnState()) ?? false; + }); + readonly canAttemptStand = computed(() => this.unit()?.turnState().canStandUp() ?? false); readonly modifiersList = computed(() => { const unit = this.unit(); @@ -92,33 +123,55 @@ export class PageStandingUpPanelComponent { } setCarefulStand(event: Event): void { - this.carefulStand.set((event.target as HTMLInputElement).checked); + if (this.reviewOnly) return; + const checked = (event.target as HTMLInputElement).checked; + this.carefulStand.set(checked && this.canCarefulStand()); } roll(): void { + if (this.reviewOnly) return; const roller = this.diceRoller(); if (!roller || roller.isRolling() || this.lastOutcome() === 'success') return; this.lastOutcome.set(null); this.rolledResult.set(null); + this.pendingRolledOutcome.set(null); roller.roll(); } onRollFinished(event: { readonly results: number[]; readonly sum: number }): void { + if (this.reviewOnly) return; const result = psrRollOutcome(event.sum, this.targetRoll()); - this.resolve(result); - if (this.lastOutcome() === result) this.rolledResult.set(result.toUpperCase()); + this.pendingRolledOutcome.set(result); + this.rolledResult.set(result.toUpperCase()); + } + + onRollOverlayClosed(): void { + if (this.reviewOnly) return; + const outcome = this.pendingRolledOutcome(); + if (!outcome) return; + this.pendingRolledOutcome.set(null); + this.resolve(outcome); } resolve(outcome: RuleCheckOutcome): void { + if (this.reviewOnly) return; const unit = this.unit(); if (!unit || this.lastOutcome() === 'success') return; this.rolledResult.set(null); - if (unit.turnState().resolveStandAttempt(outcome)) this.lastOutcome.set(outcome); + if (unit.turnState().resolveStandAttempt(outcome, { carefulStand: this.carefulStand() })) { + this.lastOutcome.set(outcome); + if (outcome === 'failed') this.close(); + } } adjustAttempts(delta: number): void { - this.unit()?.turnState().adjustStandAttempts(delta); + //Note: even in reviewOnly mode we still allow to adjust the attempts. That's the whole point of the review mode... + const turnState = this.unit()?.turnState(); + turnState?.adjustStandAttempts(delta); + const committedCarefulStand = turnState?.carefulStand?.(); + if (committedCarefulStand !== undefined) this.carefulStand.set(committedCarefulStand); if (this.lastOutcome() !== 'success') this.lastOutcome.set(null); this.rolledResult.set(null); + this.pendingRolledOutcome.set(null); } } diff --git a/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.spec.ts b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.spec.ts index 9817039ea..f1440d905 100644 --- a/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.spec.ts +++ b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.spec.ts @@ -21,11 +21,14 @@ describe('PageTurnSummaryPanelComponent', () => { const immobile = signal(false); const rulesId = signal<'core2026' | 'tw'>('core2026'); const moveMode = signal<'stationary' | 'walk' | 'run' | 'jump' | 'UMU' | 'VTOL' | null>(null); + const markPhaseStateChanged = jasmine.createSpy('markPhaseStateChanged'); const turnState = { airborne: signal(false), moveMode, moveDistance: signal(5), carefulStand: signal(false), + applyMovePSR: signal(true), + markPhaseStateChanged, }; const unit = { get gameRules() { @@ -98,6 +101,13 @@ describe('PageTurnSummaryPanelComponent', () => { expect(component.immobile()).toBeFalse(); expect(component.onlyStationaryMoveMode()).toBeTrue(); + + component.selectMove('stationary'); + + expect(moveMode()).toBe('stationary'); + expect(markPhaseStateChanged).toHaveBeenCalledTimes(1); + moveMode.set(null); + fixture.detectChanges(); expect(fixture.nativeElement.querySelectorAll('.move-button').length).toBe(1); diff --git a/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.ts b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.ts index e51d66be8..4f66dce25 100644 --- a/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.ts +++ b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.ts @@ -332,6 +332,7 @@ export class PageTurnSummaryPanelComponent { turnState.moveMode.set(null); turnState.moveDistance.set(null); turnState.applyMovePSR.set(true); + turnState.markPhaseStateChanged(); } readonly moveModes = computed(() => { @@ -381,6 +382,7 @@ export class PageTurnSummaryPanelComponent { turnState.moveDistance.set(mode === 'stationary' ? null : turnState.minDistanceCurrentMoveMode()); } turnState.applyMovePSR.set(true); + turnState.markPhaseStateChanged(); } toggleSpotting(): void { @@ -482,6 +484,7 @@ export class PageTurnSummaryPanelComponent { if (!unit) return; this.setMoveDistance(value, false); unit.turnState().markModified(); + unit.turnState().markPhaseStateChanged(); } private buildModifierTooltip(title: string, entries: UnitModifierBreakdownEntry[]): TooltipLine[] { diff --git a/src/app/components/page-viewer/page-viewer.component.ts b/src/app/components/page-viewer/page-viewer.component.ts index 0aee4d0b6..ad53081a2 100644 --- a/src/app/components/page-viewer/page-viewer.component.ts +++ b/src/app/components/page-viewer/page-viewer.component.ts @@ -36,6 +36,7 @@ import { ForceBuilderService } from '../../services/force-builder.service'; import { OptionsService } from '../../services/options.service'; import { DbService } from '../../services/db.service'; import { KeyboardShortcutService } from '../../services/keyboard-shortcut.service'; +import { CBTAutomationToastService } from '../../services/cbt-automation-toast.service'; import { CBTForceUnit } from '../../models/cbt-force-unit.model'; import { CBTForce } from '../../models/cbt-force.model'; import { SvgInteractionService } from './svg-interaction.service'; @@ -172,8 +173,10 @@ export class PageViewerComponent implements AfterViewInit { private pageViewerSwipeDom = inject(PageViewerSwipeDomService); private pageViewerSwipeRenderer = inject(PageViewerSwipeRendererService); private pageViewerWrapperLayout = inject(PageViewerWrapperLayoutService); - private keyboardShortcutService = inject(KeyboardShortcutService); + private keyboardShortcutService = inject(KeyboardShortcutService); + private automationToasts = inject(CBTAutomationToastService); private destroyRef = inject(DestroyRef); + private readonly automationToastVisibilityOwner = {}; canvasService = inject(PageViewerCanvasService); @@ -344,6 +347,13 @@ export class PageViewerComponent implements AfterViewInit { private fluffImageInjectEffectRef: EffectRef | null = null; constructor() { + effect(() => { + this.automationToasts.setVisibleUnitIds( + this.automationToastVisibilityOwner, + this.displayedUnitIds(), + ); + }); + this.keyboardShortcutService.register({ id: 'page-viewer', active: () => this.viewInitialized() && !!this.unit(), @@ -511,7 +521,10 @@ export class PageViewerComponent implements AfterViewInit { } }); - this.destroyRef.onDestroy(() => this.cleanup()); + this.destroyRef.onDestroy(() => { + this.automationToasts.clearVisibleUnitIds(this.automationToastVisibilityOwner); + this.cleanup(); + }); } ngAfterViewInit(): void { 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 5e9295cda..98cd9a63c 100644 --- a/src/app/components/page-viewer/svg-interaction.service.spec.ts +++ b/src/app/components/page-viewer/svg-interaction.service.spec.ts @@ -81,6 +81,7 @@ function createSvgInteractionUnit(overrides: T): T & { getInve isEquipmentOperational: () => true, canPerformEquipmentAction: () => true, getNotificationDisplayName: () => 'Test Unit', + automationMode: () => 'ask', applyUnderwaterBreachAndFlooding: () => undefined, automationTriggers: new Subject(), rules: NO_CONDITION_RULES, @@ -115,6 +116,7 @@ describe('SvgInteractionService', () => { let openUnitChecks: jasmine.Spy; let openFalling: jasmine.Spy; let phaseIsResolving: jasmine.Spy; + let showToast: jasmine.Spy; beforeEach(() => { zoomPanService = { @@ -171,6 +173,7 @@ describe('SvgInteractionService', () => { openUnitChecks = jasmine.createSpy('open').and.resolveTo(); openFalling = jasmine.createSpy('open').and.resolveTo(); phaseIsResolving = jasmine.createSpy('isResolving').and.returnValue(false); + showToast = jasmine.createSpy('showToast'); options = { pickerStyle: 'default', colorScheme: 'default', @@ -225,7 +228,7 @@ describe('SvgInteractionService', () => { { provide: UnitCheckResolutionService, useValue: { open: openUnitChecks } }, { provide: FallingResolutionService, useValue: { open: openFalling } }, { provide: CBTPhaseResolutionService, useValue: { isResolving: phaseIsResolving } }, - { provide: ToastService, useValue: { showToast: jasmine.createSpy('showToast') } } + { provide: ToastService, useValue: { showToast } } ] }); @@ -582,7 +585,7 @@ describe('SvgInteractionService', () => { await service.automationQueue; expect(automationResolve).toHaveBeenCalledWith( - 'breachAndFlood', + 'breachAndFloodCheck', [ jasmine.objectContaining({ id: 'flood:1:LL', event: 'Breach and flood' }), jasmine.objectContaining({ id: 'flood:1:RL', event: 'Breach and flood' }), @@ -592,6 +595,30 @@ describe('SvgInteractionService', () => { expect(setLocationCondition).toHaveBeenCalledOnceWith('LL', 'flooded', true, true); }); + it('does not discard a breach and flood review during phase resolution', async () => { + const automationTriggers = new Subject(); + const setLocationCondition = jasmine.createSpy('setLocationCondition'); + const unit = createSvgInteractionUnit({ + id: 'unit-a', + automationTriggers, + getNotificationDisplayName: () => 'Archer ARC-2D', + setLocationCondition, + }); + phaseIsResolving.and.returnValue(true); + service.updateUnit(unit); + + automationTriggers.next({ + kind: 'breach-and-flood', + id: 'flood:phase', + locations: ['LL'], + commit: true, + }); + await service.automationQueue; + + expect(automationResolve).toHaveBeenCalled(); + expect(setLocationCondition).toHaveBeenCalledOnceWith('LL', 'flooded', true, true); + }); + it('returns cancelled flood locations to the eligible pool instead of losing them', async () => { const automationTriggers = new Subject(); const deferUnderwaterBreachAndFloodingReview = jasmine.createSpy( @@ -1416,6 +1443,7 @@ describe('SvgInteractionService', () => { internalPoints: 12, internalHits: 0, }); + unit.automationMode = () => 'yes'; service.updateUnit(unit); service.setupInteractions(svg); @@ -1445,6 +1473,8 @@ describe('SvgInteractionService', () => { internalPoints: 3, internalHits: 0, }); + unit.automationMode = () => 'yes'; + unit.applyHeadHitCrewHits.and.returnValue(3); service.updateUnit(unit); service.setupInteractions(svg); @@ -1453,7 +1483,7 @@ describe('SvgInteractionService', () => { await service.automationQueue; expect(automationResolve).toHaveBeenCalledOnceWith( - 'pilotHitsAndConsciousness', + 'pilotHitsAndConsciousnessCheck', [jasmine.objectContaining({ subject: 'Test Unit', event: 'Head hit', @@ -1461,14 +1491,18 @@ describe('SvgInteractionService', () => { })], { title: 'Review Pilot Hit', - message: 'Choose whether to apply the pilot hit caused by this head hit. Cancel leaves the head damage unapplied.', + message: 'Choose whether to apply the pilot hit caused by this head hit.', }, ); - expect(unit.applyHeadHitPilotHits).toHaveBeenCalledTimes(1); + expect(unit.applyHeadHitCrewHits).toHaveBeenCalledTimes(1); expect(unit.addArmorHits).toHaveBeenCalledWith('HD', 2, false, false); expect(unit.addInternalHits).toHaveBeenCalledWith('HD', 2, false, { hardenedArmorApplies: true, }); + expect(showToast).toHaveBeenCalledWith( + 'Test Unit — Pilot hit from head damage in Head: 3 applied', + 'error', + ); }); it('applies rejected head damage without applying its pilot hit', async () => { @@ -1487,7 +1521,7 @@ describe('SvgInteractionService', () => { pickerFactory.createNumericPicker.calls.mostRecent().args[0].onPick({ value: 2 }); await service.automationQueue; - expect(unit.applyHeadHitPilotHits).not.toHaveBeenCalled(); + expect(unit.applyHeadHitCrewHits).not.toHaveBeenCalled(); expect(unit.addArmorHits).toHaveBeenCalledOnceWith('HD', 2, false, false); }); @@ -1507,7 +1541,7 @@ describe('SvgInteractionService', () => { pickerFactory.createNumericPicker.calls.mostRecent().args[0].onPick({ value: 2 }); await service.automationQueue; - expect(unit.applyHeadHitPilotHits).not.toHaveBeenCalled(); + expect(unit.applyHeadHitCrewHits).not.toHaveBeenCalled(); expect(unit.addArmorHits).not.toHaveBeenCalled(); expect(unit.addInternalHits).not.toHaveBeenCalled(); }); @@ -1526,7 +1560,7 @@ describe('SvgInteractionService', () => { tap(location, 712); pickerFactory.createNumericPicker.calls.mostRecent().args[0].onPick({ value: -4 }); - expect(unit.applyHeadHitPilotHits).not.toHaveBeenCalled(); + expect(unit.applyHeadHitCrewHits).not.toHaveBeenCalled(); }); it('does not pass armor repairs backward into structure', () => { @@ -2312,7 +2346,7 @@ function createArmorInteractionUnit(config: { addInternalHits: jasmine.createSpy('addInternalHits').and.callFake((_loc: string, hits: number) => { internalHits += hits; }), - applyHeadHitPilotHits: jasmine.createSpy('applyHeadHitPilotHits'), + applyHeadHitCrewHits: jasmine.createSpy('applyHeadHitCrewHits').and.returnValue(1), getCritSlotsAsMatrix: () => ({}), }); return { svg, location, unit }; diff --git a/src/app/components/page-viewer/svg-interaction.service.ts b/src/app/components/page-viewer/svg-interaction.service.ts index d1572696e..dcb8174bc 100644 --- a/src/app/components/page-viewer/svg-interaction.service.ts +++ b/src/app/components/page-viewer/svg-interaction.service.ts @@ -47,6 +47,7 @@ import { canApplyMekCriticalHitToSlot } from '../../utils/mek-critical-hit.util' import { uidTranslations } from '../../models/common.model'; import type { MekRules } from '../../models/rules/mek-rules'; import { CBTAutomationService } from '../../services/cbt-automation.service'; +import { CBTAutomationToastService } from '../../services/cbt-automation-toast.service'; import { MekCriticalHitAutomationService } from '../../services/mek-critical-hit-automation.service'; import { MekCriticalResolutionService } from '../../services/mek-critical-resolution.service'; import { UnitCheckResolutionService } from '../../services/unit-check-resolution.service'; @@ -120,6 +121,7 @@ export class SvgInteractionService { private pageViewerState = inject(PageViewerStateService); private pickerFactory = inject(PickerFactoryService); private automations = inject(CBTAutomationService); + private automationToasts = inject(CBTAutomationToastService); private criticalHitAutomation = inject(MekCriticalHitAutomationService); private criticalResolution = inject(MekCriticalResolutionService); private unitCheckResolution = inject(UnitCheckResolutionService); @@ -758,7 +760,16 @@ export class SvgInteractionService { const endValue = remainingArmorPoints + remainingInternalPoints; const commitArmorChange = (unit: CBTForceUnit, value: number, applyHeadHit: boolean) => { - if (applyHeadHit) unit.applyHeadHitPilotHits(); + if (applyHeadHit) { + const appliedPilotHits = unit.applyHeadHitCrewHits(); + if (unit.automationMode('pilotHitsAndConsciousnessCheck') === 'yes') { + this.automationToasts.show( + unit, + `Pilot hit from head damage in ${getMekLocationLabel(loc) ?? loc}: ${appliedPilotHits > 0 ? `${appliedPilotHits} applied` : 'none applied'}`, + appliedPilotHits > 0 ? 'error' : 'info', + ); + } + } if (isStructure) { unit.addInternalHits(loc, value, this.consolidateImmediately); } else { @@ -1758,8 +1769,10 @@ export class SvgInteractionService { private scheduleAutomation(unit: CBTForceUnit, trigger: CBTUnitAutomationTrigger): void { // Events emitted while END PHASE is draining are already represented in - // the unit queue and belong to that awaited workflow. - if (this.phaseResolution.isResolving(unit)) return; + // the unit queue and belong to that awaited workflow. Breach reviews are + // transient, so they must still be delivered rather than silently lost. + const phaseOwned = trigger.kind !== 'breach-and-flood'; + if (phaseOwned && this.phaseResolution.isResolving(unit)) return; let task: () => Promise; if (trigger.kind === 'critical-hit-chance') { @@ -1774,7 +1787,7 @@ export class SvgInteractionService { this.queueAutomation(async () => { // END PHASE may have started after the trigger was scheduled. - if (this.phaseResolution.isResolving(unit)) return; + if (phaseOwned && this.phaseResolution.isResolving(unit)) return; await task(); }); } @@ -1793,9 +1806,9 @@ export class SvgInteractionService { description: 'Apply the resulting pilot hit', effects: ['Queue any required Consciousness Roll'], }; - const accepted = await this.automations.resolve('pilotHitsAndConsciousness', [event], { + const accepted = await this.automations.resolve('pilotHitsAndConsciousnessCheck', [event], { title: 'Review Pilot Hit', - message: 'Choose whether to apply the pilot hit caused by this head hit. Cancel leaves the head damage unapplied.', + message: 'Choose whether to apply the pilot hit caused by this head hit.', }); return accepted === null ? null : accepted.has(event.id); } @@ -1812,9 +1825,8 @@ export class SvgInteractionService { })); let accepted: ReadonlySet | null; try { - accepted = await this.automations.resolve('breachAndFlood', events, { + accepted = await this.automations.resolve('breachAndFloodCheck', events, { title: 'Review Breach and Flooding', - message: 'Choose which exposed locations to flood. Cancel defers every location until flooding is evaluated again.', }); } catch (error) { unit.deferUnderwaterBreachAndFloodingReview(trigger.locations); diff --git a/src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.scss b/src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.scss new file mode 100644 index 000000000..961f7e8c8 --- /dev/null +++ b/src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.scss @@ -0,0 +1,276 @@ +:host { + display: contents; +} + +.panel { + width: min(700px, calc(100dvw - 24px)); + max-height: calc(100dvh - 24px); +} + +.unit-check-dialog-body { + overflow-y: auto; +} + +.unit-check-dialog-header { + position: relative; + display: flex; + flex-direction: column; + padding-inline: 60px; +} + +.unit-check-roll-all { + position: absolute; + left: 12px; + width: 40px; + height: 40px; + background-size: 40px; +} + +.unit-check-list { + display: grid; + gap: 6px; + padding: 0 4px; +} + +.unit-check-event { + min-width: 0; + padding: 8px; + border: 1px solid var(--border-color); + border-left: 3px solid var(--bt-yellow); + background: var(--background-input); + transition: opacity 0.2s, border-color 0.2s; +} + +.unit-check-event.resolved { + border-left-color: var(--success); +} + +.unit-check-event.failed { + border-left-color: var(--danger); +} + +.unit-check-layout { + display: grid; + grid-template-columns: minmax(140px, 0.6fr) minmax(280px, 1.4fr); + align-items: start; + gap: 8px; +} + +.unit-check-info, +.unit-check-roll-column { + min-width: 0; +} + +.unit-check-roll-column { + display: grid; + align-content: start; +} + +.unit-check-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.unit-check-heading-copy { + min-width: 0; +} + +.unit-check-heading h3 { + margin: 0; + color: var(--text-color); + font-size: 0.95em; + line-height: 1.25; +} + +.unit-check-name { + font-size: 0.9em; + font-weight: 600; +} + +.unit-check-description { + display: grid; + row-gap: 2px; + margin-top: 3px; + color: var(--text-color-secondary); + font-size: 0.82em; + line-height: 1.3; +} + +.unit-check-failure { + display: flex; + align-items: baseline; + gap: 4px; +} + +@media (min-width: 681px) { + .unit-check-description, + .unit-check-failure { + white-space: nowrap; + } +} + +.unit-check-failure-outcome { + color: var(--danger); + font-weight: 600; +} + +.unit-check-roll-summary { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 4px; +} + +.unit-check-target { + display: flex; + flex-direction: column; + font-size: 0.82em; + align-items: center; +} + +.unit-check-target span { + color: var(--text-color-secondary); +} + +.unit-check-target strong, +.unit-check-roll strong { + display: grid; + place-items: center; + font-variant-numeric: tabular-nums; +} + +.unit-check-target strong { + font-size: 2.15rem; + font-weight: 900; + line-height: 1; +} + +.unit-check-controls { + display: grid; + grid-template-columns: minmax(140px, 0.6fr) minmax(280px, 1.4fr); + gap: 8px; + margin-top: 8px; +} + +.unit-check-control-group { + min-width: 0; +} + +.unit-check-random-row { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + min-height: 40px; + padding: 0 8px; + cursor: pointer; + + &:not(.roll-disabled):hover .random-button, + &:not(.roll-disabled):focus-within .random-button { + opacity: 1; + } + + &.roll-disabled { + cursor: not-allowed; + } + + .random-button { + width: 24px; + min-width: 24px; + height: 32px; + padding: 0; + border: none; + background: transparent url('/images/random.svg') center / 24px 24px no-repeat; + cursor: inherit; + opacity: 0.8; + transition: opacity 0.2s ease-in-out; + + &:disabled { + opacity: 0.35; + } + } +} + +.unit-check-dice-trigger { + cursor: inherit; + + &:focus-visible { + outline: 2px solid var(--bt-yellow); + outline-offset: 3px; + } +} + +.unit-check-manual-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +.unit-check-manual-actions .bt-button { + width: 100%; + min-width: 0; + min-height: 40px; + padding: 4px 8px; + white-space: normal; + line-height: 1.15; +} + +.unit-check-automatic { + grid-column: 1 / -1; + padding: 12px; + text-align: center; + font-weight: 800; + border: 2px solid currentColor; +} + +.unit-check-automatic.success { + color: var(--success); +} + +.unit-check-automatic.danger { + color: var(--danger); +} + +.unit-check-ammo-choices { + display: grid; + gap: 6px; + margin-top: 10px; +} + +.unit-check-ammo-choices .bt-button { + display: flex; + justify-content: space-between; + gap: 12px; + text-align: left; +} + +.unit-check-ammo-choices .selected { + color: var(--bt-yellow); + border-color: var(--bt-yellow); +} + +.unit-check-dialog-actions .bt-button { + min-width: 0; + flex: 1 1 0; +} + +@media (max-width: 680px) { + .unit-check-layout { + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + } + + .unit-check-roll-summary { + justify-content: flex-end; + } + + .unit-check-target { + align-items: end; + } + + .unit-check-controls { + grid-template-columns: 1fr; + } +} diff --git a/src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.spec.ts b/src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.spec.ts new file mode 100644 index 000000000..aaebcafc2 --- /dev/null +++ b/src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.spec.ts @@ -0,0 +1,333 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; +import type { SerializedPendingUnitCheck } from '../../models/force-serialization'; +import type { PSRCheck } from '../../models/rules/unit-type-rules'; +import { + PendingUnitCheckDialogComponent, + type PendingUnitCheckDialogData, +} from './pending-unit-check-dialog.component'; +import { PendingUnitCheckRowComponent } from './pending-unit-check-row.component'; + +function createUnit( + id: string, + name: string, + check: SerializedPendingUnitCheck | readonly SerializedPendingUnitCheck[], + rulesId: 'core2026' | 'tw' = 'core2026', +): { + readonly unit: CBTForceUnit; + readonly checks: ReturnType>; + readonly psrChecks: ReturnType>; +} { + const checks = signal( + Array.isArray(check) ? check : [check as SerializedPendingUnitCheck], + ); + const psrChecks = signal([]); + const psrOutcomes = signal>>({}); + const psrOutcomeSelections = signal>>({}); + const psrDiceSelections = signal>>({}); + const crew = { + getHits: () => 3, + getName: () => '', + getState: () => 'unconscious', + }; + const turnState = { + actionablePendingUnitChecks: () => checks().filter(candidate => + !('readyTurn' in candidate) || candidate.readyTurn <= 0), + pendingCriticalChanceCount: () => 0, + pendingCriticalHitCount: () => 0, + PSRRollsCount: () => psrChecks().filter(check => + !!check.id && psrOutcomes()[check.id] === undefined).length, + actionablePSRRollsCount: () => psrChecks().filter(check => + !!check.id && psrOutcomes()[check.id] === undefined).length, + automaticPSRFailure: () => false, + isPSRCheckAutomaticFailure: () => false, + autoFall: () => false, + getPSRChecks: psrChecks, + getPSROutcome: (id: string) => psrOutcomes()[id], + getPendingUnitCheck: (checkId: string) => checks().find(candidate => candidate.id === checkId), + setPendingUnitCheckOutcome: ( + checkId: string, + outcome: 'success' | 'failed', + roll?: readonly number[], + ) => { + if (!checks().some(candidate => candidate.id === checkId)) return false; + checks.update(current => current.map(candidate => candidate.id === checkId + ? { + ...candidate, + result: roll + ? { kind: 'roll' as const, dice: [roll[0], roll[1]] as const } + : { kind: 'manual' as const, outcome }, + } as SerializedPendingUnitCheck + : candidate)); + return true; + }, + }; + return { + checks, + psrChecks, + unit: { + id, + gameRules: { + id: rulesId, + aggregatedEndPhaseConsciousRolls: rulesId === 'core2026', + }, + automationMode: () => 'ask', + pendingFallCount: () => 0, + turnState: () => turnState, + psrOutcomeSelections, + psrDiceSelections, + PSRTargetRoll: () => 5, + getRuleCheck: () => undefined, + rules: { + getActivePilotCrewId: () => 0, + controlRollFullLabel: 'Piloting Skill Rolls', + }, + getNotificationDisplayName: () => name, + getCrewMember: () => crew, + getCrewMembers: () => [crew], + getHeat: () => ({ current: 0, previous: 0 }), + getUnit: () => ({ type: 'Mek' }), + getCritSlots: () => [], + } as unknown as CBTForceUnit, + }; +} + +describe('PendingUnitCheckDialogComponent', () => { + let fixture: ComponentFixture; + let close: jasmine.Spy; + let applyResolved: jasmine.Spy; + let first: ReturnType; + let second: ReturnType; + + beforeEach(async () => { + first = createUnit('one', 'Atlas', { + type: 'unit-check', + id: 'recovery:one', + kind: 'consciousness-recovery', + crewId: 0, + target: 7, + readyTurn: 0, + }); + second = createUnit('two', 'Marauder', { + type: 'unit-check', + id: 'recovery:two', + kind: 'consciousness-recovery', + crewId: 0, + target: 7, + readyTurn: 0, + }); + close = jasmine.createSpy('close'); + applyResolved = jasmine.createSpy('applyResolved').and.callFake( + (entries: readonly { unit: CBTForceUnit; check: SerializedPendingUnitCheck }[]) => { + for (const entry of entries) { + const harness = entry.unit === first.unit ? first : second; + harness.checks.update(current => current.filter(check => check.id !== entry.check.id)); + } + }, + ); + const data: PendingUnitCheckDialogData = { + units: [first.unit, second.unit], + applyResolved, + }; + + await TestBed.configureTestingModule({ + imports: [PendingUnitCheckDialogComponent], + providers: [ + provideZonelessChangeDetection(), + { provide: DIALOG_DATA, useValue: data }, + { provide: DialogRef, useValue: { close } }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(PendingUnitCheckDialogComponent); + fixture.detectChanges(); + }); + + afterEach(() => TestBed.resetTestingModule()); + + it('groups every eligible recovery and supports physical-dice outcomes', () => { + const rows = fixture.debugElement.queryAll(By.directive(PendingUnitCheckRowComponent)); + + expect(rows.length).toBe(2); + + rows[0].componentInstance.choose('success'); + rows[1].componentInstance.choose('failed'); + + expect(first.checks()[0].result).toEqual({ kind: 'manual', outcome: 'success' }); + expect(second.checks()[0].result).toEqual({ kind: 'manual', outcome: 'failed' }); + expect(fixture.componentInstance.allResolved()).toBeTrue(); + + fixture.componentInstance.apply(); + + expect(applyResolved).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledOnceWith(true); + expect(first.checks()).toEqual([]); + expect(second.checks()).toEqual([]); + }); + + it('shows a header roll button for multiple rollable checks and rolls every row', () => { + const rows = fixture.debugElement.queryAll(By.directive(PendingUnitCheckRowComponent)) + .map(row => row.componentInstance as PendingUnitCheckRowComponent); + const rollSpies = rows.map(row => spyOn(row, 'roll')); + const rollAllButton = fixture.debugElement.query(By.css('.unit-check-roll-all')); + + expect(rollAllButton).not.toBeNull(); + + rollAllButton.triggerEventHandler('click'); + + expect(rollSpies.every(spy => spy.calls.count() === 1)).toBeTrue(); + }); + + it('does not show the header roll button for a single rollable check', () => { + second.checks.set([]); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('.unit-check-roll-all'))).toBeNull(); + }); + + it('orders shutdown, ammo, and consciousness events across every unit', () => { + first.checks.set([ + { type: 'unit-check', id: 'shutdown:one', kind: 'heat-shutdown', target: 6 }, + { type: 'unit-check', id: 'ammo:one', kind: 'heat-ammo-explosion', target: 4 }, + { + type: 'unit-check', id: 'consciousness:one', kind: 'consciousness', crewId: 0, + pilotDamageGroup: 'turn-closed:heat:end-turn:test', target: 5, + }, + ]); + second.checks.set([ + { type: 'unit-check', id: 'shutdown:two', kind: 'heat-shutdown', target: 6 }, + { type: 'unit-check', id: 'ammo:two', kind: 'heat-ammo-explosion', target: 4 }, + { + type: 'unit-check', id: 'consciousness:two', kind: 'consciousness', crewId: 0, + pilotDamageGroup: 'turn-closed:heat:end-turn:test', target: 5, + }, + ]); + fixture.detectChanges(); + + const rows = fixture.debugElement.queryAll(By.directive(PendingUnitCheckRowComponent)); + expect(rows.map(row => [ + row.componentInstance.entry().unit.id, + row.componentInstance.label(), + ])).toEqual([ + ['one', 'Shutdown'], + ['two', 'Shutdown'], + ['one', 'Ammunition explosion'], + ['two', 'Ammunition explosion'], + ['one', 'Consciousness check'], + ['two', 'Consciousness check'], + ]); + }); + + it('shows PSRs in sequence and auto-fails them after the active pilot loses consciousness', () => { + first.checks.set([ + { + type: 'unit-check', id: 'consciousness:one', kind: 'consciousness', crewId: 0, + pilotDamageGroup: 'immediate:test', target: 5, + }, + { type: 'unit-check', id: 'seatbelt:one', kind: 'seatbelt', crewId: 0, target: 5 }, + ]); + second.checks.set([]); + (first.unit.gameRules as { id: 'core2026' | 'tw'; aggregatedEndPhaseConsciousRolls: boolean }).id = 'tw'; + (first.unit.gameRules as { id: 'core2026' | 'tw'; aggregatedEndPhaseConsciousRolls: boolean }) + .aggregatedEndPhaseConsciousRolls = false; + first.psrChecks.set([{ + id: 'psr:one', + fallCheck: 0, + reason: '20 or more damage', + failureOutcome: 'Fall', + }]); + fixture.detectChanges(); + + let rows = fixture.debugElement.queryAll(By.directive(PendingUnitCheckRowComponent)); + expect(rows.map(row => row.componentInstance.label())).toEqual([ + 'Consciousness check', + 'Piloting Skill Check', + 'Seatbelt check · Falling', + ]); + expect(rows.map(row => row.componentInstance.failureOutcome())).toEqual([ + 'unconsciousness', + 'Fall', + 'pilot hit', + ]); + rows[0].componentInstance.choose('failed'); + fixture.detectChanges(); + rows = fixture.debugElement.queryAll(By.directive(PendingUnitCheckRowComponent)); + + expect(rows[1].componentInstance.outcome()).toBe('failed'); + expect(rows[1].componentInstance.isAutomatic()).toBeTrue(); + }); + + it('persists completed choices when the dialog is closed or dismissed', () => { + const row = fixture.debugElement.query(By.directive(PendingUnitCheckRowComponent)) + .componentInstance as PendingUnitCheckRowComponent; + + row.choose('success'); + fixture.componentInstance.close(); + + expect(first.checks()[0].result).toEqual({ kind: 'manual', outcome: 'success' }); + expect(second.checks()[0].result).toBeUndefined(); + expect(applyResolved).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledOnceWith(false); + }); + + it('restores the exact virtual dice after the dialog is closed and reopened', () => { + jasmine.clock().install(); + try { + const row = fixture.debugElement.query(By.directive(PendingUnitCheckRowComponent)) + .componentInstance as PendingUnitCheckRowComponent; + + row.roller()!.roll([4, 4]); + jasmine.clock().tick(500); + fixture.componentInstance.close(); + + expect(first.checks()[0]).toEqual(jasmine.objectContaining({ + result: { kind: 'roll', dice: [4, 4] }, + })); + + fixture.destroy(); + fixture = TestBed.createComponent(PendingUnitCheckDialogComponent); + fixture.detectChanges(); + const reopenedRow = fixture.debugElement.query(By.directive(PendingUnitCheckRowComponent)) + .componentInstance as PendingUnitCheckRowComponent; + + expect(reopenedRow.roller()!.diceResults()).toEqual([4, 4]); + expect(reopenedRow.roller()!.rollFinished()).toBeTrue(); + } finally { + jasmine.clock().uninstall(); + } + }); + + it('identifies seatbelt checks as fall consequences even when unit-local IDs match', () => { + first.checks.set([{ + type: 'unit-check', + id: 'seatbelt', + kind: 'seatbelt', + crewId: 0, + target: 5, + }]); + second.checks.set([{ + type: 'unit-check', + id: 'seatbelt', + kind: 'seatbelt', + crewId: 0, + target: 5, + }]); + fixture.detectChanges(); + + const rows = fixture.debugElement.queryAll(By.directive(PendingUnitCheckRowComponent)) + .map(row => row.componentInstance as PendingUnitCheckRowComponent); + + expect(rows.map(row => row.label())).toEqual([ + 'Seatbelt check · Falling', + 'Seatbelt check · Falling', + ]); + expect(rows.every(row => row.description().includes('Reason: Falling.'))).toBeTrue(); + expect(rows.every(row => row.failureOutcome() === 'pilot hit')).toBeTrue(); + }); +}); diff --git a/src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.ts b/src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.ts new file mode 100644 index 000000000..d6b73f250 --- /dev/null +++ b/src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.ts @@ -0,0 +1,165 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { ChangeDetectionStrategy, Component, computed, inject, viewChildren } from '@angular/core'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; +import { + isConsciousnessCheck, + isPendingUnitCheckEntry, + pendingCheckReviewEntryKey, + pendingCheckReviewGroupList, + pendingPsrCommittedOutcome, + pendingUnitCheckIsResolved, + pendingUnitCheckDialogTitle, + pendingUnitCheckOutcome, + type PendingCheckReviewEntry, +} from '../../utils/unit-check.util'; +import { PendingUnitCheckRowComponent } from './pending-unit-check-row.component'; + +export interface PendingUnitCheckDialogData { + readonly units: readonly CBTForceUnit[]; + readonly atPhaseEnd?: boolean; + readonly applyResolved: ( + entries: readonly PendingCheckReviewEntry[], + forcedPsrFailures: ReadonlySet, + ) => void; +} + +@Component({ + selector: 'pending-unit-check-dialog', + standalone: true, + imports: [PendingUnitCheckRowComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+ @if (showRollAll()) { + + } + {{ checkTitle() }} + @if (commonUnitName(); as unitName) { + {{ unitName }} + } +
+
+
+ @for (entry of entries(); track entryKey(entry)) { + + } +
+
+
+ + +
+
+ `, + styleUrls: [ + '../page-viewer/overlay/page-psr-warning-panel.component.scss', + './pending-unit-check-dialog.component.scss', + ], +}) +export class PendingUnitCheckDialogComponent { + readonly data = inject(DIALOG_DATA); + private readonly dialogRef = inject>(DialogRef); + readonly rows = viewChildren(PendingUnitCheckRowComponent); + readonly entries = computed(() => + pendingCheckReviewGroupList(this.data.units, this.data.atPhaseEnd)); + readonly commonUnitName = computed(() => { + const entries = this.entries(); + const firstUnit = entries[0]?.unit; + return firstUnit && entries.every(entry => entry.unit.id === firstUnit.id) + ? firstUnit.getNotificationDisplayName() + : null; + }); + readonly forcedPsrFailures = computed>(() => { + const failedControllers = new Set(); + const failedFallChecks = new Set(); + const forced = new Set(); + + for (const entry of this.entries()) { + if (isPendingUnitCheckEntry(entry)) { + if (isConsciousnessCheck(entry.check) + && pendingUnitCheckOutcome( + entry.unit.turnState().getPendingUnitCheck(entry.check.id) ?? entry.check, + ) === 'failed' + && entry.unit.rules.getActivePilotCrewId() === entry.check.crewId) { + failedControllers.add(entry.unit.id); + } + continue; + } + + const entryKey = pendingCheckReviewEntryKey(entry); + const checkId = entry.check.id; + const isForced = failedControllers.has(entry.unit.id) + || entry.unit.turnState().isPSRCheckAutomaticFailure(entry.check) + || (entry.unit.turnState().autoFall() && entry.check.failureOutcome === 'Fall') + || (failedFallChecks.has(entry.unit.id) && entry.check.failureOutcome === 'Fall'); + if (isForced) forced.add(entryKey); + const outcome = isForced + ? 'failed' + : pendingPsrCommittedOutcome(entry.unit, entry.check) + ?? (checkId ? entry.unit.psrOutcomeSelections()[checkId] : undefined); + if (outcome === 'failed' && entry.check.failureOutcome === 'Fall') { + failedFallChecks.add(entry.unit.id); + } + } + return forced; + }); + readonly checkTitle = computed(() => { + const entries = this.entries(); + const unitChecks = entries.flatMap(entry => isPendingUnitCheckEntry(entry) ? [entry.check] : []); + if (unitChecks.length === entries.length && unitChecks.length > 0) { + const title = pendingUnitCheckDialogTitle(unitChecks[0]); + if (title && unitChecks.every(check => pendingUnitCheckDialogTitle(check) === title)) return title; + } + if (entries.length > 0 && entries.every(entry => !isPendingUnitCheckEntry(entry))) { + return 'Piloting Skill Rolls'; + } + return 'Resolve Pending Checks'; + }); + readonly allResolved = computed(() => this.entries().length > 0 + && this.entries().every(entry => { + if (isPendingUnitCheckEntry(entry)) { + return pendingUnitCheckIsResolved(entry.unit, entry.check); + } + return this.forcedPsrFailures().has(pendingCheckReviewEntryKey(entry)) + || pendingPsrCommittedOutcome(entry.unit, entry.check) !== undefined + || (!!entry.check.id && entry.unit.psrOutcomeSelections()[entry.check.id] !== undefined); + })); + readonly rollableRows = computed(() => this.rows().filter(row => row.isPresent() && !row.isAutomatic())); + readonly showRollAll = computed(() => this.rollableRows().length > 1); + readonly isAnyRolling = computed(() => this.rollableRows().some(row => row.isRolling())); + + rollAll(): void { + if (this.isAnyRolling()) return; + this.rollableRows().forEach(row => row.roll()); + } + + apply(): void { + const entries = this.entries(); + if (!entries.length || !this.allResolved()) return; + this.data.applyResolved(entries, this.forcedPsrFailures()); + if (this.entries().length === 0) this.dialogRef.close(true); + } + + close(): void { + this.dialogRef.close(false); + } + + entryKey(entry: PendingCheckReviewEntry): string { + return pendingCheckReviewEntryKey(entry); + } +} diff --git a/src/app/components/pending-unit-check-dialog/pending-unit-check-row.component.ts b/src/app/components/pending-unit-check-dialog/pending-unit-check-row.component.ts new file mode 100644 index 000000000..261f61b88 --- /dev/null +++ b/src/app/components/pending-unit-check-dialog/pending-unit-check-row.component.ts @@ -0,0 +1,287 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { ChangeDetectionStrategy, Component, computed, input, viewChild } from '@angular/core'; +import type { SerializedPendingUnitCheck } from '../../models/force-serialization'; +import { getMekLocationLabel } from '../../models/entity/types'; +import { getPreferredHeatAmmoExplosionCandidates } from '../../utils/heat-effects.util'; +import { + isAmmoExplosionCheck, + isPendingUnitCheckEntry, + type PendingCheckReviewEntry, + pendingUnitCheckActionLabel, + pendingUnitCheckAutomaticLabel, + pendingUnitCheckDescription, + pendingUnitCheckFailureOutcome, + pendingUnitCheckIsAutomatic, + pendingUnitCheckLabel, + pendingUnitCheckOutcome, +} from '../../utils/unit-check.util'; +import { DiceRollerComponent } from '../dice-roller/dice-roller.component'; + +@Component({ + selector: 'pending-unit-check-row', + standalone: true, + imports: [DiceRollerComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (isPresent()) { +
+
+
+
+
+ @if (showUnitName()) { +

{{ entry().unit.getNotificationDisplayName() }}

+ } +
{{ label() }}
+
+
+
+ @if (description(); as description) { + {{ description }} + } + @if (failureOutcome(); as failure) { + + Failure: + {{ failure }} + + } +
+
+ + @if (target() !== undefined) { +
+
+
Target{{ target() }}+
+
+
+ } +
+ +
+ @if (isAutomatic()) { +
+ {{ automaticLabel() }} +
+ } @else { +
+
+ +
+ +
+
+
+
+
+ + +
+
+ } +
+ + @if (ammoChoices().length > 1 && outcome() === 'failed') { +
+ @for (choice of ammoChoices(); track choice.id) { + + } +
+ } +
+ } + `, + styleUrl: './pending-unit-check-dialog.component.scss', +}) +export class PendingUnitCheckRowComponent { + readonly entry = input.required(); + readonly forcedPsrFailure = input(false); + readonly showUnitName = input(true); + readonly roller = viewChild('roller'); + readonly currentUnitCheck = computed(() => { + const entry = this.entry(); + return isPendingUnitCheckEntry(entry) + ? entry.unit.turnState().getPendingUnitCheck(entry.check.id) + : undefined; + }); + readonly currentPsrCheck = computed(() => { + const entry = this.entry(); + if (isPendingUnitCheckEntry(entry) || !entry.check.id) return undefined; + return entry.unit.turnState().getPSRChecks().find(check => check.id === entry.check.id); + }); + readonly isPresent = computed(() => this.currentUnitCheck() !== undefined + || this.currentPsrCheck() !== undefined); + readonly label = computed(() => { + const entry = this.entry(); + if (!isPendingUnitCheckEntry(entry)) return 'Piloting Skill Check'; + const check = this.currentUnitCheck() ?? entry.check; + return pendingUnitCheckLabel(check, true); + }); + readonly description = computed(() => { + const entry = this.entry(); + if (isPendingUnitCheckEntry(entry)) { + const check = this.currentUnitCheck(); + return check ? pendingUnitCheckDescription(entry.unit, check) : ''; + } + const check = this.currentPsrCheck(); + if (!check) return ''; + const location = check.loc ? getMekLocationLabel(check.loc) ?? check.loc : undefined; + return `${check.reason}${location ? ` · ${location}` : ''}.`; + }); + readonly failureOutcome = computed(() => { + const entry = this.entry(); + if (isPendingUnitCheckEntry(entry)) { + const check = this.currentUnitCheck(); + return check ? pendingUnitCheckFailureOutcome(check) : ''; + } + return this.currentPsrCheck()?.failureOutcome ?? 'Fall'; + }); + readonly target = computed(() => { + const entry = this.entry(); + return isPendingUnitCheckEntry(entry) + ? this.currentUnitCheck()?.target + : entry.unit.PSRTargetRoll(); + }); + readonly outcome = computed(() => { + const entry = this.entry(); + if (isPendingUnitCheckEntry(entry)) { + const check = this.currentUnitCheck(); + return check ? pendingUnitCheckOutcome(check) : undefined; + } + if (this.forcedPsrFailure()) return 'failed'; + return entry.check.id + ? entry.unit.psrOutcomeSelections()[entry.check.id] + ?? entry.unit.turnState().getPSROutcome(entry.check.id) + : undefined; + }); + readonly restoredDice = computed(() => { + const entry = this.entry(); + if (isPendingUnitCheckEntry(entry)) { + const result = this.currentUnitCheck()?.result; + return result?.kind === 'roll' ? result.dice : null; + } + return !this.forcedPsrFailure() && entry.check.id + ? entry.unit.psrDiceSelections()[entry.check.id] ?? null + : null; + }); + readonly ammoChoices = computed(() => { + const entry = this.entry(); + return isPendingUnitCheckEntry(entry) && isAmmoExplosionCheck(entry.check) + ? getPreferredHeatAmmoExplosionCandidates(entry.unit) + : []; + }); + readonly selectedAmmoId = computed(() => { + const check = this.currentUnitCheck(); + return check && isAmmoExplosionCheck(check) ? check.selectionId : undefined; + }); + readonly isRolling = computed(() => this.roller()?.isRolling() ?? false); + readonly isAutomatic = computed(() => { + const entry = this.entry(); + if (!isPendingUnitCheckEntry(entry)) return this.forcedPsrFailure(); + const check = this.currentUnitCheck(); + return check ? pendingUnitCheckIsAutomatic(check) : false; + }); + readonly successLabel = computed(() => { + const entry = this.entry(); + if (!isPendingUnitCheckEntry(entry)) return 'PASSED'; + const check = this.currentUnitCheck(); + return check ? pendingUnitCheckActionLabel(check, 'success') : 'SUCCESS'; + }); + readonly failedLabel = computed(() => { + const entry = this.entry(); + if (!isPendingUnitCheckEntry(entry)) return 'FAILED'; + const check = this.currentUnitCheck(); + return check ? pendingUnitCheckActionLabel(check, 'failed') : 'FAILED'; + }); + + roll(): void { + if (!this.isRolling()) this.roller()?.roll(); + } + + onFinished(event: { readonly results: readonly number[]; readonly sum: number }): void { + const entry = this.entry(); + const target = this.target(); + if (target === undefined) return; + const outcome = event.sum >= target ? 'success' : 'failed'; + if (isPendingUnitCheckEntry(entry)) { + const check = this.currentUnitCheck(); + if (check) entry.unit.turnState().setPendingUnitCheckOutcome(check.id, outcome, event.results); + return; + } + this.selectPsrOutcome(outcome, event.results); + } + + choose(outcome: 'success' | 'failed'): void { + if (this.isRolling() || this.isAutomatic()) return; + const entry = this.entry(); + if (!isPendingUnitCheckEntry(entry)) { + this.selectPsrOutcome(outcome); + return; + } + const check = this.currentUnitCheck(); + if (check) entry.unit.turnState().setPendingUnitCheckOutcome(check.id, outcome); + } + + selectAmmo(id: string): void { + const check = this.currentUnitCheck(); + if (check && this.ammoChoices().some(choice => choice.id === id)) { + this.entry().unit.turnState().setPendingUnitCheckSelection(check.id, id); + } + } + + rollTotal(check: SerializedPendingUnitCheck): number { + return check.result?.kind === 'roll' + ? check.result.dice[0] + check.result.dice[1] + : 0; + } + + automaticLabel(): string { + const check = this.currentUnitCheck(); + return check + ? pendingUnitCheckAutomaticLabel(check, this.outcome() ?? 'failed') + : this.outcome() === 'success' ? 'AUTOMATIC SUCCESS' : 'AUTOMATIC FAILURE'; + } + + private selectPsrOutcome( + outcome: 'success' | 'failed', + dice?: readonly number[], + ): void { + const entry = this.entry(); + if (isPendingUnitCheckEntry(entry) || !entry.check.id || this.forcedPsrFailure()) return; + const checkId = entry.check.id; + entry.unit.psrOutcomeSelections.update(current => ({ ...current, [checkId]: outcome })); + entry.unit.psrDiceSelections.update(current => { + if (dice?.length === 2) { + return { ...current, [checkId]: [dice[0], dice[1]] as readonly [number, number] }; + } + const { [checkId]: _removed, ...remaining } = current; + return remaining; + }); + } +} diff --git a/src/app/components/unit-block/unit-block.component.spec.ts b/src/app/components/unit-block/unit-block.component.spec.ts index 538a16a1b..048cfc5b0 100644 --- a/src/app/components/unit-block/unit-block.component.spec.ts +++ b/src/app/components/unit-block/unit-block.component.spec.ts @@ -62,82 +62,4 @@ describe('UnitBlockComponent', () => { ]); }); - it('renders compact notifications as a normal-flow row inside the unit content', () => { - const forceUnit = Object.create(CBTForceUnit.prototype) as CBTForceUnit; - const turnState = { - dirty: () => false, - autoFall: () => false, - actionablePSRRollsCount: () => 0, - pendingCriticalChanceCount: () => 3, - pendingCriticalHitCount: () => 1, - getPendingCriticalChances: () => [{ - type: 'mek-critical-chance' as const, - id: 'chance:1', - location: 'CT', - }], - getPendingCriticalHits: () => [{ - type: 'mek-critical-hit' as const, - id: 'critical:1', - location: 'LT', - targetLocation: 'LT', - remainingHits: 1, - }], - getPendingEvents: () => [{ - type: 'mek-critical-hit' as const, - id: 'critical:1', - location: 'LT', - targetLocation: 'LT', - remainingHits: 1, - }, { - type: 'mek-critical-chance' as const, - id: 'chance:1', - location: 'CT', - }], - pendingUnitCheckCount: () => 0, - }; - Object.defineProperty(forceUnit, 'force', { - value: { gameSystem: 'cbt' }, - configurable: true, - }); - Object.defineProperty(forceUnit, 'rules', { - value: { controlRollFullLabel: 'Piloting Skill Rolls' }, - configurable: true, - }); - Object.defineProperty(forceUnit, 'destroyed', { - value: false, - configurable: true, - }); - Object.assign(forceUnit, { - gameRules: { aggregatedEndPhaseConsciousRolls: true }, - getUnit: () => ({ chassis: 'Atlas', model: 'AS7-D' }), - commander: () => false, - alias: () => '', - getPilotStats: () => '4/5', - pendingFallCount: () => 0, - turnState: () => turnState, - }); - - const fixture = TestBed.createComponent(UnitBlockComponent); - fixture.componentRef.setInput('forceUnit', forceUnit); - fixture.componentRef.setInput('compactMode', true); - fixture.detectChanges(); - - const square = fixture.nativeElement.querySelector('.unit-square') as HTMLElement; - const content = square.querySelector('.unit-content') as HTMLElement; - const badges = content.querySelector('unit-notification-badges') as HTMLElement; - expect(fixture.componentInstance.compactMode()).toBeTrue(); - expect(content.contains(badges)).toBeTrue(); - expect(badges.classList).toContain('compact-notification-row'); - expect(badges.classList).not.toContain('compact'); - expect(badges.querySelector('.critical-chance-warning')?.textContent).toContain('3'); - expect(badges.querySelector('.critical-hit-warning')?.textContent).toContain('1'); - expect(Array.from(badges.querySelectorAll( - '.critical-chance-warning, .critical-hit-warning', - )).map(badge => badge.classList[1])).toEqual([ - 'critical-hit-warning', - 'critical-chance-warning', - ]); - expect(getComputedStyle(content).flexDirection).toBe('column'); - expect(getComputedStyle(badges).position).toBe('static'); - }); }); diff --git a/src/app/components/unit-notification-badges/unit-notification-badges.component.html b/src/app/components/unit-notification-badges/unit-notification-badges.component.html new file mode 100644 index 000000000..627fd56df --- /dev/null +++ b/src/app/components/unit-notification-badges/unit-notification-badges.component.html @@ -0,0 +1,66 @@ +@if (hasAutoFall()) { + + + +} + +@let notification = pendingNotification(); +@if (notification) { + + @switch (notification.kind) { + @case ('fall') { + + + } + @case ('psr') { + + } + @case ('critical-chance') { + + } + @case ('critical-hit') { + + } + @case ('unit-check') { + + } + } + +} diff --git a/src/app/components/unit-notification-badges/unit-notification-badges.component.scss b/src/app/components/unit-notification-badges/unit-notification-badges.component.scss new file mode 100644 index 000000000..e42bd6646 --- /dev/null +++ b/src/app/components/unit-notification-badges/unit-notification-badges.component.scss @@ -0,0 +1,168 @@ +:host { + display: inline-flex; + min-width: 0; + flex: 0 0 auto; + align-items: center; + gap: 3px; + vertical-align: middle; +} + +:host(.empty) { + display: none; +} + +.unit-notification-badge { + display: inline-flex; + width: 20px; + height: 20px; + flex: 0 0 20px; + align-items: center; + justify-content: center; + outline: none; + pointer-events: auto; + cursor: help; + + &:focus-visible { + box-shadow: 0 0 0 2px var(--bt-yellow); + } + + svg { + display: block; + width: 100%; + height: 100%; + overflow: visible; + } + + text { + pointer-events: none; + font-size: 8px; + font-weight: bold; + } +} + +.fall-warning { + .fall-background { + fill: #f00; + } + + .fall-symbol { + fill: #fff; + } +} + +.pending-events-warning.fall-warning { + position: relative; +} + +.fall-event-count { + position: absolute; + right: -2px; + bottom: -2px; + display: flex; + min-width: 10px; + height: 10px; + box-sizing: border-box; + align-items: center; + justify-content: center; + padding-inline: 2px; + border: 0.5px solid #fff; + border-radius: 999px; + background: #000; + color: #fff; + font-size: 7px; + font-weight: bold; + line-height: 1; +} + +.psr-warning { + path { + fill: var(--damage-color); + } + + text { + fill: #000; + } +} + +.critical-chance-warning, +.critical-hit-warning, +.unit-check-warning { + polygon, + rect { + stroke: #fff; + stroke-width: 0.5; + } + + text { + fill: #fff; + } +} + +.critical-chance-warning rect { + fill: #e88720; +} + +.critical-hit-warning polygon { + fill: var(--danger); +} + +.unit-check-warning { + polygon { + fill: var(--bt-yellow); + } + + text { + fill: #000; + } +} + +:host(.overlay) { + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + pointer-events: auto; +} + +:host(.overlay) .unit-notification-badge { + width: 40px; + height: 40px; + flex-basis: 40px; +} + +:host(.overlay) .fall-event-count { + right: -4px; + bottom: -4px; + min-width: 18px; + height: 18px; + padding-inline: 3px; + border-width: 1px; + font-size: 11px; +} + +:host(.overlay.interactive) .unit-notification-badge { + opacity: 0.8; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + opacity: 1; + } +} + +@media (min-width: 480px) and (min-height: 480px) { + :host(.overlay) { + gap: 10px; + } + + :host(.overlay) .unit-notification-badge { + width: 46px; + height: 46px; + flex-basis: 46px; + } + + :host(.overlay) .fall-event-count { + min-width: 20px; + height: 20px; + font-size: 12px; + } +} diff --git a/src/app/components/unit-notification-badges/unit-notification-badges.component.spec.ts b/src/app/components/unit-notification-badges/unit-notification-badges.component.spec.ts new file mode 100644 index 000000000..4ab8ebb21 --- /dev/null +++ b/src/app/components/unit-notification-badges/unit-notification-badges.component.spec.ts @@ -0,0 +1,227 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { Overlay } from '@angular/cdk/overlay'; +import { provideZonelessChangeDetection, signal, type WritableSignal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; +import type { + SerializedPendingMekCritical, + SerializedPendingMekCriticalChance, + SerializedPendingUnitCheck, +} from '../../models/force-serialization'; +import { UnitNotificationBadgesComponent } from './unit-notification-badges.component'; + +describe('UnitNotificationBadgesComponent', () => { + let fixture: ComponentFixture; + let autoFall: WritableSignal; + let automaticPsrFailure: WritableSignal; + let psrOutcome: WritableSignal<'success' | 'failed' | undefined>; + let prone: WritableSignal; + let pendingFallCount: WritableSignal; + let psrCount: WritableSignal; + let chanceCount: WritableSignal; + let criticalHitCount: WritableSignal; + let criticalOrder: WritableSignal; + let unitCheckCount: WritableSignal; + + beforeEach(async () => { + autoFall = signal(false); + automaticPsrFailure = signal(false); + psrOutcome = signal(undefined); + prone = signal(false); + pendingFallCount = signal(0); + psrCount = signal(0); + chanceCount = signal(3); + criticalHitCount = signal(1); + criticalOrder = signal(['mek-critical-hit', 'mek-critical-chance']); + unitCheckCount = signal(2); + + await TestBed.configureTestingModule({ + imports: [UnitNotificationBadgesComponent], + providers: [ + provideZonelessChangeDetection(), + { provide: Overlay, useValue: {} }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(UnitNotificationBadgesComponent); + fixture.componentRef.setInput('unit', createUnit()); + fixture.detectChanges(); + }); + + it('summarizes the complete mixed queue in event order', () => { + expect(fixture.componentInstance.pendingNotification()).toEqual({ + kind: 'unit-check', + count: 6, + tooltip: [ + { label: 'Consciousness check', value: 'Target 6+' }, + { label: 'Consciousness check', value: 'Target 6+' }, + { label: 'Critical Hit: Left Torso', value: '1 hit' }, + { label: 'Critical Chance: Center Torso', value: 'Pending' }, + { label: 'Critical Chance: Center Torso', value: 'Pending' }, + { label: 'Critical Chance: Center Torso', value: 'Pending' }, + ], + }); + }); + + it('uses the first queued critical type after higher-priority work is gone', () => { + unitCheckCount.set(0); + + expect(fixture.componentInstance.pendingNotification()).toEqual(jasmine.objectContaining({ + kind: 'critical-hit', + count: 4, + })); + + criticalOrder.set(['mek-critical-chance', 'mek-critical-hit']); + + expect(fixture.componentInstance.pendingNotification()).toEqual(jasmine.objectContaining({ + kind: 'critical-chance', + count: 4, + })); + }); + + it('groups pending fall damage but keeps automatic fall outside the numbered queue', () => { + autoFall.set(true); + pendingFallCount.set(1); + + expect(fixture.componentInstance.hasPendingFalls()).toBeTrue(); + expect(fixture.componentInstance.hasAutoFall()).toBeFalse(); + expect(fixture.componentInstance.pendingNotification()).toEqual(jasmine.objectContaining({ + kind: 'fall', + count: 7, + })); + + pendingFallCount.set(0); + unitCheckCount.set(0); + chanceCount.set(1); + criticalHitCount.set(0); + psrCount.set(1); + + expect(fixture.componentInstance.hasAutoFall()).toBeTrue(); + expect(fixture.componentInstance.pendingNotification()).toEqual(jasmine.objectContaining({ + kind: 'critical-chance', + count: 1, + })); + }); + + it('shows an automatic-fall badge when an unconscious pilot will fail every pending PSR', () => { + unitCheckCount.set(0); + chanceCount.set(0); + criticalHitCount.set(0); + psrCount.set(2); + automaticPsrFailure.set(true); + fixture.detectChanges(); + + expect(fixture.componentInstance.pendingNotification()).toBeNull(); + expect(fixture.componentInstance.hasAutoFall()).toBeTrue(); + expect(fixture.componentInstance.fallTooltip()).toEqual([ + { label: 'PSR 1', value: 'Fall' }, + { label: 'PSR 2', value: 'Fall' }, + ]); + expect(fixture.nativeElement.querySelector('.automatic-fall-warning')).not.toBeNull(); + }); + + it('keeps the automatic-fall badge visible for a stored failed PSR until its fall is applied', () => { + unitCheckCount.set(0); + chanceCount.set(0); + criticalHitCount.set(0); + psrCount.set(1); + automaticPsrFailure.set(true); + psrOutcome.set('failed'); + fixture.detectChanges(); + + expect(fixture.componentInstance.pendingNotification()).toBeNull(); + expect(fixture.componentInstance.hasAutoFall()).toBeTrue(); + expect(fixture.componentInstance.fallTooltip()).toEqual([ + { label: 'PSR 1', value: 'Fall' }, + ]); + + prone.set(true); + fixture.detectChanges(); + expect(fixture.componentInstance.hasAutoFall()).toBeFalse(); + }); + + it('emits activation only when interaction is enabled', () => { + const activated = jasmine.createSpy('activated'); + const event = jasmine.createSpyObj('event', ['preventDefault', 'stopPropagation']); + fixture.componentInstance.activated.subscribe(activated); + + fixture.componentInstance.activate(event, 'unit-check'); + expect(activated).not.toHaveBeenCalled(); + + fixture.componentRef.setInput('interactive', true); + fixture.detectChanges(); + fixture.componentInstance.activate(event, 'unit-check'); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(event.stopPropagation).toHaveBeenCalledTimes(1); + expect(activated).toHaveBeenCalledOnceWith({ kind: 'unit-check', event }); + }); + + function createUnit(): CBTForceUnit { + const checks = (): SerializedPendingUnitCheck[] => Array.from( + { length: unitCheckCount() }, + (_, index) => ({ + type: 'unit-check', + id: `consciousness:${index}`, + kind: 'consciousness', + target: 6, + crewId: 0, + pilotDamageGroup: 'combat:closed', + }), + ); + const chances = (): SerializedPendingMekCriticalChance[] => Array.from( + { length: chanceCount() }, + (_, index) => ({ + type: 'mek-critical-chance', + id: `chance:${index}`, + location: 'CT', + }), + ); + const hits = (): SerializedPendingMekCritical[] => criticalHitCount() > 0 ? [{ + type: 'mek-critical-hit', + id: 'critical:0', + location: 'LT', + targetLocation: 'LT', + remainingHits: criticalHitCount(), + }] : []; + const turnState = { + autoFall: () => autoFall(), + automaticPSRFailure: () => automaticPsrFailure(), + isPSRCheckAutomaticFailure: () => automaticPsrFailure(), + actionablePSRRollsCount: () => autoFall() || automaticPsrFailure() || psrOutcome() !== undefined + ? 0 + : psrCount(), + PSRRollsCount: () => psrOutcome() === undefined ? psrCount() : 0, + getPSRChecks: () => Array.from({ length: psrCount() }, (_, index) => ({ + id: `psr:${index}`, + fallCheck: 1, + failureOutcome: 'Fall', + reason: `PSR ${index + 1}`, + })), + getPSROutcome: () => psrOutcome(), + pendingCriticalChanceCount: () => chanceCount(), + pendingCriticalHitCount: () => criticalHitCount(), + getPendingCriticalChances: chances, + getPendingCriticalHits: hits, + getPendingEvents: () => criticalOrder().flatMap< + SerializedPendingMekCriticalChance | SerializedPendingMekCritical + >(type => type === 'mek-critical-chance' ? chances() : hits()), + pendingUnitCheckCount: () => unitCheckCount(), + actionablePendingUnitChecks: checks, + }; + return { + gameRules: { aggregatedEndPhaseConsciousRolls: false }, + rules: { controlRollFullLabel: 'Piloting Skill Rolls' }, + pendingFallCount: () => pendingFallCount(), + turnState: () => turnState, + PSRTargetRoll: () => 7, + getHeat: () => ({ current: 19 }), + getCrewMember: () => undefined, + getCrewMembers: () => [], + getCondition: (condition: string) => condition === 'prone' && prone(), + } as unknown as CBTForceUnit; + } +}); diff --git a/src/app/components/unit-notification-badges/unit-notification-badges.component.ts b/src/app/components/unit-notification-badges/unit-notification-badges.component.ts new file mode 100644 index 000000000..3234111e7 --- /dev/null +++ b/src/app/components/unit-notification-badges/unit-notification-badges.component.ts @@ -0,0 +1,75 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; +import { TooltipDirective } from '../../directives/tooltip.directive'; +import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; +import { + buildFallTooltip, + buildPendingNotificationSummary, + type PendingNotificationSummary, + type UnitNotificationKind, +} from './unit-notification-tooltip.util'; + +export type { UnitNotificationKind } from './unit-notification-tooltip.util'; + +export interface UnitNotificationActivation { + kind: UnitNotificationKind; + event: Event; +} + +@Component({ + selector: 'unit-notification-badges', + standalone: true, + imports: [TooltipDirective], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './unit-notification-badges.component.html', + styleUrl: './unit-notification-badges.component.scss', + host: { + '[class.overlay]': 'display() === "overlay"', + '[class.interactive]': 'interactive()', + '[class.preventZoomReset]': 'interactive()', + '[class.empty]': '!hasNotifications()', + }, +}) +export class UnitNotificationBadgesComponent { + unit = input(null); + display = input<'inline' | 'overlay'>('inline'); + interactive = input(false); + activated = output(); + + pendingFallCount = computed(() => this.unit()?.pendingFallCount?.() ?? 0); + hasPendingFalls = computed(() => this.pendingFallCount() > 0); + fallTooltip = computed(() => buildFallTooltip(this.unit())); + hasAutoFall = computed(() => !this.hasPendingFalls() && this.fallTooltip() !== null); + + pendingNotification = computed(() => buildPendingNotificationSummary(this.unit())); + hasNotifications = computed(() => this.hasAutoFall() || this.pendingNotification() !== null); + + pendingNotificationAriaLabel(notification: PendingNotificationSummary): string { + const eventLabel = notification.count === 1 ? 'event' : 'events'; + const next = NOTIFICATION_KIND_LABELS[notification.kind]; + return `${this.interactive() ? 'Resume' : ''} ${notification.count} pending ${eventLabel}; next: ${next}`.trim(); + } + + activate(event: Event, kind: UnitNotificationKind): void { + if (!this.interactive()) return; + event.preventDefault(); + event.stopPropagation(); + this.activated.emit({ kind, event }); + } + + activateFromKeyboard(event: KeyboardEvent, kind: UnitNotificationKind): void { + if (event.key !== 'Enter' && event.key !== ' ') return; + this.activate(event, kind); + } +} + +const NOTIFICATION_KIND_LABELS: Readonly> = { + fall: 'fall damage', + psr: 'PSR checks', + 'critical-chance': 'critical chance', + 'critical-hit': 'critical hit', + 'unit-check': 'unit checks', +}; diff --git a/src/app/components/unit-notification-badges/unit-notification-tooltip.util.ts b/src/app/components/unit-notification-badges/unit-notification-tooltip.util.ts new file mode 100644 index 000000000..f2305903f --- /dev/null +++ b/src/app/components/unit-notification-badges/unit-notification-tooltip.util.ts @@ -0,0 +1,279 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; +import type { + SerializedPendingMekCritical, + SerializedPendingMekCriticalChance, + SerializedPendingUnitCheck, +} from '../../models/force-serialization'; +import { getMekLocationLabel } from '../../models/entity/types'; +import type { PSRCheck } from '../../models/rules/unit-type-rules'; +import { + pendingUnitCheckOutcome, + pendingUnitCheckPriority, + pendingUnitCheckLabel, + pendingUnitCheckStage, +} from '../../utils/unit-check.util'; +import type { TooltipLine } from '../tooltip/tooltip.component'; + +export type UnitNotificationKind = 'fall' | 'psr' | 'critical-chance' | 'critical-hit' | 'unit-check'; + +export interface PendingNotificationSummary { + readonly kind: UnitNotificationKind; + readonly count: number; + readonly tooltip: TooltipLine[]; +} + +type PendingCriticalEvent = SerializedPendingMekCriticalChance | SerializedPendingMekCritical; + +/** + * Builds the one numbered badge used for all actionable work. The ordering + * mirrors CBTPhaseResolutionService so the badge shape describes the dialog + * that clicking it will open first. + */ +export function buildPendingNotificationSummary( + unit: CBTForceUnit | null | undefined, +): PendingNotificationSummary | null { + if (!unit) return null; + + const turnState = unit.turnState(); + const fallCount = unit.pendingFallCount?.() ?? 0; + const unitCheckCount = turnState.pendingUnitCheckCount(); + const criticalChanceCount = turnState.pendingCriticalChanceCount(); + const criticalHitCount = turnState.pendingCriticalHitCount(); + const psrCount = turnState.actionablePSRRollsCount(); + const count = fallCount + unitCheckCount + criticalChanceCount + criticalHitCount + psrCount; + if (count === 0) return null; + + const criticalEvents = orderedPendingCriticalEvents( + unit, + criticalChanceCount > 0, + criticalHitCount > 0, + ); + const firstCriticalKind = criticalEvents[0]?.type === 'mek-critical-hit' + ? 'critical-hit' + : criticalEvents[0]?.type === 'mek-critical-chance' + ? 'critical-chance' + : criticalChanceCount > 0 + ? 'critical-chance' + : 'critical-hit'; + const kind: UnitNotificationKind = fallCount > 0 + ? 'fall' + : unitCheckCount > 0 + ? 'unit-check' + : criticalChanceCount + criticalHitCount > 0 + ? firstCriticalKind + : 'psr'; + + const tooltip: TooltipLine[] = []; + if (fallCount > 0) { + tooltip.push(...(buildFallTooltip(unit) ?? [{ + label: 'Fall damage', + value: fallCount === 1 ? 'Pending' : `${fallCount} pending`, + }])); + } + if (unitCheckCount > 0) { + tooltip.push(...(buildPendingUnitCheckTooltip(unit) ?? [{ + label: 'Unit checks', + value: `${unitCheckCount} pending`, + }])); + } + + let listedCriticalChances = 0; + let listedCriticalHits = 0; + for (const event of criticalEvents) { + if (event.type === 'mek-critical-chance') { + listedCriticalChances++; + tooltip.push(prefixTooltipLabel(criticalChanceLine(event), 'Critical Chance')); + } else { + listedCriticalHits += event.remainingHits; + tooltip.push(prefixTooltipLabel(criticalHitLine(event), 'Critical Hit')); + } + } + if (listedCriticalChances < criticalChanceCount) { + tooltip.push({ + label: 'Critical chances', + value: `${criticalChanceCount - listedCriticalChances} pending`, + }); + } + if (listedCriticalHits < criticalHitCount) { + const remaining = criticalHitCount - listedCriticalHits; + tooltip.push({ + label: 'Critical hits', + value: `${remaining} hit${remaining === 1 ? '' : 's'} pending`, + }); + } + if (psrCount > 0) { + const psrLines = buildPsrTooltip(unit) ?? [{ + label: 'Piloting Skill Rolls', + value: `${psrCount} pending`, + }]; + tooltip.push(...psrLines.map(line => prefixTooltipLabel(line, 'PSR', ' · '))); + } + + return { kind, count, tooltip }; +} + +export function buildFallTooltip(unit: CBTForceUnit | null | undefined): TooltipLine[] | null { + if (!unit) return null; + const pendingFallCount = unit.pendingFallCount?.() ?? 0; + if (pendingFallCount > 0) { + return [{ + label: 'Fall damage', + value: pendingFallCount === 1 ? 'Pending' : `${pendingFallCount} pending`, + }]; + } + const automaticFallLines = buildPsrEventTooltip(unit, 'automatic-fall'); + if (!unit.turnState().autoFall() && automaticFallLines === null) return null; + return automaticFallLines ?? [{ + label: 'Automatic fall', + value: 'Fall', + }]; +} + +export function buildPsrTooltip(unit: CBTForceUnit | null | undefined): TooltipLine[] | null { + return unit ? buildPsrEventTooltip(unit, 'roll') : null; +} + +function buildPsrEventTooltip( + unit: CBTForceUnit, + mode: 'roll' | 'automatic-fall', +): TooltipLine[] | null { + const turnState = unit.turnState(); + const pending = turnState.getPSRChecks().filter(check => { + if (check.fallCheck === undefined || check.id === undefined) return false; + const outcome = turnState.getPSROutcome(check.id); + if (mode === 'automatic-fall') { + return check.failureOutcome === 'Fall' + && (turnState.autoFall() || turnState.isPSRCheckAutomaticFailure(check)) + && (outcome === undefined || (outcome === 'failed' && !unit.getCondition('prone'))); + } + return outcome === undefined + && !turnState.isPSRCheckAutomaticFailure(check) + && (!turnState.autoFall() || check.failureOutcome !== 'Fall'); + }); + if (pending.length === 0) return null; + + return pending.map(check => ({ + label: psrCheckLabel(check), + value: mode === 'automatic-fall' + ? check.failureOutcome ?? 'Fall' + : `Target ${unit.PSRTargetRoll()}+ · ${check.failureOutcome ?? 'Fall'}`, + })); +} + +export function buildPendingCriticalChanceTooltip( + unit: CBTForceUnit | null | undefined, +): TooltipLine[] | null { + const pending = unit?.turnState().getPendingCriticalChances() ?? []; + if (pending.length === 0) return null; + return pending.map(criticalChanceLine); +} + +export function buildPendingCriticalHitTooltip( + unit: CBTForceUnit | null | undefined, +): TooltipLine[] | null { + const pending = unit?.turnState().getPendingCriticalHits() ?? []; + const count = pending.reduce((total, event) => total + event.remainingHits, 0); + if (pending.length === 0 || count === 0) return null; + return pending.map(criticalHitLine); +} + +export function buildPendingUnitCheckTooltip( + unit: CBTForceUnit | null | undefined, +): TooltipLine[] | null { + if (!unit) return null; + const total = unit.turnState().pendingUnitCheckCount(); + if (total === 0) return null; + + const currentStage = pendingUnitCheckStage(unit); + const currentIds = new Set(currentStage.map(check => check.id)); + const checks = [ + ...currentStage, + ...unit.turnState().actionablePendingUnitChecks() + .filter(check => !currentIds.has(check.id)) + .sort((left, right) => pendingUnitCheckPriority(unit, left) - pendingUnitCheckPriority(unit, right)), + ].slice(0, total); + return checks.flatMap(check => unitCheckLines(unit, check)); +} + +function unitCheckLines(unit: CBTForceUnit, check: SerializedPendingUnitCheck): TooltipLine[] { + const outcome = pendingUnitCheckOutcome(check); + const resolution = check.target !== undefined + ? `Target ${check.target}+${outcome ? ` · ${capitalize(outcome)}` : ''}` + : outcome + ? `${check.result?.kind === 'automatic' ? 'Automatic · ' : ''}${capitalize(outcome)}` + : 'Pending'; + return [ + { label: pendingUnitCheckLabel(check), value: resolution } + ]; +} + +function criticalChanceStatus(result: 'none' | 'blown-off' | 1 | 2 | 3 | 4 | undefined): string { + if (result === undefined) return 'Pending'; + if (result === 'none') return 'No criticals'; + if (result === 'blown-off') return 'Blown off'; + return `${result} critical hit${result === 1 ? '' : 's'}`; +} + +function orderedPendingCriticalEvents( + unit: CBTForceUnit, + includeChances: boolean, + includeHits: boolean, +): PendingCriticalEvent[] { + const turnState = unit.turnState(); + const ordered: PendingCriticalEvent[] = []; + const seen = new Set(); + const append = (event: PendingCriticalEvent): void => { + if ((event.type === 'mek-critical-chance' && !includeChances) + || (event.type === 'mek-critical-hit' && !includeHits) + || seen.has(event.id)) return; + seen.add(event.id); + ordered.push(event); + }; + + for (const event of turnState.getPendingEvents?.() ?? []) { + if (event.type === 'mek-critical-chance' || event.type === 'mek-critical-hit') append(event); + } + for (const event of turnState.getPendingCriticalChances()) append(event); + for (const event of turnState.getPendingCriticalHits()) append(event); + return ordered; +} + +function criticalChanceLine(event: SerializedPendingMekCriticalChance): TooltipLine { + return { + label: getMekLocationLabel(event.location) ?? event.location, + value: criticalChanceStatus(event.result), + }; +} + +function criticalHitLine(event: SerializedPendingMekCritical): TooltipLine { + const source = getMekLocationLabel(event.location) ?? event.location; + const target = getMekLocationLabel(event.targetLocation) ?? event.targetLocation; + const location = source === target ? target : `${source} → ${target}`; + const caseII = event.caseII?.status === 'pending' ? ' · CASE II pending' : ''; + return { + label: location, + value: `${event.remainingHits} hit${event.remainingHits === 1 ? '' : 's'}${caseII}`, + }; +} + +function prefixTooltipLabel(line: TooltipLine, prefix: string, separator = ': '): TooltipLine { + return { + ...line, + label: line.label ? `${prefix}${separator}${line.label}` : prefix, + }; +} + +function psrCheckLabel(check: PSRCheck): string { + const location = check.loc + ? getMekLocationLabel(check.loc) ?? check.loc + : undefined; + return location ? `${check.reason} (${location})` : check.reason; +} + +function capitalize(value: string): string { + return `${value.charAt(0).toUpperCase()}${value.slice(1)}`; +} diff --git a/src/app/models/automation-review.model.ts b/src/app/models/automation-review.model.ts new file mode 100644 index 000000000..62819c91d --- /dev/null +++ b/src/app/models/automation-review.model.ts @@ -0,0 +1,30 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +export interface AutomationReviewEvent { + id: string; + subject: string; + event: string; + description: string; + delta?: number; + breakdown?: readonly AutomationReviewBreakdownItem[]; + effects?: readonly string[]; +} + +export interface AutomationReviewBreakdownItem { + readonly id: string; + readonly label: string; + readonly value: number; +} + +export interface AutomationReviewDialogData { + title: string; + message: string; + events: readonly AutomationReviewEvent[]; + allowCancel: boolean; +} + +export interface AutomationReviewResult { + acceptedEventIds: string[]; +} diff --git a/src/app/models/cbt-force-unit-state.model.ts b/src/app/models/cbt-force-unit-state.model.ts index 3fae0cca8..7599229ed 100644 --- a/src/app/models/cbt-force-unit-state.model.ts +++ b/src/app/models/cbt-force-unit-state.model.ts @@ -10,6 +10,7 @@ import { ForceUnitState } from './force-unit-state.model'; import { TurnState } from './turn-state.model'; import type { CBTForceUnit } from './cbt-force-unit.model'; import { Sanitizer } from '../utils/sanitizer.util'; +import { closePilotDamageTurn, isPilotDamageGroup } from '../utils/pilot-damage-group.util'; export class CBTForceUnitState extends ForceUnitState { @@ -33,9 +34,20 @@ export class CBTForceUnitState extends ForceUnitState { this.turnState.set(new TurnState(this)); } - resetTurnState() { - const turnState = new TurnState(this); + resetTurnState(turnCounter = 0, preservePendingWork = false) { + const pendingEvents = preservePendingWork + ? this.turnState().getPendingEvents().map(event => { + const pilotDamageGroup = 'pilotDamageGroup' in event + ? event.pilotDamageGroup + : undefined; + return structuredClone(isPilotDamageGroup(pilotDamageGroup) + ? { ...event, pilotDamageGroup: closePilotDamageTurn(pilotDamageGroup!) } + : event); + }) + : []; + const turnState = new TurnState(this, turnCounter); this.turnState.set(turnState); + if (pendingEvents.length > 0) turnState.update({ pendingEvents }); turnState.capturePassiveHeatSourceBaseline(); } @@ -93,6 +105,11 @@ export class CBTForceUnitState extends ForceUnitState { consolidateCrits() { if (!this.hasUnconsolidatedCrits()) return; const crits = this.crits(); + const commitsDestruction = crits.some(crit => + crit.destroying !== undefined && crit.destroyed === undefined); + const destructionTurn = commitsDestruction + ? this.turnState().getTurnCounter() + : undefined; let updated = false; crits.forEach(crit => { if ((crit.pendingHits ?? 0) !== 0) { @@ -102,8 +119,9 @@ export class CBTForceUnitState extends ForceUnitState { crit.pendingHitTimestamps = undefined; updated = true; } - if (!!crit.destroying !== !!crit.destroyed) { + if ((crit.destroying !== undefined) !== (crit.destroyed !== undefined)) { crit.destroyed = crit.destroying; + crit.destroyedTurn = crit.destroying !== undefined ? destructionTurn : undefined; updated = true; } }); @@ -146,14 +164,20 @@ export class CBTForceUnitState extends ForceUnitState { endPhase() { const turnState = this.turnState(); - if (turnState.autoFall() && !this.hasCondition('prone')) { - this.unit.setCondition('prone', true); + if (this.unit.automationMode('pilotSkillCheck') !== 'no') { + if (turnState.PSRRollsCount() > 0 && turnState.automaticPSRFailure()) { + turnState.failPendingPSRChecks(); + } else { + turnState.resolveAutomaticFall(); + } } this.consolidateLocations(); this.consolidateCrits(); this.consolidateInventory(); + turnState.preparePendingCriticalWorkAfterPhaseCommit(); turnState.resetPSRChecks(); turnState.commitEquipmentStateChanges(); + turnState.completePilotDamagePhase(); } private cleanupEndTurnConditions() { @@ -171,10 +195,10 @@ export class CBTForceUnitState extends ForceUnitState { } } - endTurn() { + endTurn(phaseAlreadyEnded = false) { this.consolidateHeat(); this.cleanupEndTurnConditions(); - this.endPhase(); + if (!phaseAlreadyEnded) this.endPhase(); } override update(data: CBTSerializedState) { @@ -250,6 +274,7 @@ export class CBTForceUnitState extends ForceUnitState { !this.numberArraysEqual(existingCrit.pendingHitTimestamps, incomingCrit.pendingHitTimestamps) || existingCrit.destroying !== incomingCrit.destroying || existingCrit.destroyed !== incomingCrit.destroyed || + existingCrit.destroyedTurn !== incomingCrit.destroyedTurn || existingCrit.consumed !== incomingCrit.consumed) { existingCrit.hits = incomingCrit.hits; existingCrit.pendingHits = incomingCrit.pendingHits; @@ -259,6 +284,7 @@ export class CBTForceUnitState extends ForceUnitState { existingCrit.name = incomingCrit.name; existingCrit.originalName = incomingCrit.originalName; existingCrit.destroyed = incomingCrit.destroyed; + existingCrit.destroyedTurn = incomingCrit.destroyedTurn; existingCrit.consumed = incomingCrit.consumed; critsChanged = true; } @@ -267,7 +293,9 @@ export class CBTForceUnitState extends ForceUnitState { if ((existingCrit.hits ?? 0) > 0 || (existingCrit.pendingHits ?? 0) !== 0 || (existingCrit.hitTimestamps?.length ?? 0) > 0 || (existingCrit.pendingHitTimestamps?.length ?? 0) > 0 || existingCrit.destroying !== undefined || - existingCrit.destroyed !== undefined || (existingCrit.consumed ?? 0) > 0 || + existingCrit.destroyed !== undefined || + existingCrit.destroyedTurn !== undefined || + (existingCrit.consumed ?? 0) > 0 || existingCrit.originalName !== undefined) { existingCrit.hits = 0; existingCrit.pendingHits = undefined; @@ -275,6 +303,7 @@ export class CBTForceUnitState extends ForceUnitState { existingCrit.pendingHitTimestamps = undefined; existingCrit.destroying = undefined; existingCrit.destroyed = undefined; + existingCrit.destroyedTurn = undefined; existingCrit.consumed = undefined; if (existingCrit.originalName) { existingCrit.name = existingCrit.originalName; diff --git a/src/app/models/cbt-force-unit.model.spec.ts b/src/app/models/cbt-force-unit.model.spec.ts index 7c52535f1..0789e9f39 100644 --- a/src/app/models/cbt-force-unit.model.spec.ts +++ b/src/app/models/cbt-force-unit.model.spec.ts @@ -7,7 +7,7 @@ import { computed, Injector, provideZonelessChangeDetection, signal } from '@ang import { TestBed } from '@angular/core/testing'; import { AmmoEquipment, Equipment, MiscEquipment, resolveWeaponDamage, WeaponEquipment, type EquipmentMap } from './equipment.model'; import { CBTForce } from './cbt-force.model'; -import { CBTForceUnit } from './cbt-force-unit.model'; +import { CBTForceUnit, type CBTUnitAutomationTrigger } from './cbt-force-unit.model'; import { DEAD_CREW_HIT_THRESHOLD } from './crew-member.model'; import { INVENTORY_CONTROL_TARGET_MAX_COUNT } from './inventory-control-runtime-state.model'; import { MountedAmmo, MountedEquipment, MountedMisc, MountedWeapon } from './mounted-equipment.model'; @@ -48,6 +48,7 @@ import { BombastLaserHandler, } from '../equipment-handlers/bombast-laser.handler'; import { applyMekCriticalRoll } from '../utils/mek-critical-hit.util'; +import type { AutomationMode, CBTAutomationKey } from './options.model'; function createEquipment(): EquipmentMap { const ultraAc20 = new WeaponEquipment({ @@ -729,6 +730,10 @@ class ExposedUnitSvgVehicleService extends UnitSvgVehicleService { } class ExposedUnitSvgMekService extends UnitSvgMekService { + refreshHeat(): void { + this.updateHeatDisplay(this.unit.getHeat()); + } + refreshInventory(): void { this.updateInventory(); } @@ -736,6 +741,10 @@ class ExposedUnitSvgMekService extends UnitSvgMekService { refreshHeatSinks(): void { this.updateHeatSinkPips(); } + + refreshCriticalSlots(criticalSlots = this.unit.getCritSlots()): void { + this.updateCritSlotDisplay(criticalSlots); + } } class ExposedUnitSvgAeroService extends UnitSvgAeroService { @@ -868,30 +877,41 @@ describe('CBTForceUnit direct inventory ammo bins', () => { let dataService: jasmine.SpyObj; let unitInitializer: UnitInitializerService; let injector: Injector; - let cbtAutomations: ReturnType>; + let toastService: jasmine.SpyObj; + let heatAutomationMode: ReturnType>; + let automationModes: Partial>; let extremeRange: ReturnType>; + let cbtRules: 'core2026' | 'tw'; beforeEach(() => { equipment = createEquipment(); dataService = jasmine.createSpyObj('DataService', ['getEquipmentRegistry', 'findEquipment', 'getUnitByName']); dataService.getEquipmentRegistry.and.callFake(() => new EquipmentRegistry(equipment)); dataService.findEquipment.and.callFake((name: string) => dataService.getEquipmentRegistry().findEquipment(name) ?? undefined); - cbtAutomations = signal(true); + heatAutomationMode = signal('yes'); + automationModes = {}; extremeRange = signal(false); + cbtRules = 'core2026'; + toastService = jasmine.createSpyObj('ToastService', ['showToast']); TestBed.configureTestingModule({ providers: [ UnitInitializerService, { provide: DataService, useValue: dataService }, { provide: DialogsService, useValue: jasmine.createSpyObj('DialogsService', ['createDialog', 'showError']) }, - { provide: ToastService, useValue: jasmine.createSpyObj('ToastService', ['showToast']) }, - { provide: OptionsService, useValue: { options: () => ({ - cbtAutomations: cbtAutomations(), - CBTOptionalRules: { - forcedWithdrawal: true, - extremeRange: extremeRange(), - }, - }) } }, + { provide: ToastService, useValue: toastService }, + { provide: OptionsService, useValue: { + cbtAutomationMode: (key: CBTAutomationKey) => key === 'heatAndDissipationResolution' + ? heatAutomationMode() + : automationModes[key] ?? 'yes', + options: () => ({ + CBTRules: cbtRules, + CBTOptionalRules: { + forcedWithdrawal: true, + extremeRange: extremeRange(), + }, + }), + } }, ], }); @@ -1140,6 +1160,66 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(forceUnit.turnState().cover()).toBeUndefined(); }); + it('carries unresolved criticals across turn reset with committed resolution context', () => { + const forceUnit = createForceUnit(); + const pilotDamageGroup = forceUnit.turnState().currentPilotDamageGroup(); + forceUnit.turnState().queuePendingCriticalChance({ + id: 'chance:1', + location: 'CT', + result: 2, + pilotDamageGroup, + }); + forceUnit.turnState().queuePendingCriticalHits({ + id: 'critical:1', + location: 'LT', + targetLocation: 'LT', + remainingHits: 2, + roll: [3, 4], + pilotDamageGroup, + }); + + forceUnit.endTurn(); + + expect(forceUnit.turnState().getPendingCriticalChances()).toEqual([{ + type: 'mek-critical-chance', + id: 'chance:1', + location: 'CT', + result: 2, + consolidateImmediately: true, + pilotDamageGroup: `turn-closed:${pilotDamageGroup}`, + }]); + expect(forceUnit.turnState().getPendingCriticalHits()).toEqual([{ + type: 'mek-critical-hit', + id: 'critical:1', + location: 'LT', + targetLocation: 'LT', + remainingHits: 2, + consolidateImmediately: true, + roll: [3, 4], + pilotDamageGroup: `turn-closed:${pilotDamageGroup}`, + }]); + expect(forceUnit.serialize().state.turnState?.pendingEvents).toEqual([ + { + type: 'mek-critical-chance', + id: 'chance:1', + location: 'CT', + result: 2, + consolidateImmediately: true, + pilotDamageGroup: `turn-closed:${pilotDamageGroup}`, + }, + { + type: 'mek-critical-hit', + id: 'critical:1', + location: 'LT', + targetLocation: 'LT', + remainingHits: 2, + consolidateImmediately: true, + roll: [3, 4], + pilotDamageGroup: `turn-closed:${pilotDamageGroup}`, + }, + ]); + }); + it('reacquires each current mount when an end-turn hook rebuilds inventory', () => { const handler = new EndTurnTestHandler(true); TestBed.inject(EquipmentInteractionRegistryService).getRegistry().register(handler); @@ -1183,7 +1263,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { const projectedHeat = forceUnit.turnState().heatProjection().projected; - forceUnit.endTurn(); + forceUnit.endTurn({ heatAndDissipationResolution: true }); expect(forceUnit.getHeat().current).toBe(projectedHeat); expect(forceUnit.turnState().heatSources()).toContain(jasmine.objectContaining({ @@ -1214,7 +1294,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(forceUnit.getHeat().current).not.toBe(99); }); - it('applies calculated heat automatically when ending the turn', () => { + it('applies calculated heat when approved at end turn', () => { const forceUnit = createForceUnit(createEmptyUnit({ ...createMekUnit(), heat: 20, @@ -1224,7 +1304,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { forceUnit.turnState().addFiredHeat(8); const projectedHeat = forceUnit.turnState().heatProjection().projected; - forceUnit.endTurn(); + forceUnit.endTurn({ heatAndDissipationResolution: true }); expect(forceUnit.getHeat().current).toBe(projectedHeat); expect(forceUnit.getHeat().next).toBeUndefined(); @@ -1241,7 +1321,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { const projectedHeat = forceUnit.turnState().heatProjection().projected; forceUnit.setHeat(25); - forceUnit.endTurn(); + forceUnit.endTurn({ heatAndDissipationResolution: true }); expect(forceUnit.getHeat().current).toBe(projectedHeat); expect(forceUnit.getHeat().current).not.toBe(25); @@ -1280,14 +1360,14 @@ describe('CBTForceUnit direct inventory ammo bins', () => { })); expect(forceUnit.turnState().dirty()).toBeTrue(); - forceUnit.endTurn(); + forceUnit.endTurn({ heatAndDissipationResolution: true }); expect(forceUnit.getHeat().current).toBe(10); expect(forceUnit.turnState().hasPendingHeatResolution()).toBeTrue(); expect(forceUnit.turnState().dirty()).toBeFalse(); }); - it('applies Aero cooling automatically without requiring a heat source', () => { + it('applies approved Aero cooling without requiring a heat source', () => { const forceUnit = createForceUnit(createEmptyUnit({ name: 'Cooling Test Aero', type: 'Aero', @@ -1301,14 +1381,14 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(forceUnit.turnState().heatSources()).toEqual([]); expect(forceUnit.turnState().hasPendingHeatResolution()).toBeTrue(); - forceUnit.endTurn(); + forceUnit.endTurn({ heatAndDissipationResolution: true }); expect(forceUnit.getHeat().current).toBe(5); expect(forceUnit.getHeat().next).toBeUndefined(); }); - it('does not calculate or apply heat automatically when CBT automations are disabled', () => { - cbtAutomations.set(false); + it('does not calculate or apply heat automatically when heat automation is no', () => { + heatAutomationMode.set('no'); const forceUnit = createForceUnit(createEmptyUnit({ ...createMekUnit(), heat: 20, @@ -1317,6 +1397,8 @@ describe('CBTForceUnit direct inventory ammo bins', () => { forceUnit.setHeatData({ current: 10, previous: 10 }); forceUnit.turnState().addFiredHeat(8); + expect(forceUnit.automationMode('heatAndDissipationResolution')).toBe('no'); + forceUnit.applyHeat(); expect(forceUnit.getHeat().current).toBe(10); @@ -1324,7 +1406,84 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(forceUnit.getHeat().current).toBe(10); }); - it('keeps sources unresolved when automations are toggled after a manual heat correction', () => { + it('resolves yes-mode heat at end turn without an approval decision', () => { + const forceUnit = createForceUnit(createEmptyUnit({ + ...createMekUnit(), + heat: 20, + dissipation: 5, + })); + forceUnit.setHeatData({ current: 10, previous: 10 }); + forceUnit.turnState().addFiredHeat(8); + + expect(forceUnit.automationMode('heatAndDissipationResolution')).toBe('yes'); + expect(forceUnit.hasPendingEndTurnHeat()).toBeTrue(); + const projectedHeat = forceUnit.turnState().heatProjection().projected; + + forceUnit.endTurn(); + + expect(forceUnit.getHeat().current).toBe(projectedHeat); + expect(toastService.showToast).toHaveBeenCalledWith( + jasmine.stringContaining(`Heat and dissipation: Heat 10 → ${projectedHeat}`), + 'info', + ); + }); + + it('does not turn an unapplied manual heat arrow into an end-turn automation event', () => { + const forceUnit = createForceUnit(createEmptyUnit({ + ...createMekUnit(), + heat: 20, + dissipation: 5, + })); + forceUnit.setHeatData({ current: 0, previous: 0 }); + forceUnit.setHeat(12); + + expect(forceUnit.getHeat().next).toBe(12); + expect(forceUnit.turnState().hasPendingHeatResolution()).toBeFalse(); + expect(forceUnit.hasPendingEndTurnHeat()).toBeFalse(); + + forceUnit.endTurn(); + + expect(forceUnit.getHeat().current).toBe(0); + expect(forceUnit.getHeat().next).toBeUndefined(); + }); + + it('requires an explicit approval to resolve ask-mode heat at end turn', () => { + heatAutomationMode.set('ask'); + const forceUnit = createForceUnit(createEmptyUnit({ + ...createMekUnit(), + heat: 20, + dissipation: 5, + })); + forceUnit.setHeatData({ current: 10, previous: 10 }); + forceUnit.turnState().addFiredHeat(8); + forceUnit.setHeat(27); + + expect(forceUnit.automationMode('heatAndDissipationResolution')).toBe('ask'); + expect(forceUnit.hasPendingEndTurnHeat()).toBeTrue(); + + forceUnit.endTurn({ heatAndDissipationResolution: false }); + + expect(forceUnit.getHeat().current).toBe(10); + expect(forceUnit.getHeat().next).toBeUndefined(); + }); + + it('resolves an approved ask-mode heat projection at end turn', () => { + heatAutomationMode.set('ask'); + const forceUnit = createForceUnit(createEmptyUnit({ + ...createMekUnit(), + heat: 20, + dissipation: 5, + })); + forceUnit.setHeatData({ current: 10, previous: 10 }); + forceUnit.turnState().addFiredHeat(8); + const projectedHeat = forceUnit.turnState().heatProjection().projected; + + forceUnit.endTurn({ heatAndDissipationResolution: true }); + + expect(forceUnit.getHeat().current).toBe(projectedHeat); + }); + + it('keeps sources unresolved when the heat automation mode changes after a manual heat correction', () => { const forceUnit = createForceUnit(); forceUnit.turnState().moveMode.set('run'); forceUnit.turnState().addFiredHeat(8); @@ -1332,19 +1491,19 @@ describe('CBTForceUnit direct inventory ammo bins', () => { forceUnit.applyHeat(); expect(forceUnit.turnState().heatSources().map(source => source.id)).toEqual(['movement', 'weapons']); - cbtAutomations.set(false); + heatAutomationMode.set('no'); expect(forceUnit.turnState().heatSources().map(source => source.id)).toEqual(['movement', 'weapons']); expect(forceUnit.turnState().heatProjectionVisible()).toBeTrue(); - cbtAutomations.set(true); + heatAutomationMode.set('yes'); expect(forceUnit.turnState().heatSources().map(source => source.id)).toEqual(['movement', 'weapons']); expect(forceUnit.turnState().heatProjectionVisible()).toBeTrue(); }); - it('applies an explicit user heat target without acknowledging sources when CBT automations are disabled', () => { - cbtAutomations.set(false); + it('applies an explicit user heat target without acknowledging sources when heat automation is no', () => { + heatAutomationMode.set('no'); const forceUnit = createForceUnit(); forceUnit.setHeatData({ current: 10, previous: 10 }); forceUnit.turnState().addFiredHeat(8); @@ -1419,7 +1578,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(forceUnit.turnState().heatProjection().projected).toBe(firstProjection + 3); const finalProjection = forceUnit.turnState().heatProjection().projected; - forceUnit.endTurn(); + forceUnit.endTurn({ heatAndDissipationResolution: true }); expect(forceUnit.getHeat().current).toBe(finalProjection); }); @@ -1436,7 +1595,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(forceUnit.turnState().effectiveHeatDissipation()).toBe(20); expect(forceUnit.turnState().serialize()?.heatDissipationConsumed).toBeUndefined(); - forceUnit.endTurn(); + forceUnit.endTurn({ heatAndDissipationResolution: true }); expect(forceUnit.getHeat().current).toBe(0); }); @@ -1461,7 +1620,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(lines.map(line => line.textContent)).toEqual([ 'Movement: +1', 'Weapons: +5', - 'Sink (-20): -11', + 'Sink (20): -11', ]); expect(lines[2].getAttribute('fill')).toBe('#2070d1'); expect(lines[2].getAttribute('y')).toBe('100'); @@ -1569,7 +1728,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { const lines = Array.from(svg.querySelectorAll('#damagedEngineHeatText > tspan')); expect(lines.map(line => line.textContent)).toEqual([ 'Selected: +10', - 'Sink (-20): -10', + 'Sink (20): -10', ]); expect(lines[0].getAttribute('fill')).toBe('orange'); expect(lines[1].getAttribute('fill')).toBe('#2070d1'); @@ -1592,14 +1751,10 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(lines.map(line => line.textContent)).toEqual([ 'Selected: +7', 'Weapons: +2', - 'Sink (-20): -7', + 'Sink (20): -7', ]); expect(forceUnit.turnState().weaponsHeat()).toBe(2); - forceUnit.setInventoryControlEntrySelected(variableLaser, false); - svgService.refreshTurnState(); - expect(Array.from(svg.querySelectorAll('#damagedEngineHeatText > tspan')) - .map(line => line.textContent)).toEqual(['Weapons: +2', 'Sink (-20): -2']); }); it('removes selected inventory heat and hides an otherwise empty summary after deselection', () => { @@ -1641,28 +1796,16 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(svg.querySelector('#damagedEngineHeatText > tspan')?.textContent).toBe('Selected: +3'); }); - it('shows used and available dissipation when cooling clips heat to zero', () => { + it('tracks consumed dissipation when cooling clips heat to zero', () => { const forceUnit = createForceUnit(createMekUnitWithDissipation(28)); - const svg = new DOMParser().parseFromString(` - - - - `, 'image/svg+xml').documentElement as unknown as SVGSVGElement; - forceUnit.svg.set(svg); forceUnit.setHeatData({ current: 3, previous: 3 }); - const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgService(forceUnit, unitInitializer)); - - svgService.refreshTurnState(); - const line = svg.querySelector('#damagedEngineHeatText > tspan'); expect(forceUnit.turnState().heatProjection().consumedDissipation).toBe(3); expect(forceUnit.turnState().heatProjection().projected).toBe(0); - expect(line?.textContent).toBe('Sink (-28): -3'); - expect(line?.getAttribute('fill')).toBe('#2070d1'); }); - it('includes generated heat in effective dissipation while automations are disabled', () => { - cbtAutomations.set(false); + it('includes generated heat in effective dissipation when heat automation is no', () => { + heatAutomationMode.set('no'); const forceUnit = createForceUnit(createMekUnitWithDissipation(28)); const svg = new DOMParser().parseFromString(` @@ -1686,7 +1829,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { }); it('shows full dissipation capacity when current heat exceeds it with automations disabled', () => { - cbtAutomations.set(false); + heatAutomationMode.set('no'); const forceUnit = createForceUnit(createMekUnitWithDissipation(28)); const svg = new DOMParser().parseFromString(` @@ -1703,7 +1846,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { }); it('shows dissipation for generated heat when current heat is zero with automations disabled', () => { - cbtAutomations.set(false); + heatAutomationMode.set('no'); const forceUnit = createForceUnit(createMekUnitWithDissipation(28)); const svg = new DOMParser().parseFromString(` @@ -1725,7 +1868,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { }); it('omits dissipation when current and generated heat are both zero with automations disabled', () => { - cbtAutomations.set(false); + heatAutomationMode.set('no'); const forceUnit = createForceUnit(createMekUnitWithDissipation(28)); const svg = new DOMParser().parseFromString(` @@ -1845,6 +1988,9 @@ describe('CBTForceUnit direct inventory ammo bins', () => { }); it('automatically floods armorless submerged locations based on posture', () => { + // Keep posture under this test's explicit control; Core's flooded-leg + // automatic fall is exercised by the phase-resolution coverage. + automationModes.pilotSkillCheck = 'no'; const { forceUnit } = createCriticalHeatSinkForceUnit(); forceUnit.setArmorHits('LT', 5); forceUnit.turnState().setCover('underwater-depth-1'); @@ -1854,25 +2000,197 @@ describe('CBTForceUnit direct inventory ammo bins', () => { forceUnit.addArmorHits('LL', 5); forceUnit.endPhase(); expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeTrue(); + expect(toastService.showToast).toHaveBeenCalledWith( + jasmine.stringContaining('Breach and flooding: Left Leg flooded'), + 'error', + ); expect(forceUnit.getLocationCondition('LT', 'flooded')).toBeFalse(); forceUnit.setCondition('prone', true); expect(forceUnit.getLocationCondition('LT', 'flooded')).toBeTrue(); }); - it('waits to flood a location until pending armor damage is committed', () => { + it('marks flooding when pending armor breaches underwater and commits it at phase end', () => { const { forceUnit } = createCriticalHeatSinkForceUnit(); forceUnit.turnState().setCover('underwater-depth-1'); forceUnit.addArmorHits('LL', 5); expect(forceUnit.getCommittedArmorHits('LL')).toBe(0); - expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeFalse(); + expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeTrue(); + expect(forceUnit.serialize().state.locations['LL'].conditions) + .toEqual([{ key: 'flooded', pending: true }]); forceUnit.endPhase(); expect(forceUnit.getCommittedArmorHits('LL')).toBe(5); expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeTrue(); + expect(forceUnit.serialize().state.locations['LL'].conditions).toEqual(['flooded']); + }); + + it('does not flood or request review when breach and flood automation is no', () => { + automationModes.breachAndFloodCheck = 'no'; + const { forceUnit } = createCriticalHeatSinkForceUnit(); + const triggers: CBTUnitAutomationTrigger[] = []; + forceUnit.automationTriggers.subscribe(trigger => triggers.push(trigger)); + forceUnit.turnState().setCover('underwater-depth-1'); + + forceUnit.addArmorHits('LL', 5); + forceUnit.endPhase(); + + expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeFalse(); + expect(triggers.filter(trigger => trigger.kind === 'breach-and-flood')).toEqual([]); + }); + + it('requests one flood review as soon as armor breaches while already underwater', () => { + automationModes.breachAndFloodCheck = 'ask'; + const { forceUnit } = createCriticalHeatSinkForceUnit(); + const triggers: CBTUnitAutomationTrigger[] = []; + forceUnit.automationTriggers.subscribe(trigger => triggers.push(trigger)); + forceUnit.turnState().setCover('underwater-depth-1'); + + forceUnit.addArmorHits('LL', 5); + + expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeFalse(); + expect(triggers.filter(trigger => trigger.kind === 'breach-and-flood')).toEqual([ + jasmine.objectContaining({ + kind: 'breach-and-flood', + locations: ['LL'], + commit: false, + }), + ]); + }); + + it('can request a deferred flood review again', () => { + automationModes.breachAndFloodCheck = 'ask'; + const { forceUnit } = createCriticalHeatSinkForceUnit(); + const triggers: CBTUnitAutomationTrigger[] = []; + forceUnit.automationTriggers.subscribe(trigger => triggers.push(trigger)); + forceUnit.turnState().setCover('underwater-depth-1'); + forceUnit.addArmorHits('LL', 5); + forceUnit.endPhase(); + + forceUnit.deferUnderwaterBreachAndFloodingReview(['LL']); + forceUnit.applyUnderwaterBreachAndFlooding(true); + + expect(triggers.filter(trigger => trigger.kind === 'breach-and-flood')).toHaveSize(2); + }); + + it('keeps an unobserved flood review eligible until the unit sheet is opened', () => { + automationModes.breachAndFloodCheck = 'ask'; + const { forceUnit } = createCriticalHeatSinkForceUnit(); + forceUnit.turnState().setCover('underwater-depth-1'); + forceUnit.addArmorHits('LL', 5); + forceUnit.endPhase(); + + const triggers: CBTUnitAutomationTrigger[] = []; + forceUnit.automationTriggers.subscribe(trigger => triggers.push(trigger)); + forceUnit.applyUnderwaterBreachAndFlooding(true); + + expect(triggers.filter(trigger => trigger.kind === 'breach-and-flood')).toEqual([ + jasmine.objectContaining({ locations: ['LL'], commit: true }), + ]); + }); + + it('emits one critical chance for each internal-damage assignment', () => { + const { forceUnit } = createCriticalHeatSinkForceUnit(); + const triggers: CBTUnitAutomationTrigger[] = []; + forceUnit.automationTriggers.subscribe(trigger => triggers.push(trigger)); + + forceUnit.addInternalHits('LT', 2); + forceUnit.addInternalHits('LT', 2); + forceUnit.addInternalHits('LT', -1); + + const criticalTriggers = triggers.filter(trigger => trigger.kind === 'critical-hit-chance'); + expect(criticalTriggers).toEqual([ + jasmine.objectContaining({ + kind: 'critical-hit-chance', + }), + jasmine.objectContaining({ + kind: 'critical-hit-chance', + }), + ]); + expect(criticalTriggers.map(trigger => trigger.id)).toEqual([ + jasmine.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/), + jasmine.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/), + ]); + expect(criticalTriggers[0].id).not.toBe(criticalTriggers[1].id); + expect(forceUnit.turnState().getPendingCriticalChances()).toEqual([ + jasmine.objectContaining({ + id: criticalTriggers[0].id, + location: 'LT', + }), + jasmine.objectContaining({ + id: criticalTriggers[1].id, + location: 'LT', + }), + ]); + expect(forceUnit.turnState().getPendingCriticalChances() + .every(chance => !chance.locationDestroyed)).toBeTrue(); + }); + + it('does not queue automatic critical chances when that automation is no', () => { + automationModes.criticalHitChanceCheck = 'no'; + const { forceUnit } = createCriticalHeatSinkForceUnit(); + const triggers: CBTUnitAutomationTrigger[] = []; + forceUnit.automationTriggers.subscribe(trigger => triggers.push(trigger)); + + forceUnit.addInternalHits('LT', 2); + + expect(triggers.filter(trigger => trigger.kind === 'critical-hit-chance')).toEqual([]); + expect(forceUnit.turnState().getPendingCriticalChances()).toEqual([]); + }); + + it('carries explosion protection on the critical chance created by internal damage', () => { + const { forceUnit } = createCriticalHeatSinkForceUnit(); + const triggers: CBTUnitAutomationTrigger[] = []; + forceUnit.automationTriggers.subscribe(trigger => triggers.push(trigger)); + + forceUnit.addInternalHits('LT', 1, false, { + explosionProtection: 'case-ii', + hardenedArmorApplies: false, + pilotDamageGroup: 'turn-closed:immediate:end-turn:heat', + }); + + expect(triggers).toEqual([ + jasmine.objectContaining({ + kind: 'critical-hit-chance', + }), + ]); + const criticalTrigger = triggers.find(trigger => trigger.kind === 'critical-hit-chance'); + expect(criticalTrigger).toBeDefined(); + expect(forceUnit.turnState().getPendingCriticalChances()).toEqual([ + jasmine.objectContaining({ + id: criticalTrigger!.id, + location: 'LT', + explosionProtection: 'case-ii', + hardenedArmorApplies: false, + pilotDamageGroup: 'turn-closed:immediate:end-turn:heat', + }), + ]); + }); + + it('marks the chance that destroys a location and ignores damage beyond its structure', () => { + const { forceUnit } = createCriticalHeatSinkForceUnit(); + const triggers: CBTUnitAutomationTrigger[] = []; + forceUnit.automationTriggers.subscribe(trigger => triggers.push(trigger)); + const structure = forceUnit.getInternalPoints('LT'); + + forceUnit.addInternalHits('LT', structure - 1); + forceUnit.addInternalHits('LT', 5); + forceUnit.addInternalHits('LT', 1); + + const criticalTriggers = triggers.filter(trigger => trigger.kind === 'critical-hit-chance'); + expect(forceUnit.turnState().getPendingCriticalChances()).toEqual([ + jasmine.objectContaining({ + id: criticalTriggers[0].id, + }), + jasmine.objectContaining({ + id: criticalTriggers[1].id, + locationDestroyed: true, + }), + ]); + expect(forceUnit.turnState().getPendingCriticalChances()[0].locationDestroyed).toBeUndefined(); }); it('floods all submerged locations at the unit-specific full-submersion depth', () => { @@ -1979,19 +2297,19 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(forceUnit.turnState().heatDissipationBalance()).toBe(17); expect(forceUnit.turnState().heatProjection().projected).toBe(0); - forceUnit.endTurn(); + forceUnit.endTurn({ heatAndDissipationResolution: true }); expect(forceUnit.getHeat().current).toBe(0); expect(forceUnit.getHeat().next).toBeUndefined(); }); - it('settles a persisted dissipation deficit when automations are disabled', () => { + it('settles a persisted dissipation deficit when heat automation is no', () => { const forceUnit = createForceUnit(createMekUnitWithDissipation(20)); forceUnit.setHeatData({ current: 15, previous: 15 }); forceUnit.turnState().moveMode.set('walk'); forceUnit.turnState().acknowledgeHeatSources(16); forceUnit.setHeatsinksOff(7); - cbtAutomations.set(false); + heatAutomationMode.set('no'); forceUnit.setHeat(3); forceUnit.applyHeat(); @@ -2041,68 +2359,13 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(svg.querySelector('#heat-projection-path')).toBe(projectionPath); - forceUnit.endTurn(); + forceUnit.endTurn({ heatAndDissipationResolution: true }); svgService.refreshHeat(); expect(svg.querySelector('#heat-projection-path')).toBeNull(); expect(svg.querySelector('#projection-arrow')).toBeNull(); }); - it('separates a manual heat target from the automated projection UI', () => { - const forceUnit = createForceUnit(createEmptyUnit({ - ...createMekUnit(), - heat: 20, - dissipation: 0, - })); - const svg = new DOMParser().parseFromString(` - - - - ${Array.from({ length: 11 }, (_, value) => ``).join('')} - - - `, 'image/svg+xml').documentElement as unknown as SVGSVGElement; - forceUnit.svg.set(svg); - forceUnit.setHeatData({ current: 2, previous: 2 }); - forceUnit.turnState().addFiredHeat(5); - const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgService(forceUnit, unitInitializer)); - - svgService.refreshHeat(); - - expect(svg.querySelector('#projection-arrow')?.getAttribute('fill')).toBe('none'); - expect(svg.querySelector('#projection-arrow')?.getAttribute('stroke')).toBe('var(--hot-color)'); - expect(svg.querySelector('#now-arrow-label')?.textContent).toBe('NOW'); - expect(svg.querySelector('#now-arrow-label')?.getAttribute('transform')).toContain('rotate(90 '); - const calculatedProjectionPath = svg.querySelector('#heat-projection-path'); - expect(calculatedProjectionPath).not.toBeNull(); - expect(calculatedProjectionPath?.tagName.toLowerCase()).toBe('path'); - expect((calculatedProjectionPath?.getAttribute('d')?.match(/\bM\b/g) ?? []).length).toBe(1); - expect(svg.querySelectorAll('#heat-projection-path').length).toBe(1); - expect(svg.querySelector('#heatDataPanel')?.classList.contains('heatApplicationAvailable')).toBeFalse(); - const initialProjectionPathData = calculatedProjectionPath?.getAttribute('d'); - - forceUnit.setHeat(4); - svgService.refreshHeat(); - - expect(svg.querySelector('#projection-arrow')).toBeNull(); - expect(svg.querySelector('#next-arrow')).not.toBeNull(); - expect(svg.querySelector('#heat-projection-path')).toBe(calculatedProjectionPath); - expect(svg.querySelector('#heat-projection-path')?.getAttribute('d')).toBe(initialProjectionPathData); - expect(svg.querySelector('#heatDataPanel')?.classList.contains('heatApplicationAvailable')).toBeTrue(); - - forceUnit.applyHeat(); - svgService.refreshHeat(); - - expect(forceUnit.getHeat().current).toBe(4); - expect(forceUnit.getHeat().next).toBeUndefined(); - expect(forceUnit.turnState().heatSources().some(source => source.id === 'weapons')).toBeTrue(); - expect(svg.querySelector('#next-arrow')).toBeNull(); - expect(svg.querySelector('#projection-arrow')).not.toBeNull(); - expect(svg.querySelector('#heat-projection-path')).toBe(calculatedProjectionPath); - expect(svg.querySelector('#heat-projection-path')?.getAttribute('d')).not.toBe(initialProjectionPathData); - expect(svg.querySelector('#heatDataPanel')?.classList.contains('heatApplicationAvailable')).toBeFalse(); - }); - it('centers the overflow projection arrow over its body', () => { const forceUnit = createForceUnit(createEmptyUnit({ ...createMekUnit(), @@ -2240,38 +2503,8 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(svg.querySelector('#heat-projection-path')).toBeNull(); }); - it('hides calculated heat graphics when CBT automations are disabled', () => { - cbtAutomations.set(false); - const forceUnit = createForceUnit(createEmptyUnit({ - ...createMekUnit(), - heat: 20, - dissipation: 0, - })); - const svg = new DOMParser().parseFromString(` - - - - ${Array.from({ length: 11 }, (_, value) => ``).join('')} - - - `, 'image/svg+xml').documentElement as unknown as SVGSVGElement; - forceUnit.svg.set(svg); - forceUnit.setHeatData({ current: 2, previous: 2 }); - forceUnit.turnState().addFiredHeat(5); - const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgService(forceUnit, unitInitializer)); - - svgService.refreshHeat(); - - expect(svg.querySelector('#projection-arrow')).toBeNull(); - expect(svg.querySelector('#heat-projection-path')).toBeNull(); - expect(svg.querySelector('#heat-projection-target-marker')).not.toBeNull(); - expect(svg.querySelector('#heat-projection-target-marker')?.tagName.toLowerCase()).toBe('polygon'); - expect(svg.querySelector('#heatDataPanel')?.classList.contains('heatApplicationAvailable')).toBeFalse(); - expect(svg.querySelector('#now-arrow-label')).not.toBeNull(); - }); - it('shows an orange manual marker for selected weapons when committed heat is zero', () => { - cbtAutomations.set(false); + heatAutomationMode.set('no'); const forceUnit = createForceUnit(createSelectedHeatUnit(equipment, 0)); const svg = createSelectedHeatScaleSvg(); initialize(forceUnit, svg); @@ -2293,7 +2526,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { }); it('shows the orange manual marker at zero when sinks fully dissipate selected heat', () => { - cbtAutomations.set(false); + heatAutomationMode.set('no'); const forceUnit = createForceUnit(createSelectedHeatUnit(equipment, 20)); const svg = createSelectedHeatScaleSvg(); initialize(forceUnit, svg); @@ -2311,7 +2544,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { }); it('shows the committed manual marker at zero when sinks fully dissipate committed heat', () => { - cbtAutomations.set(false); + heatAutomationMode.set('no'); const forceUnit = createForceUnit(createSelectedHeatUnit(equipment, 20)); const svg = createSelectedHeatScaleSvg(); initialize(forceUnit, svg); @@ -2326,7 +2559,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { }); it('shows the committed manual marker for pure cooling without a committed heat source', () => { - cbtAutomations.set(false); + heatAutomationMode.set('no'); const forceUnit = createForceUnit(createSelectedHeatUnit(equipment, 20)); const svg = createSelectedHeatScaleSvg(); initialize(forceUnit, svg); @@ -2340,7 +2573,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { }); it('paints an orange selected marker over a committed marker when both target zero', () => { - cbtAutomations.set(false); + heatAutomationMode.set('no'); const forceUnit = createForceUnit(createSelectedHeatUnit(equipment, 20)); const svg = createSelectedHeatScaleSvg(); initialize(forceUnit, svg); @@ -2362,7 +2595,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { }); it('shows committed and selected manual heat markers independently', () => { - cbtAutomations.set(false); + heatAutomationMode.set('no'); const forceUnit = createForceUnit(createSelectedHeatUnit(equipment, 0)); const svg = createSelectedHeatScaleSvg(); initialize(forceUnit, svg); @@ -2380,6 +2613,505 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(svg.querySelector('#heat-selected-weapons-target-marker')?.getAttribute('fill')).toBe('orange'); }); + it('applies head, heat, drowning, and internal-explosion damage to every crew member aboard', () => { + const cases: readonly [string, (unit: CBTForceUnit) => number][] = [ + ['head hit', unit => unit.applyHeadHitCrewHits('combat:head')], + ['heat', unit => unit.applyHeatCrewHits(1, 'heat:end-turn')], + ['drowning', unit => unit.applyLifeSupportDrowningCrewHits(1, 'end:drowning')], + ['internal explosion', unit => unit.applyInternalExplosionCrewHits(1, 'combat:explosion')], + ]; + + for (const [label, apply] of cases) { + const forceUnit = createForceUnit(createEmptyUnit({ + type: 'Mek', + subtype: 'BattleMek', + crewSize: 3, + })); + const crew = forceUnit.getCrewMembers(); + crew[1].setState('unconscious'); + + expect(apply(forceUnit)).withContext(label).toBe(3); + expect(crew.map(member => member.getHits())).withContext(label).toEqual([1, 1, 1]); + } + }); + + it('queues independent consciousness targets for every crew member hit', () => { + const forceUnit = createForceUnit(createEmptyUnit({ + type: 'Mek', + subtype: 'BattleMek', + crewSize: 3, + })); + const crew = forceUnit.getCrewMembers(); + crew[1].setHits(1); + crew[2].setHits(2); + + expect(forceUnit.applyHeadHitCrewHits('combat:head')).toBe(3); + + expect(crew.map(member => member.getHits())).toEqual([1, 2, 3]); + expect(forceUnit.turnState().getPendingUnitChecks() + .filter(check => check.kind === 'consciousness') + .map(check => ({ crewId: check.crewId, target: check.target }))) + .toEqual([ + { crewId: 0, target: 3 }, + { crewId: 1, target: 5 }, + { crewId: 2, target: 7 }, + ]); + }); + + it('keeps consciousness recovery independent for each crew member and hit total', () => { + const forceUnit = createForceUnit(createEmptyUnit({ + type: 'Mek', + subtype: 'BattleMek', + crewSize: 3, + })); + forceUnit.getCrewMember(0).setHits(1); + forceUnit.getCrewMember(1).setHits(3); + + expect(forceUnit.setCrewState(0, 'unconscious')).toBeTrue(); + expect(forceUnit.setCrewState(1, 'unconscious')).toBeTrue(); + + expect(forceUnit.turnState().getPendingUnitChecks() + .filter(check => check.kind === 'consciousness-recovery') + .map(check => ({ crewId: check.crewId, target: check.target }))) + .toEqual([ + { crewId: 0, target: 3 }, + { crewId: 1, target: 7 }, + ]); + + expect(forceUnit.setCrewState(0, 'healthy')).toBeTrue(); + expect(forceUnit.turnState().getPendingUnitChecks() + .filter(check => check.kind === 'consciousness-recovery') + .map(check => ({ crewId: check.crewId, target: check.target }))) + .toEqual([{ crewId: 1, target: 7 }]); + }); + + it('aggregates Core combat and Heat Phase pilot damage at the highest consciousness target', () => { + const combatUnit = createForceUnit(); + combatUnit.applyPilotHits(1, 'combat:test'); + combatUnit.applyPilotHits(1, 'combat:test'); + + expect(combatUnit.turnState().getPendingUnitChecks()).toEqual([ + jasmine.objectContaining({ + kind: 'consciousness', + pilotDamageGroup: 'combat:test', + target: 5, + }), + ]); + + const heatUnit = createForceUnit(); + heatUnit.applyHeatCrewHits(2, 'turn-closed:heat:end-turn:test'); + heatUnit.applyInternalExplosionCrewHits(1, 'turn-closed:heat:end-turn:test'); + + expect(heatUnit.turnState().getPendingUnitChecks()).toEqual([ + jasmine.objectContaining({ + kind: 'consciousness', + pilotDamageGroup: 'turn-closed:heat:end-turn:test', + target: 7, + }), + ]); + }); + + it('preserves unresolved Heat Phase consciousness work across the turn boundary', () => { + const forceUnit = createForceUnit(); + forceUnit.applyHeatCrewHits(1, 'heat:end-turn:test'); + + forceUnit.endTurn(); + + expect(forceUnit.turnState().getPendingUnitChecks()).toEqual([ + jasmine.objectContaining({ + kind: 'consciousness', + pilotDamageGroup: 'turn-closed:heat:end-turn:test', + target: 3, + }), + ]); + expect(forceUnit.turnState().pendingUnitCheckCount()).toBe(1); + }); + + it('defers Core Movement Phase consciousness until the phase boundary', () => { + const forceUnit = createForceUnit(); + spyOn(forceUnit, 'tracksPhaseAndTurn').and.returnValue(true); + const automationTrigger = jasmine.createSpy('automationTrigger'); + const subscription = forceUnit.automationTriggers.subscribe(automationTrigger); + expect(forceUnit.turnState().currentPhase()).toBe('M'); + + forceUnit.applyPilotHits(1); + + const check = forceUnit.turnState().getPendingUnitChecks()[0]; + expect(check).toEqual(jasmine.objectContaining({ + kind: 'consciousness', + target: 3, + })); + expect(check.pilotDamageGroup?.startsWith('combat:')).toBeTrue(); + expect(forceUnit.turnState().actionablePendingUnitChecks().some(check => + check.kind === 'consciousness')).toBeFalse(); + expect(forceUnit.turnState().pendingUnitCheckCountAtPhaseEnd()).toBe(1); + expect(automationTrigger).not.toHaveBeenCalled(); + subscription.unsubscribe(); + }); + + it('folds Core seatbelt damage into the phase consciousness target', () => { + const forceUnit = createForceUnit(); + spyOn(forceUnit, 'tracksPhaseAndTurn').and.returnValue(true); + const group = forceUnit.turnState().currentPilotDamageGroup(); + forceUnit.applyPilotHits(1, group); + expect(forceUnit.queueFall('psr')).toBeTrue(); + expect(forceUnit.completePendingFall(forceUnit.getPendingFall()!.id)).toBeTrue(); + const seatbelt = forceUnit.turnState().getPendingUnitChecks().find(check => + check.kind === 'seatbelt')!; + + forceUnit.applyPilotHits(1, seatbelt.pilotDamageGroup, seatbelt.crewId); + + expect(seatbelt.pilotDamageGroup).toBe(group); + expect(forceUnit.turnState().getPendingUnitChecks().filter(check => + check.kind === 'consciousness')).toEqual([ + jasmine.objectContaining({ pilotDamageGroup: group, target: 5 }), + ]); + expect(forceUnit.turnState().actionablePendingUnitChecks().some(check => + check.kind === 'consciousness')).toBeFalse(); + }); + + it('applies tabletop pilot damage but queues no consciousness or recovery automation in no mode', () => { + automationModes.pilotHitsAndConsciousnessCheck = 'no'; + const forceUnit = createForceUnit(); + + expect(forceUnit.applyPilotHits(1)).toBe(1); + expect(forceUnit.getCrewMember(0).getHits()).toBe(1); + expect(forceUnit.turnState().getPendingUnitChecks()).toEqual([]); + + expect(forceUnit.setCrewState(0, 'unconscious')).toBeTrue(); + expect(forceUnit.turnState().getPendingUnitChecks()).toEqual([]); + }); + + it('uses the roll dialog itself as the ask interaction for consciousness and recovery', () => { + automationModes.pilotHitsAndConsciousnessCheck = 'ask'; + const forceUnit = createForceUnit(); + + forceUnit.applyPilotHits(1); + expect(forceUnit.turnState().getPendingUnitChecks()).toContain(jasmine.objectContaining({ + kind: 'consciousness', + target: 3, + })); + + forceUnit.setCrewState(0, 'unconscious'); + expect(forceUnit.turnState().getPendingUnitChecks()).toContain(jasmine.objectContaining({ + kind: 'consciousness-recovery', + })); + }); + + it('makes consciousness recovery actionable during the following turn', () => { + const forceUnit = createForceUnit(); + + forceUnit.applyPilotHits(1); + forceUnit.setCrewState(0, 'unconscious'); + + expect(forceUnit.turnState().getPendingUnitChecks()).toContain(jasmine.objectContaining({ + kind: 'consciousness-recovery', + readyTurn: 1, + })); + expect(forceUnit.turnState().pendingUnitCheckCount()).toBe(0); + + forceUnit.endTurn(); + + expect(forceUnit.turnState().getTurnCounter()).toBe(1); + expect(forceUnit.turnState().pendingUnitCheckCount()).toBe(1); + }); + + it('records a fatal sixth pilot hit immediately but resolves death at phase end', () => { + const forceUnit = createForceUnit(); + forceUnit.applyPilotHits(5); + forceUnit.setCrewState(0, 'unconscious'); + + expect(forceUnit.turnState().getPendingUnitChecks().some(check => + check.kind === 'consciousness-recovery')).toBeTrue(); + + expect(forceUnit.applyPilotHits(3)).toBe(1); + + const crew = forceUnit.getCrewMember(0); + expect(crew.getState()).toBe('unconscious'); + expect(crew.getHits()).toBe(DEAD_CREW_HIT_THRESHOLD); + expect(forceUnit.serialize().state.crew[0].state).toBe(1); + expect(forceUnit.getCondition('abandoned')).toBeFalse(); + expect(forceUnit.turnState().getPendingUnitChecks().filter(check => + check.kind === 'consciousness' || check.kind === 'consciousness-recovery')).toEqual([]); + + forceUnit.endPhase(); + + expect(crew.getState()).toBe('dead'); + expect(forceUnit.serialize().state.crew[0].state).toBe(2); + expect(forceUnit.getCondition('abandoned')).toBeTrue(); + }); + + it('routes tabletop pilot damage through Core consciousness aggregation', () => { + const forceUnit = createForceUnit(); + spyOn(forceUnit, 'tracksPhaseAndTurn').and.returnValue(true); + forceUnit.turnState().moveMode.set('stationary'); + const triggers: CBTUnitAutomationTrigger[] = []; + forceUnit.automationTriggers.subscribe(trigger => triggers.push(trigger)); + + expect(forceUnit.setCrewHits(0, 1)).toBeTrue(); + expect(forceUnit.setCrewHits(0, 2)).toBeTrue(); + + expect(forceUnit.getCrewMember(0).getHits()).toBe(2); + expect(forceUnit.turnState().dirtyPhase()).toBeTrue(); + expect(forceUnit.turnState().getPendingUnitChecks()).toEqual([ + jasmine.objectContaining({ + kind: 'consciousness', + target: 5, + pilotDamageGroup: jasmine.stringMatching(/^combat:/), + }), + ]); + expect(forceUnit.turnState().pendingUnitCheckCount()).toBe(0); + expect(forceUnit.turnState().pendingUnitCheckCountAtPhaseEnd()).toBe(1); + expect(triggers).toEqual([]); + }); + + it('routes each tabletop pilot hit through Total Warfare consciousness checks', () => { + cbtRules = 'tw'; + const forceUnit = createForceUnit(); + const triggers: CBTUnitAutomationTrigger[] = []; + forceUnit.automationTriggers.subscribe(trigger => triggers.push(trigger)); + + expect(forceUnit.setCrewHits(0, 2)).toBeTrue(); + + expect(forceUnit.getCrewMember(0).getHits()).toBe(2); + expect(forceUnit.turnState().getPendingUnitChecks()).toEqual([ + jasmine.objectContaining({ kind: 'consciousness', target: 3 }), + jasmine.objectContaining({ kind: 'consciousness', target: 5 }), + ]); + expect(forceUnit.turnState().pendingUnitCheckCount()).toBe(2); + expect(triggers).toEqual([{ kind: 'pending-unit-check' }]); + + expect(forceUnit.setCrewHits(0, 1)).toBeTrue(); + + expect(forceUnit.turnState().getPendingUnitChecks()).toEqual([ + jasmine.objectContaining({ kind: 'consciousness', target: 3 }), + ]); + expect(triggers).toEqual([{ kind: 'pending-unit-check' }]); + }); + + it('reconciles pending consciousness tiers after a tabletop hit correction', () => { + const forceUnit = createForceUnit(); + forceUnit.applyPilotHits(2); + expect(forceUnit.turnState().getPendingUnitChecks().map(check => check.target)).toEqual([5]); + + expect(forceUnit.setCrewHits(0, 1)).toBeTrue(); + + expect(forceUnit.turnState().getPendingUnitChecks().map(check => check.target)).toEqual([3]); + }); + + it('automatically fails an unconscious pilot\'s PSR before committing the phase', () => { + const forceUnit = createForceUnit(); + forceUnit.setCrewState(0, 'unconscious'); + forceUnit.turnState().addDmgReceived(20); + + expect(forceUnit.turnState().PSRRollsCount()).toBe(1); + expect(forceUnit.turnState().actionablePSRRollsCount()).toBe(0); + + forceUnit.endPhase(); + + expect(forceUnit.getCondition('prone')).toBeTrue(); + expect(forceUnit.turnState().PSRRollsCount()).toBe(0); + expect(forceUnit.pendingFallCount()).toBe(1); + expect(forceUnit.turnState().getPendingUnitChecks()).toEqual([]); + + expect(forceUnit.completePendingFall(forceUnit.getPendingFall()!.id)).toBeTrue(); + expect(forceUnit.turnState().getPendingUnitChecks()).toContain(jasmine.objectContaining({ + kind: 'seatbelt', + result: { kind: 'automatic', outcome: 'failed' }, + })); + }); + + it('keeps crew 0 on PSRs while available and uses the best available alternate only after takeover', () => { + const forceUnit = createForceUnit(createEmptyUnit({ + type: 'Mek', + subtype: 'BattleMek', + crewSize: 3, + })); + forceUnit.getCrewMember(0).setSkill('piloting', 6); + forceUnit.getCrewMember(1).setSkill('piloting', 5); + forceUnit.getCrewMember(2).setSkill('piloting', 3); + + expect(forceUnit.rules.getActivePilotCrewId()).toBe(0); + expect(forceUnit.rules.getBasePilotingSkill()).toBe(6); + + forceUnit.getCrewMember(0).setState('unconscious'); + forceUnit.turnState().addDmgReceived(20); + + expect(forceUnit.rules.getActivePilotCrewId()).toBe(2); + expect(forceUnit.rules.getBasePilotingSkill()).toBe(3); + expect(forceUnit.turnState().automaticPSRFailure()).toBeFalse(); + expect(forceUnit.turnState().actionablePSRRollsCount()).toBe(1); + }); + + it('keeps the TW involuntary shutdown PSR rollable while the Mek is standing', () => { + cbtRules = 'tw'; + const forceUnit = createForceUnit(); + forceUnit.setCondition('shutdown', true); + forceUnit.turnState().setPSRCheckState({ shutdown: true }); + + const [shutdownCheck] = forceUnit.turnState().getPSRChecks(); + expect(shutdownCheck.kind).toBe('shutdown'); + expect(forceUnit.turnState().PSRRollsCount()).toBe(1); + expect(forceUnit.turnState().actionablePSRRollsCount()).toBe(1); + expect(forceUnit.turnState().automaticPSRFailure()).toBeFalse(); + + expect(forceUnit.turnState().resolvePSRCheck(shutdownCheck.id!, 'success')).toBeTrue(); + expect(forceUnit.getCondition('prone')).toBeFalse(); + expect(forceUnit.pendingFallCount()).toBe(0); + }); + + it('keeps a fall pending until completion, retains its rolls, then releases seatbelt work', () => { + const forceUnit = createForceUnit(); + forceUnit.setCondition('prone', true); + const triggers: CBTUnitAutomationTrigger[] = []; + forceUnit.automationTriggers.subscribe(trigger => triggers.push(trigger)); + + expect(forceUnit.queueFall('stand-attempt')).toBeTrue(); + + expect(triggers).toEqual([ + jasmine.objectContaining({ + kind: 'falling', + source: 'stand-attempt', + levelsFallen: 0, + }), + ]); + expect(forceUnit.pendingFallCount()).toBe(1); + expect(forceUnit.turnState().getPendingUnitChecks()).toEqual([]); + const pending = forceUnit.getPendingFall()!; + + expect(forceUnit.setPendingFallRolls(pending.id, 4, [{ + hitLocationRoll: 7, + hitLocationDice: [5, 2], + tripodLegRoll: null, + }], [4])).toBeTrue(); + expect(forceUnit.getPendingFall(pending.id)).toEqual(jasmine.objectContaining({ + orientationRoll: 4, + orientationDice: [4], + damageRolls: [{ + hitLocationRoll: 7, + hitLocationDice: [5, 2], + tripodLegRoll: null, + }], + })); + expect('falling' in forceUnit.serialize().state).toBeFalse(); + + expect(forceUnit.completePendingFall(pending.id)).toBeTrue(); + expect(forceUnit.pendingFallCount()).toBe(0); + expect(triggers[triggers.length - 1]).toEqual({ kind: 'pending-unit-check' }); + expect(forceUnit.turnState().getPendingUnitChecks()).toContain(jasmine.objectContaining({ + kind: 'seatbelt', + })); + }); + + it('creates one seatbelt check per crew member with independent skills and unconscious failure', () => { + const forceUnit = createForceUnit(createEmptyUnit({ + type: 'Mek', + subtype: 'BattleMek', + crewSize: 3, + })); + forceUnit.getCrewMember(0).setSkill('piloting', 5); + forceUnit.getCrewMember(1).setSkill('piloting', 4); + forceUnit.getCrewMember(1).setState('unconscious'); + forceUnit.getCrewMember(2).setSkill('piloting', 3); + + expect(forceUnit.queueFall('psr')).toBeTrue(); + expect(forceUnit.completePendingFall(forceUnit.getPendingFall()!.id)).toBeTrue(); + + const seatbelts = forceUnit.turnState().getPendingUnitChecks() + .filter(check => check.kind === 'seatbelt'); + expect(seatbelts).toEqual([ + jasmine.objectContaining({ crewId: 0, target: 5 }), + jasmine.objectContaining({ + crewId: 1, + result: { kind: 'automatic', outcome: 'failed' }, + }), + jasmine.objectContaining({ crewId: 2, target: 3 }), + ]); + expect(seatbelts[1].target).toBeUndefined(); + }); + + it('does not queue another fall or seatbelt for a PSR while already prone', () => { + const forceUnit = createForceUnit(); + forceUnit.setCondition('prone', true); + const triggers: CBTUnitAutomationTrigger[] = []; + forceUnit.automationTriggers.subscribe(trigger => triggers.push(trigger)); + + expect(forceUnit.queueFall('psr')).toBeFalse(); + + expect(triggers).toEqual([]); + expect(forceUnit.pendingFallCount()).toBe(0); + expect(forceUnit.turnState().getPendingUnitChecks()).toEqual([]); + }); + + it('does not queue falling work when falling automation is no', () => { + automationModes.fallingCheck = 'no'; + const forceUnit = createForceUnit(); + const triggers: CBTUnitAutomationTrigger[] = []; + forceUnit.automationTriggers.subscribe(trigger => triggers.push(trigger)); + + expect(forceUnit.queueFall('psr')).toBeFalse(); + + expect(triggers).toEqual([]); + expect(forceUnit.pendingFallCount()).toBe(0); + }); + + it('does not create a fall or seatbelt check for 20-point damage while prone', () => { + const forceUnit = createForceUnit(); + forceUnit.setCondition('prone', true); + const triggers: CBTUnitAutomationTrigger[] = []; + forceUnit.automationTriggers.subscribe(trigger => triggers.push(trigger)); + + forceUnit.turnState().addDmgReceived(19); + forceUnit.turnState().addDmgReceived(1); + forceUnit.turnState().addDmgReceived(5); + + expect(forceUnit.pendingFallCount()).toBe(0); + expect(forceUnit.turnState().PSRRollsCount()).toBe(0); + expect(triggers).toEqual([]); + expect(forceUnit.turnState().getPendingUnitChecks()).toEqual([]); + }); + + it('removes a standing 20-point fall PSR when stance is manually changed to prone', () => { + const forceUnit = createForceUnit(); + const triggers: CBTUnitAutomationTrigger[] = []; + forceUnit.automationTriggers.subscribe(trigger => triggers.push(trigger)); + + forceUnit.turnState().addDmgReceived(20); + expect(forceUnit.turnState().PSRRollsCount()).toBe(1); + + forceUnit.setCondition('prone', true); + + expect(forceUnit.turnState().PSRRollsCount()).toBe(0); + expect(forceUnit.pendingFallCount()).toBe(0); + expect(triggers).toEqual([]); + expect(forceUnit.turnState().getPendingUnitChecks()).toEqual([]); + }); + + it('keeps standing 20-point damage on the normal fall-PSR path', () => { + const forceUnit = createForceUnit(); + + forceUnit.turnState().addDmgReceived(20); + + expect(forceUnit.turnState().getPendingUnitChecks()).toEqual([]); + expect(forceUnit.turnState().getPSRChecks()).toContain(jasmine.objectContaining({ + reason: jasmine.stringMatching(/20/), + failureOutcome: 'Fall', + })); + }); + + it('keeps the manual prone state override separate from falling automation', () => { + const forceUnit = createForceUnit(); + const triggers: CBTUnitAutomationTrigger[] = []; + forceUnit.automationTriggers.subscribe(trigger => triggers.push(trigger)); + + forceUnit.setCondition('prone', true); + forceUnit.setCondition('prone', false); + + expect(triggers).toEqual([]); + expect(forceUnit.pendingFallCount()).toBe(0); + expect(forceUnit.turnState().getPendingUnitChecks()).toEqual([]); + }); + it('clamps turn movement when committed inventory state reduces active run movement bonus', () => { TestBed.inject(EquipmentInteractionRegistryService).getRegistry().register(new RunMovementBonusTestHandler()); const forceUnit = createForceUnit(createEmptyUnit({ @@ -2704,6 +3436,8 @@ describe('CBTForceUnit direct inventory ammo bins', () => { for (const consolidateImmediately of [false, true]) { it(`commits a charged PPC-capacitor explosion across every Mek slot${consolidateImmediately ? ' immediately' : ' at phase end'}`, () => { const forceUnit = createForceUnit(createMekUnit()); + const crew = forceUnit.getCrewMember(0); + crew.setHits(DEAD_CREW_HIT_THRESHOLD - 1); const { weaponSlots, capacitorSlots, unrelatedSlot } = installChargedPpcPair(forceUnit, true); const triggerSlot = consolidateImmediately ? weaponSlots[0] : capacitorSlots[0]; @@ -2714,7 +3448,15 @@ describe('CBTForceUnit direct inventory ammo bins', () => { consolidateImmediately, ); expect(result?.applied).toBeTrue(); - if (!consolidateImmediately) forceUnit.endPhase(); + expect(crew.getHits()).toBe(consolidateImmediately ? DEAD_CREW_HIT_THRESHOLD : DEAD_CREW_HIT_THRESHOLD - 1); + if (consolidateImmediately) { + expect(crew.getState()).toBe('healthy'); + } + + forceUnit.endPhase(); + + expect(crew.getHits()).toBe(DEAD_CREW_HIT_THRESHOLD); + expect(crew.getState()).toBe('dead'); const committedSlots = [...weaponSlots, ...capacitorSlots] .map(slot => forceUnit.findCurrentCriticalSlot(slot)!); @@ -2986,10 +3728,48 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(restored.turnState().dmgReceived()).toBe(20); expect(restored.turnState().weaponsHeat()).toBe(8); expect(restored.turnState().spotting()).toBeTrue(); + expect(restored.turnState().getTurnCounter()).toBe(forceUnit.turnState().getTurnCounter()); expect(restored.turnState().getPSRCheckState().legActuators?.get('LL')).toBe(1); expect(restored.turnState().getPSRCheckState().hipsHit?.has('RL')).toBeTrue(); }); + it('increments and persists the per-unit turn counter on endTurn', () => { + const forceUnit = createForceUnit(); + + expect(forceUnit.turnState().getTurnCounter()).toBe(0); + + forceUnit.endTurn(); + forceUnit.endTurn(); + + expect(forceUnit.turnState().getTurnCounter()).toBe(2); + + const restored = CBTForceUnit.deserialize( + forceUnit.serialize(), + new TestCBTForce('Restored Turn Counter Force', dataService, unitInitializer, injector), + dataService, + unitInitializer, + injector, + ); + + expect(restored.turnState().getTurnCounter()).toBe(2); + + restored.endTurn(); + + expect(restored.turnState().getTurnCounter()).toBe(3); + }); + + it('clears the resumable end-turn checkpoint only when the turn resets', () => { + const forceUnit = createForceUnit(); + forceUnit.turnState().markEndTurnHeatStaged(); + + expect(forceUnit.turnState().getEndTurnCheckpoint()).toBe('heat-staged'); + + forceUnit.endTurn({ heatAndDissipationResolution: false, phaseAlreadyEnded: true }); + + expect(forceUnit.turnState().getTurnCounter()).toBe(1); + expect(forceUnit.turnState().getEndTurnCheckpoint()).toBeUndefined(); + }); + it('exposes spotting as a transient condition and clears it at end of turn', () => { const forceUnit = createForceUnit(); @@ -3035,25 +3815,40 @@ describe('CBTForceUnit direct inventory ammo bins', () => { forceUnit.getCrewMember(0).setHits(DEAD_CREW_HIT_THRESHOLD); + expect(forceUnit.getCondition('abandoned')).toBeFalse(); + + forceUnit.endPhase(); + expect(forceUnit.getCondition('abandoned')).toBeTrue(); expect(forceUnit.getConditions().has('abandoned')).toBeTrue(); expect(forceUnit.conditions.has('abandoned')).toBeFalse(); expect(forceUnit.serialize().state.conditions).toBeUndefined(); }); - it('derives crew death from hits while preserving the underlying crew state', () => { + it('stores crew death at phase end and only clears it when hits are reduced', () => { const forceUnit = createForceUnit(); const crewMember = forceUnit.getCrewMember(0); crewMember.setState('unconscious'); crewMember.setHits(DEAD_CREW_HIT_THRESHOLD); - expect(crewMember.getState()).toBe('dead'); + expect(crewMember.getState()).toBe('unconscious'); expect(crewMember.serialize().state).toBe(1); + forceUnit.endPhase(); + + expect(crewMember.getState()).toBe('dead'); + expect(crewMember.serialize().state).toBe(2); + + crewMember.setState('unconscious'); + crewMember.setState('ejected'); + + expect(crewMember.getState()).toBe('dead'); + crewMember.setHits(DEAD_CREW_HIT_THRESHOLD - 1); - expect(crewMember.getState()).toBe('unconscious'); + expect(crewMember.getState()).toBe('healthy'); + expect(crewMember.serialize().state).toBe(0); }); it('derives crew death from destroyed cockpit', () => { @@ -3071,6 +3866,10 @@ describe('CBTForceUnit direct inventory ammo bins', () => { crewMember.setHits(DEAD_CREW_HIT_THRESHOLD); + expect(crewMember.getState()).toBe('healthy'); + + forceUnit.endPhase(); + expect(crewMember.getState()).toBe('dead'); }); @@ -3956,6 +4755,45 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(forceUnit.canPerformEquipmentAction(hatchet, 'physical-attack')).toBeTrue(); }); + it('prevents an unconscious crew from receiving movement or attack selections', () => { + const forceUnit = createForceUnit(createEmptyUnit({ + ...createMekUnit(), + walk: 5, + run: 8, + run2: 8, + })); + forceUnit.isLoaded.set(true); + const rangedWeapon = new MountedEquipment({ + owner: forceUnit, + id: 'VariableDamageLaser@RA#0', + name: 'Variable Damage Laser', + equipment: equipment['VariableDamageLaser'], + }); + const punch = new MountedEquipment({ + owner: forceUnit, + id: 'physical:punch', + name: 'Punch', + intrinsicPhysicalAttack: true, + }); + + forceUnit.setCrewState(0, 'unconscious'); + + expect(forceUnit.canTakeActiveActions()).toBeFalse(); + expect(forceUnit.canPerformEquipmentAction(rangedWeapon, 'fire')).toBeFalse(); + expect(forceUnit.canPerformEquipmentAction(punch, 'physical-attack')).toBeFalse(); + expect(forceUnit.canPerformEquipmentAction(rangedWeapon, 'activate')).toBeFalse(); + expect(forceUnit.canPerformEquipmentAction(rangedWeapon, 'change-mode')).toBeFalse(); + expect(forceUnit.canPerformEquipmentAction(rangedWeapon, 'configure-network')).toBeFalse(); + expect(forceUnit.canPerformEquipmentAction(rangedWeapon, 'provide-passive-effect')).toBeTrue(); + expect(forceUnit.getAvailableMotiveModes(false).map(option => option.mode)).toEqual(['stationary']); + + forceUnit.setCrewState(0, 'healthy'); + + expect(forceUnit.canTakeActiveActions()).toBeTrue(); + expect(forceUnit.canPerformEquipmentAction(rangedWeapon, 'fire')).toBeTrue(); + expect(forceUnit.getAvailableMotiveModes(false).some(option => option.mode !== 'stationary')).toBeTrue(); + }); + it('unions current critical and mount installation locations for whole-mount status', () => { const forceUnit = createForceUnit(); forceUnit.locations = { @@ -4556,32 +5394,6 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(linkedCritGroup.classList.contains('disabledLocation')).toBeFalse(); }); - it('hides unit condition buttons at runtime when there are no matching controls', () => { - const forceUnit = createForceUnit(createEmptyUnit({ - name: 'AFTest_AERO-1', - chassis: 'Test Aero', - model: 'AERO-1', - type: 'Aero', - subtype: 'Aerospace Fighter', - })); - const svg = new DOMParser().parseFromString(` - - - - - - - `, 'image/svg+xml').documentElement as unknown as SVGSVGElement; - forceUnit.svg.set(svg); - const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgService(forceUnit, unitInitializer)); - - svgService.refreshConditions(); - - expect((svg.getElementById('unit_condition_wrapper') as SVGElement).style.display).toBe('none'); - expect((svg.getElementById('unit_condition_button_menu') as SVGElement).style.display).toBe('none'); - expect((svg.getElementById('unit_condition_button_prone') as SVGElement).style.display).toBe('none'); - }); - it('hides crew state buttons at runtime when there are no crew state controls', () => { const forceUnit = createForceUnit(createEmptyUnit({ name: 'AFTest_AERO-1', @@ -5312,4 +6124,5 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(forceUnit.getInventoryControlSnapshot().entryStates.has(weaponEntry.id)).toBeFalse(); expect(forceUnit.isInventoryControlEntrySelected(weaponEntry.id)).toBeFalse(); }); + }); diff --git a/src/app/models/cbt-force-unit.model.ts b/src/app/models/cbt-force-unit.model.ts index b57ca29f8..d8ff085e2 100644 --- a/src/app/models/cbt-force-unit.model.ts +++ b/src/app/models/cbt-force-unit.model.ts @@ -3,16 +3,17 @@ // Author: Drake import { computed, createEnvironmentInjector, effect, type EffectRef, EnvironmentInjector, type Injector, isDevMode, runInInjectionContext, signal, type Signal, untracked, type WritableSignal } from '@angular/core'; +import { Subject } from 'rxjs'; import { DataService } from '../services/data.service'; import { getUnitHeight, type UnitSummary, type UnitHeight } from "./unit-summary.model"; import type { UnitInitializerService } from '../services/unit-initializer.service'; import { MountedAmmo, MountedEquipment, MountedWeapon } from './mounted-equipment.model'; -import { type CriticalSlot, type HeatProfile, type LocationData, type ViewportTransform, CRIT_SLOT_SCHEMA, HEAT_SCHEMA, LOCATION_SCHEMA, INVENTORY_SCHEMA, C3_POSITION_SCHEMA, TURN_STATE_SCHEMA, type CBTSerializedState, type CBTSerializedUnit, type RuleCheckOutcome, type SerializedCrewMember, type SerializedRuleCheck, committedConditionData, conditionsForSerialization, conditionsHasActive, conditionsHasCommittedActive, conditionsMapFromSerialization, normalizeConditionData, normalizeConditionKey } from './force-serialization'; +import { type CriticalSlot, type HeatProfile, type LocationData, type MekHitArc, type ViewportTransform, CRIT_SLOT_SCHEMA, HEAT_SCHEMA, LOCATION_SCHEMA, INVENTORY_SCHEMA, C3_POSITION_SCHEMA, TURN_STATE_SCHEMA, type CBTSerializedState, type CBTSerializedUnit, type CBTMekFallSource, type PendingEventInput, type RuleCheckOutcome, type SerializedCrewMember, type SerializedPendingMekFall, type SerializedPendingUnitCheck, type SerializedRuleCheck, committedConditionData, conditionsForSerialization, conditionsHasActive, conditionsHasCommittedActive, conditionsMapFromSerialization, normalizeConditionData, normalizeConditionKey } from './force-serialization'; import { ForceUnit } from './force-unit.model'; import type { ConditionData } from './force-unit-state.model'; import type { CBTForce } from './cbt-force.model'; import { UnitSvgService } from '../services/unit-svg.service'; -import { CrewMember, DEFAULT_GUNNERY_SKILL, DEFAULT_PILOTING_SKILL, type SkillType } from './crew-member.model'; +import { CrewMember, DEAD_CREW_HIT_THRESHOLD, DEFAULT_GUNNERY_SKILL, DEFAULT_PILOTING_SKILL, getConsciousnessTarget, isCrewMemberAboard, isCrewMemberAvailable, type CrewMemberState, type SkillType } from './crew-member.model'; import { CBTForceUnitState } from './cbt-force-unit-state.model'; import { UnitSvgMekService } from '../services/unit-svg-mek.service'; import { UnitSvgAeroService } from '../services/unit-svg-aero.service'; @@ -28,7 +29,7 @@ import { unitHasActiveC3DisruptingStealth } from './stealth-equipment.model'; import { getMotiveModeLabel, getMotiveModesOptionsByUnit, type MotiveModeOption, type MotiveModes } from './motiveModes.model'; import type { TurnState } from './turn-state.model'; import { Sanitizer } from '../utils/sanitizer.util'; -import type { UnitTypeRules } from './rules/unit-type-rules'; +import type { PSRCheck, UnitTypeRules } from './rules/unit-type-rules'; import { type InventoryControlRuntimeAmmoSelection, type InventoryControlRuntimeEntryState, type InventoryControlRuntimeRangeKey, type InventoryControlRuntimeSnapshot, type InventoryControlRuntimeTarget, type InventoryControlRuntimeTargetId } from './inventory-control-runtime-state.model'; import { CBTInventoryControlRuntime } from './cbt-inventory-control-runtime.model'; import { getMekLegLocations, getMekLocationParent, inferMekConfigFromLocations, MEK_REAR_ARMOR_LOCATIONS } from './entity/types'; @@ -42,14 +43,21 @@ import { import type { UnitHeatSource } from './rules/unit-type-rules'; import { resolveInventoryControlSelectedAmmoType, type InventoryControlDisplayData, type InventoryControlDisplayEffectOptions, type InventoryControlRules } from '../utils/inventory-control.util'; import { ToastService } from '../services/toast.service'; -import { DialogsService } from '../services/dialogs.service'; +import { CBTAutomationToastService } from '../services/cbt-automation-toast.service'; import { getBattleArmorTrooperNumber, normalizeBattleArmorTrooperLocation } from './battle-armor-location.model'; import { CBTGameRulesService } from '../services/cbt-game-rules.service'; -import type { C3DegradationSource, C3TargetingResolution, CBTGameRules } from './rules/game-rules'; +import type { C3DegradationSource, C3TargetingResolution, CBTGameRules, MekExplosionProtection } from './rules/game-rules'; import { OptionsService } from '../services/options.service'; +import type { AutomationMode, CBTAutomationKey } from './options.model'; import { resolveSelectedInventoryWeaponHeat } from '../utils/inventory-control-heat.util'; import { parseInventoryComponentReference } from './inventory-component-reference.model'; import type { InventoryControlPhysicalDamageEffect } from '../utils/inventory-control-physical-damage.util'; +import { uuidv7 } from '../utils/uuid.util'; +import { + createPilotDamageGroup, + isHeatPilotDamageGroup, + isImmediatePilotDamageGroup, +} from '../utils/pilot-damage-group.util'; import { combineEquipmentStatuses, type CriticalSlotStatusFacts, @@ -58,6 +66,13 @@ import { } from './equipment-status.model'; import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE } from './rules/unit-type-rules'; import type { HeatDissipationState } from './rules/heat-management'; +import { getMekLocationLabel } from './entity/types/mek'; +import { UNIT_CHECK_KIND } from './unit-check.model'; +import { + isConsciousnessCheck, + isConsciousnessRecoveryCheck, + isConsciousnessSequenceCheck, +} from '../utils/unit-check.util'; export type EquipmentStatusSource = MountedEquipment | CriticalSlot; export type EquipmentAction = @@ -69,6 +84,64 @@ export type EquipmentAction = | 'configure-network'; export type EquipmentStateEdit = 'enable' | 'disable' | 'repair' | 'apply-damage'; +export interface CBTEndTurnAutomationDecisions { + heatAndDissipationResolution?: boolean; + /** The end-turn coordinator has already completed this unit's phase. */ + phaseAlreadyEnded?: boolean; +} + +export interface CBTInternalDamageContext { + readonly explosionProtection?: MekExplosionProtection; + /** Whether Hardened Armor remained in the exact facing/location when this hit reached structure. */ + readonly hardenedArmorApplies?: boolean; + /** Critical explosions retain the pilot-damage event that produced the internal hit. */ + readonly pilotDamageGroup?: string; + /** Hit-table arc for a possible through-armor critical. */ + readonly throughArmorHitArc?: MekHitArc; +} + +export interface CBTMekFallDamageRoll { + readonly hitLocationRoll: number | null; + readonly hitLocationDice?: readonly [number, number] | null; + readonly tripodLegRoll: number | null; + readonly tripodLegDice?: readonly [number] | null; +} + +/** Serialized event facts plus nonserialized dialog choices. */ +export interface CBTPendingMekFall extends SerializedPendingMekFall { + readonly orientationRoll: number | null; + readonly orientationDice: readonly [number] | null; + readonly damageRolls: readonly CBTMekFallDamageRoll[]; +} + +function normalizeD6Faces(faces: readonly number[] | null | undefined, count: number): readonly number[] | null { + return faces?.length === count + && faces.every(face => Number.isInteger(face) && face >= 1 && face <= 6) + ? [...faces] + : null; +} + +export type CBTUnitAutomationTrigger = + | { + readonly kind: 'critical-hit-chance'; + readonly id: string; + } + | { + readonly kind: 'pending-unit-check'; + } + | { + readonly kind: 'falling'; + readonly id: string; + readonly source: CBTMekFallSource; + readonly levelsFallen: number; + } + | { + readonly kind: 'breach-and-flood'; + readonly id: string; + readonly locations: readonly string[]; + readonly commit: boolean; + }; + export class CBTForceUnit extends ForceUnit { override get force(): CBTForce { return super.force as CBTForce; } override set force(value: CBTForce) { super.force = value; } @@ -78,7 +151,19 @@ export class CBTForceUnit extends ForceUnit { private svgServiceInjector: EnvironmentInjector | null = null; private optionalRulesEffect: EffectRef | null = null; private readonly unknownEquipmentInstallationLocationIds = new Set(); + private readonly reviewedFloodLocations = new Set(); private _rules!: UnitTypeRules; + readonly automationTriggers = new Subject(); + /** Provisional PSR choices retained across dialog instances; intentionally not serialized. */ + readonly psrOutcomeSelections = signal>>({}); + /** Exact virtual PSR dice retained alongside provisional outcomes. */ + readonly psrDiceSelections = signal>>({}); + private readonly pendingMekFallRolls = signal>>({}); + readonly pendingFallCount = computed(() => this.turnState().pendingFallCount()); readonly gameRules: CBTGameRules; viewState: ViewportTransform; locations?: { @@ -147,8 +232,12 @@ export class CBTForceUnit extends ForceUnit { return getUnitHeight(this.getUnit(), this.getCondition('prone')); } - useAutomations(): boolean { - return this.injector.get(OptionsService, null, { optional: true })?.options().cbtAutomations ?? true; + automationMode(key: CBTAutomationKey): AutomationMode { + return this.injector.get(OptionsService).cbtAutomationMode(key); + } + + tracksPhaseAndTurn(): boolean { + return this.injector.get(OptionsService).options().trackPhaseAndTurn; } allowsExtremeRangeAttacks(): boolean { @@ -159,6 +248,10 @@ export class CBTForceUnit extends ForceUnit { return this.injector.get(OptionsService, null, { optional: true })?.options().CBTOptionalRules?.forcedWithdrawal ?? true; } + usesFloatingCriticals(): boolean { + return this.injector.get(OptionsService, null, { optional: true })?.options().CBTOptionalRules?.floatingCriticals ?? false; + } + private getEquipmentInteractionRegistry(): EquipmentInteractionRegistry { return this.injector.get(EquipmentInteractionRegistryService).getRegistry(); } @@ -493,8 +586,9 @@ export class CBTForceUnit extends ForceUnit { slot.hits = Math.max(0, (slot.hits ?? 0) + damage); const destroying = slot.armored ? slot.hits >= 2 : slot.hits >= 1; slot.destroying = destroying ? Date.now() : undefined; - if (slot.destroyed && !destroying) { + if (slot.destroyed !== undefined && !destroying) { slot.destroyed = undefined; // Reset destroyed immediately + slot.destroyedTurn = undefined; } this.setCritSlot(slot); if (consolidateImmediately) { @@ -753,6 +847,7 @@ export class CBTForceUnit extends ForceUnit { } this.state.turnState().addDmgReceived(hitsForPsr); if (consolidateImmediately) this.state.consolidateLocations(); + else this.applyUnderwaterBreachAndFlooding(); this.evaluateDestroyed(); this.setModified(); } @@ -767,6 +862,7 @@ export class CBTForceUnit extends ForceUnit { locations[locKey].pendingArmor = undefined; this.state.locations.set({ ...this.state.locations(), [locKey]: locations[locKey] }); this.markEquipmentLocationsChanged(); + this.applyUnderwaterBreachAndFlooding(true); this.evaluateDestroyed(); this.setModified(); } @@ -780,7 +876,47 @@ export class CBTForceUnit extends ForceUnit { return (locData?.internal ?? 0) + (locData?.pendingInternal ?? 0); } - addInternalHits(loc: string, hits: number, consolidateImmediately: boolean = false) { + /** Queues one rules-generated critical chance unless that automation is disabled. */ + queueMekCriticalChance( + location: string, + options: CBTInternalDamageContext & { + readonly locationDestroyed?: boolean; + readonly consolidateImmediately?: boolean; + } = {}, + ): boolean { + if (this.getUnit().type !== 'Mek' + || !location + || this.automationMode('criticalHitChanceCheck') === 'no') return false; + const id = uuidv7(); + const queued = this.turnState().queuePendingCriticalChance({ + id, + location, + ...(options.locationDestroyed ? { locationDestroyed: true } : {}), + ...(options.consolidateImmediately ? { consolidateImmediately: true } : {}), + ...(options.explosionProtection !== undefined + ? { explosionProtection: options.explosionProtection } + : {}), + ...(options.hardenedArmorApplies !== undefined + ? { hardenedArmorApplies: options.hardenedArmorApplies } + : {}), + ...(options.throughArmorHitArc !== undefined + ? { throughArmorHitArc: options.throughArmorHitArc } + : {}), + pilotDamageGroup: options.pilotDamageGroup + ?? this.turnState().currentPilotDamageGroup(), + }); + if (queued) this.automationTriggers.next({ kind: 'critical-hit-chance', id }); + return queued; + } + + addInternalHits( + loc: string, + hits: number, + consolidateImmediately: boolean = false, + context: CBTInternalDamageContext = {}, + ) { + const previousHits = this.getInternalHits(loc); + const internalPoints = this.getInternalPoints(loc); const locations = { ...this.state.locations() }; if (locations[loc] === undefined) { locations[loc] = {}; @@ -801,6 +937,15 @@ export class CBTForceUnit extends ForceUnit { this.clearNarcFromCommittedPhysicallyDestroyedLocations(); this.evaluateDestroyed(); this.setModified(); + const boundedPreviousHits = Math.min(internalPoints, Math.max(0, previousHits)); + const boundedCurrentHits = Math.min(internalPoints, Math.max(0, this.getInternalHits(loc))); + const appliedDamage = Math.max(0, boundedCurrentHits - boundedPreviousHits); + // A single assignment is one hit/event, regardless of how many structure pips it marks. + if (appliedDamage > 0) this.queueMekCriticalChance(loc, { + ...context, + locationDestroyed: boundedCurrentHits >= internalPoints, + consolidateImmediately, + }); } setInternalHits(loc: string, hits: number) { @@ -852,6 +997,8 @@ export class CBTForceUnit extends ForceUnit { this.writeLocationConditions(loc, conditions); if (normalizedCondition === 'blown-off') { this._rules.evaluateLegDestroyed(loc, active ? 1 : -1); + } else if (normalizedCondition === 'flooded') { + this._rules.evaluateLocationFlooded(loc, active); } } @@ -906,11 +1053,19 @@ export class CBTForceUnit extends ForceUnit { const armorLocations = this.locations?.armor; const submerged = this.turnState().submerged(); const partiallyUnderwater = this.turnState().partiallyUnderwater(); - if (this.getUnit().type !== 'Mek' || (!submerged && !partiallyUnderwater) || !internalLocations || !armorLocations) return; + if (this.getUnit().type !== 'Mek' || !internalLocations || !armorLocations) { + this.reviewedFloodLocations.clear(); + return; + } + if (!submerged && !partiallyUnderwater) { + this.reviewedFloodLocations.clear(); + return; + } const submergedLocations = submerged ? Array.from(internalLocations.keys()) : getMekLegLocations(inferMekConfigFromLocations(internalLocations.keys())); + const eligibleLocations: string[] = []; for (const loc of submergedLocations) { if (!internalLocations.has(loc) || this.isInternalLocPhysicallyDestroyed(loc)) continue; // Armor metadata is sparse, so a missing front/rear entry means that facing is exposed. @@ -922,10 +1077,53 @@ export class CBTForceUnit extends ForceUnit { const armorFacings = MEK_REAR_ARMOR_LOCATIONS.has(loc) ? [false, true] : [false]; const armorBreached = armorFacings.some(rear => { const armor = armorByFacing.get(rear); - return !armor || this.getCommittedArmorHits(loc, rear) >= this.getArmorPoints(loc, rear); + const armorHits = commit + ? this.getCommittedArmorHits(loc, rear) + : this.getArmorHits(loc, rear); + return !armor || armorHits >= this.getArmorPoints(loc, rear); }); - if (armorBreached) this.setLocationCondition(loc, 'flooded', true, commit); + if (armorBreached && !this.getLocationCondition(loc, 'flooded')) eligibleLocations.push(loc); + } + + const eligible = new Set(eligibleLocations); + for (const loc of this.reviewedFloodLocations) { + if (!eligible.has(loc)) this.reviewedFloodLocations.delete(loc); } + + const mode = this.automationMode('breachAndFloodCheck'); + if (mode !== 'ask') this.reviewedFloodLocations.clear(); + if (mode === 'yes') { + for (const loc of eligibleLocations) this.setLocationCondition(loc, 'flooded', true, commit); + if (eligibleLocations.length > 0) { + const locations = eligibleLocations + .map(loc => getMekLocationLabel(loc) ?? loc) + .join(', '); + this.injector.get(CBTAutomationToastService).show( + this, + `Breach and flooding: ${locations} flooded`, + 'error', + ); + } + return; + } + if (mode === 'no') return; + + const reviewLocations = eligibleLocations.filter(loc => !this.reviewedFloodLocations.has(loc)); + if (reviewLocations.length === 0) return; + // A review is not pending until a viewer can actually receive it. This keeps + // an unvisited unit retryable when its sheet is opened later. + if (!this.automationTriggers.observed) return; + reviewLocations.forEach(loc => this.reviewedFloodLocations.add(loc)); + this.automationTriggers.next({ + kind: 'breach-and-flood', + id: uuidv7(), + locations: reviewLocations, + commit, + }); + } + + deferUnderwaterBreachAndFloodingReview(locations: readonly string[]): void { + for (const location of locations) this.reviewedFloodLocations.delete(location); } isArmorLocDestroyed(loc: string, rear: boolean = false): boolean { @@ -1064,6 +1262,12 @@ export class CBTForceUnit extends ForceUnit { || (!entry.isRepairing() && this.getEquipmentStatus(entry) === 'destroyed'); } + canTakeActiveActions(): boolean { + return !this.destroyed + && !this.getCondition('shutdown') + && (this.rules.isRemoteDrone() || this.rules.getActivePilotCrewId() !== null); + } + canPerformEquipmentAction(entry: MountedEquipment, action: EquipmentAction): boolean { if (this.hasActiveC3DisruptingStealth() && (entry.equipment?.flags.has('F_BAP') || entry.equipment?.flags.has('F_BLOODHOUND')) @@ -1076,6 +1280,7 @@ export class CBTForceUnit extends ForceUnit { } else if (!this.isEquipmentOperational(entry) || this.destroyed || this.getCondition('shutdown')) { return false; } + if (action !== 'provide-passive-effect' && !this.canTakeActiveActions()) return false; if (action === 'physical-attack' && this.isPhysicalActionUnavailable(entry)) return false; if (action === 'fire' && !this.isInventoryWeaponUsableInWater(entry, this.getInventoryControlSelectedAmmo(entry))) return false; return this.rules.canPerformEquipmentAction(entry, action); @@ -1272,7 +1477,7 @@ export class CBTForceUnit extends ForceUnit { private isPhysicalActionUnavailable(entry: MountedEquipment): boolean { if (!entry.isPhysicalWeapon()) return false; if (this.getCondition('prone')) return true; - const moveMode = this.turnState().moveMode(); + const moveMode = this.turnState().effectiveMoveMode(); if (moveMode === null) return false; // unknown! const attack = entry.name.trim().toLocaleLowerCase(); @@ -1516,8 +1721,9 @@ export class CBTForceUnit extends ForceUnit { this.state.crew.set(crew); // Clear all crits const crits = this.state.crits().map(crit => { - if (crit.destroyed) { + if (crit.destroyed !== undefined || crit.destroyedTurn !== undefined) { crit.destroyed = undefined; + crit.destroyedTurn = undefined; } if (crit.destroying) { crit.destroying = undefined; @@ -1562,6 +1768,9 @@ export class CBTForceUnit extends ForceUnit { }); this.state.inventory.set([...inventory]); this.inventoryControl.markAmmoSourcesChanged(); + this.psrOutcomeSelections.set({}); + this.psrDiceSelections.set({}); + this.pendingMekFallRolls.set({}); this.state.resetTurnState(); this.evaluateDestroyed(); this.setModified(); @@ -1578,11 +1787,26 @@ export class CBTForceUnit extends ForceUnit { } public getAvailableMotiveModes(airborne: boolean): MotiveModeOption[] { - return getMotiveModesOptionsByUnit(this.getUnit(), airborne) + const turnState = this.turnState(); + const unit = this.getUnit(); + const options = getMotiveModesOptionsByUnit(unit, airborne); + for (const mode of ['jump', 'UMU'] satisfies MotiveModes[]) { + if ((mode !== 'jump' || !airborne) + && !options.some(option => option.mode === mode) + && (this._rules.getMaxDistanceForMoveMode(mode) ?? 0) > 0) { + options.push({ mode, label: getMotiveModeLabel(mode, unit, airborne) }); + } + } + const cannotMove = this.getCondition('immobile') || !this.canTakeActiveActions(); + return options + .filter(option => option.mode === 'stationary' || !cannotMove) .filter(option => this._rules.isMotiveModeAvailable(option.mode)) .map(option => ({ ...option, - psr: this._rules.getCommittedDamageMovementModePSRCheck(option.mode) !== null, + psr: this._rules.getCommittedDamageMovementModePSRCheck( + option.mode, + option.mode === turnState.moveMode() ? turnState.moveDistance() : 0, + ) !== null, })); } @@ -1594,7 +1818,10 @@ export class CBTForceUnit extends ForceUnit { endPhase() { this.dispatchBeforeEquipmentStateCommit(); + this.resolvePendingCrewDeaths(); this.state.endPhase(); + this.psrOutcomeSelections.set({}); + this.psrDiceSelections.set({}); this.inventoryControl.markAmmoSourcesChanged(); this.phaseTrigger.update(v => v + 1); // Trigger change detection } @@ -1619,22 +1846,405 @@ export class CBTForceUnit extends ForceUnit { const heat = this.getHeat(); if (heat.next === undefined) return; this.state.consolidateHeat(); - if (!this.useAutomations()) { + if (this.automationMode('heatAndDissipationResolution') === 'no') { this.turnState().settleHeatDissipationDeficit(); } } - private resolveEndTurnHeat(): void { + /** Applies projected end-turn heat without committing or resetting the turn. */ + resolveEndTurnHeat(): void { const projection = this.turnState().heatProjection(); this.setHeat(projection.projected); this.state.consolidateHeat(); this.turnState().acknowledgeHeatSources(projection.consumedDissipation); } - - public endTurn() { + + hasPendingEndTurnHeat(): boolean { + return this.turnState().hasPendingHeatResolution(); + } + + /** + * Sets the crew-damage track from the record sheet. Increasing an eligible + * warrior's damage is still a real pilot hit; decreasing it is a tabletop + * correction and only reconciles already-pending work. + */ + setCrewHits(crewId: number, hits: number): boolean { + const crew = this.getCrewMember(crewId); + if (!crew || !Number.isFinite(hits)) return false; + const nextHits = Math.min(DEAD_CREW_HIT_THRESHOLD, Math.max(0, Math.trunc(hits))); + const currentHits = crew.getHits(); + if (nextHits === currentHits) return false; + + const unitType = this.getUnit().type; + const usesConsciousness = unitType === 'Mek' || unitType === 'ProtoMek' || unitType === 'Aero'; + if (usesConsciousness && nextHits > currentHits) { + return this.applyPilotHits(nextHits - currentHits, undefined, crewId) > 0; + } + + crew.setHits(nextHits); + this.turnState().markPhaseStateChanged(); + this.turnState().refreshPendingUnitCheckTargets(); + return true; + } + + applyPilotHits(hits: number, group?: string, crewId = 0): number { + return this.applyPilotHitsForGroup(hits, group ?? this.turnState().currentPilotDamageGroup(), crewId); + } + + applyLifeSupportDrowningCrewHits(hits: number, group?: string): number { + const immediateGroup = isImmediatePilotDamageGroup(group) + ? group! + : createPilotDamageGroup('immediate', group); + return this.applyCrewHits(hits, immediateGroup); + } + + private applyPilotHitsForGroup( + hits: number, + group: string, + crewId: number, + ): number { + const requestedHits = Number.isFinite(hits) ? Math.max(0, Math.trunc(hits)) : 0; + const crew = this.getCrewMember(crewId); + if (!crew || requestedHits === 0 || !isCrewMemberAboard(crew.getState())) return 0; + + const previousHits = crew.getHits(); + const count = Math.min(requestedHits, DEAD_CREW_HIT_THRESHOLD - previousHits); + if (count === 0) return 0; + const fatal = previousHits + count >= DEAD_CREW_HIT_THRESHOLD; + crew.setHits(previousHits + count); + this.turnState().markPhaseStateChanged(); + if (fatal) { + this.turnState().discardPendingUnitChecks(check => + isConsciousnessSequenceCheck(check) && check.crewId === crewId); + return count; + } + if (crew.getState() !== 'healthy') return count; + if (this.automationMode('pilotHitsAndConsciousnessCheck') === 'no') return count; + + if (this.gameRules.aggregatedEndPhaseConsciousRolls) { + // Core makes one roll for all pilot damage in the phase. Replace + // the pending roll so its target reflects the highest number + // reached by actual damage. + const existing = this.turnState().getPendingUnitChecks().find(check => + isConsciousnessCheck(check) + && check.pilotDamageGroup === group + && check.crewId === crewId); + if (existing) this.turnState().discardPendingUnitCheck(existing.id); + if (this.queueConsciousnessCheck(group, crewId, existing?.id)) { + const actionable = this.turnState().actionablePendingUnitChecks().some(check => + isConsciousnessCheck(check) + && check.pilotDamageGroup === group + && check.crewId === crewId); + if (actionable) this.automationTriggers.next({ kind: 'pending-unit-check' }); + } + return count; + } + + let queued = false; + for (let offset = 1; offset <= count; offset++) { + const target = getConsciousnessTarget(previousHits + offset); + if (target === null) break; + queued = this.turnState().queuePendingUnitCheck({ + id: uuidv7(), + kind: UNIT_CHECK_KIND.CONSCIOUSNESS, + pilotDamageGroup: group, + crewId, + target, + }) || queued; + } + if (queued) this.automationTriggers.next({ kind: 'pending-unit-check' }); + return count; + } + + resolvePendingCrewDeaths(): void { + const pending = this.getCrewMembers().filter(crew => + crew.getHits() >= DEAD_CREW_HIT_THRESHOLD && crew.getState() !== 'dead'); + if (pending.length === 0) return; + pending.forEach(crew => crew.setState('dead')); + if (this.rules.getActivePilotCrewId() === null + && this.getUnit().type === 'Aero' + && this.turnState().airborne() !== false) { + this.setCondition('out-of-control', true); + } + } + + private queueConsciousnessCheck( + group: string, + crewId: number, + id = uuidv7(), + ): boolean { + if (this.automationMode('pilotHitsAndConsciousnessCheck') === 'no') return false; + const target = this.getCrewMember(crewId)?.getConsciousnessTarget(); + if (target === null || target === undefined) return false; + return this.turnState().queuePendingUnitCheck({ + id, + kind: UNIT_CHECK_KIND.CONSCIOUSNESS, + pilotDamageGroup: group, + crewId, + target, + }); + } + + setCrewState( + crewId: number, + state: Exclude, + recoveryDelay = 1, + ): boolean { + const crew = this.getCrewMember(crewId); + if (!crew || crew.getState() === 'dead' || crew.getState() === state) return false; + + crew.setState(state); + this.turnState().markPhaseStateChanged(); + if (state === 'unconscious') { + this.turnState().discardPendingUnitChecks(check => + isConsciousnessCheck(check) && check.crewId === crewId); + this.queueConsciousnessRecovery(crewId, recoveryDelay); + } else { + this.turnState().discardPendingUnitChecks(check => + isConsciousnessSequenceCheck(check) && check.crewId === crewId); + } + return true; + } + + queueConsciousnessRecovery( + crewId: number, + delay: number, + replacingCheckId?: string, + ): boolean { + if (this.automationMode('pilotHitsAndConsciousnessCheck') === 'no') return false; + const crew = this.getCrewMember(crewId); + const target = crew?.getConsciousnessTarget(); + if (!crew || crew.getState() !== 'unconscious' || target === null) return false; + if (this.turnState().getPendingUnitChecks().some(check => + check.id !== replacingCheckId + && isConsciousnessRecoveryCheck(check) + && check.crewId === crewId)) return false; + + return this.turnState().queuePendingUnitCheck({ + id: uuidv7(), + kind: UNIT_CHECK_KIND.CONSCIOUSNESS_RECOVERY, + crewId, + target, + readyTurn: this.turnState().getTurnCounter() + Math.max(1, Math.trunc(delay)), + }); + } + + applyHeatCrewHits(hits: number, group?: string): number { + const heatGroup = isHeatPilotDamageGroup(group) + ? group! + : createPilotDamageGroup('heat', group); + return this.applyCrewHits(hits, heatGroup); + } + + /** One damaging head hit injures every crew member still aboard the unit. */ + applyHeadHitCrewHits(group?: string): number { + return this.applyCrewHits(this.rules.headHitPilotHits(), group); + } + + /** Internal explosions injure every crew member still aboard the unit. */ + applyInternalExplosionCrewHits(hits: number, group?: string): number { + return this.applyCrewHits(hits, group); + } + + private applyCrewHits(hits: number, group?: string): number { + return this.getCrewMembers().reduce( + (total, crew) => total + this.applyPilotHits(hits, group, crew.getId()), + 0, + ); + } + + private createFallSeatbeltChecks(levelsFallen: number): PendingEventInput[] { + if (this.automationMode('pilotHitsAndConsciousnessCheck') === 'no') return []; + const normalizedLevels = Number.isFinite(levelsFallen) + ? Math.max(0, Math.trunc(levelsFallen)) + : 0; + const levelModifier = this.gameRules.id === 'core2026' + ? normalizedLevels + : Math.max(0, normalizedLevels - 1); + const psr = this.PSRModifiers(); + const modifierTotal = levelModifier + (this.gameRules.id === 'core2026' + ? 0 + : psr.modifiers.reduce((total, modifier) => total + (modifier.pilotCheck ?? 0), 0)); + const group = this.turnState().currentPilotDamageGroup(); + const checks: PendingEventInput[] = []; + for (const crew of this.getCrewMembers()) { + const crewState = crew.getState(); + if (!isCrewMemberAboard(crewState)) continue; + + const target = crew.getSkill('piloting') + modifierTotal; + const automaticFailure = !isCrewMemberAvailable(crewState) + || this.getCondition('shutdown') + || this.getCondition('immobile') + || target > 12; + checks.push({ + id: uuidv7(), + kind: UNIT_CHECK_KIND.SEATBELT, + pilotDamageGroup: group, + crewId: crew.getId(), + ...(automaticFailure + ? { result: { kind: 'automatic' as const, outcome: 'failed' as const } } + : { target }), + }); + } + return checks; + } + + getPendingFalls(): readonly CBTPendingMekFall[] { + const drafts = this.pendingMekFallRolls(); + return this.turnState().getPendingFalls().map(pending => ({ + ...pending, + orientationRoll: drafts[pending.id]?.orientationRoll ?? null, + orientationDice: drafts[pending.id]?.orientationDice ?? null, + damageRolls: drafts[pending.id]?.damageRolls ?? [], + })); + } + + getPendingFall(id?: string): CBTPendingMekFall | undefined { + const pending = this.turnState().getPendingFall(id); + if (!pending) return undefined; + const draft = this.pendingMekFallRolls()[pending.id]; + return { + ...pending, + orientationRoll: draft?.orientationRoll ?? null, + orientationDice: draft?.orientationDice ?? null, + damageRolls: draft?.damageRolls ?? [], + }; + } + + setPendingFallRolls( + id: string, + orientationRoll: number | null, + damageRolls: readonly CBTMekFallDamageRoll[], + orientationDice: readonly number[] | null = null, + ): boolean { + const pending = this.getPendingFall(id); + if (!pending) return false; + const normalizedOrientation = orientationRoll !== null + && Number.isInteger(orientationRoll) + && orientationRoll >= 1 + && orientationRoll <= 6 + ? orientationRoll + : null; + const normalizedOrientationDice = normalizedOrientation !== null + ? normalizeD6Faces(orientationDice, 1) + : null; + const matchingOrientationDice = normalizedOrientationDice?.[0] === normalizedOrientation + ? normalizedOrientationDice as readonly [number] + : null; + const normalizedDamageRolls = damageRolls.map(roll => { + const hitLocationRoll = roll.hitLocationRoll !== null + && Number.isInteger(roll.hitLocationRoll) + && roll.hitLocationRoll >= 2 + && roll.hitLocationRoll <= 12 + ? roll.hitLocationRoll + : null; + const tripodLegRoll = roll.tripodLegRoll !== null + && Number.isInteger(roll.tripodLegRoll) + && roll.tripodLegRoll >= 1 + && roll.tripodLegRoll <= 6 + ? roll.tripodLegRoll + : null; + const hitLocationDice = hitLocationRoll !== null + ? normalizeD6Faces(roll.hitLocationDice ?? null, 2) + : null; + const tripodLegDice = tripodLegRoll !== null + ? normalizeD6Faces(roll.tripodLegDice ?? null, 1) + : null; + return { + hitLocationRoll, + ...(hitLocationDice && hitLocationDice[0] + hitLocationDice[1] === hitLocationRoll + ? { hitLocationDice: hitLocationDice as readonly [number, number] } + : {}), + tripodLegRoll, + ...(tripodLegDice?.[0] === tripodLegRoll + ? { tripodLegDice: tripodLegDice as readonly [number] } + : {}), + }; + }); + this.pendingMekFallRolls.update(current => ({ + ...current, + [id]: { + orientationRoll: normalizedOrientation, + orientationDice: matchingOrientationDice, + damageRolls: normalizedDamageRolls, + }, + })); + return true; + } + + /** + * Completes one actual fall and only then releases its seatbelt check. + * Closing the fall dialog deliberately does not call this method. + */ + completePendingFall(id: string): boolean { + const pending = this.getPendingFall(id); + if (!pending) return false; + const checks = this.createFallSeatbeltChecks(pending.levelsFallen); + const completed = this.turnState().replacePendingFallWithUnitChecks(id, checks); + if (!completed) return false; + this.pendingMekFallRolls.update(current => { + const { [id]: _completed, ...remaining } = current; + return remaining; + }); + if (checks.length > 0) this.automationTriggers.next({ kind: 'pending-unit-check' }); + return true; + } + + /** Removes automation work without treating the fall as resolved. */ + skipPendingFall(id: string): boolean { + if (!this.turnState().discardPendingFall(id)) return false; + this.pendingMekFallRolls.update(current => { + const { [id]: _skipped, ...remaining } = current; + return remaining; + }); + return true; + } + + /** + * Starts a pending falling workflow. A prone Mek cannot fall from + * another PSR, while a failed stand attempt is still a fall. Manually + * toggling prone remains a state-only override. + */ + queueFall(source: CBTMekFallSource, levelsFallen = 0): boolean { + if (source === 'psr' && this.getCondition('prone')) return false; + if (this.automationMode('fallingCheck') === 'no') return false; + const normalizedLevels = Number.isFinite(levelsFallen) + ? Math.max(0, Math.trunc(levelsFallen)) + : 0; + const pending: PendingEventInput = { + id: uuidv7(), + source, + levelsFallen: normalizedLevels, + }; + if (!this.turnState().queuePendingFall(pending)) return false; + this.automationTriggers.next({ + kind: 'falling', + id: pending.id, + source: pending.source, + levelsFallen: pending.levelsFallen, + }); + return true; + } + + public endTurn(automationDecisions: CBTEndTurnAutomationDecisions = {}) { const endsForceTurn = !this.force.units().some(unit => unit !== this && unit.turnState().dirty()); - if (this.useAutomations() && (this.getHeat().next !== undefined || this.turnState().hasPendingHeatResolution())) { + const heatAutomationMode = this.automationMode('heatAndDissipationResolution'); + const resolveHeat = heatAutomationMode === 'yes' + ? automationDecisions.heatAndDissipationResolution !== false + : heatAutomationMode === 'ask' && automationDecisions.heatAndDissipationResolution === true; + if (resolveHeat && this.hasPendingEndTurnHeat()) { + const previousHeat = this.getHeat().current; this.resolveEndTurnHeat(); + if (heatAutomationMode === 'yes') { + this.injector.get(CBTAutomationToastService).show( + this, + `Heat and dissipation: Heat ${previousHeat} → ${this.getHeat().current}`, + 'info', + ); + } + } else if (heatAutomationMode !== 'no' && this.getHeat().next !== undefined) { + // A manual arrow is only committed by APPLY HEAT while automation is active. + this.setHeatData({ ...this.getHeat(), next: undefined }); } this.clearInventoryControlSelection(); // deselect all inventory items @@ -1649,11 +2259,14 @@ export class CBTForceUnit extends ForceUnit { const equipmentRegistry = this.injector.get(EquipmentInteractionRegistryService).getRegistry(); const notifications = this.injector.get(ToastService); this.forEachCurrentInventoryEntry(entry => equipmentRegistry.onEndTurn(entry, notifications)); - this.state.endTurn(); + this.resolvePendingCrewDeaths(); + this.state.endTurn(automationDecisions.phaseAlreadyEnded === true); if (endsForceTurn) this.force.clearExpiredManualTargetTags(this); this.inventoryControl.markAmmoSourcesChanged(); this.phaseTrigger.update(v => v + 1); // Trigger change detection - this.state.resetTurnState(); + this.psrOutcomeSelections.set({}); + this.psrDiceSelections.set({}); + this.state.resetTurnState(this.turnState().getTurnCounter() + 1, true); } private _hasDirectInventory: boolean | null = null; diff --git a/src/app/models/crew-member.model.ts b/src/app/models/crew-member.model.ts index fe1e7fd26..0b90818a7 100644 --- a/src/app/models/crew-member.model.ts +++ b/src/app/models/crew-member.model.ts @@ -28,7 +28,17 @@ export function getConsciousnessHitCount(target: number): number | null { export type SkillType = 'gunnery' | 'piloting'; export type CrewMemberState = 'healthy' | 'ejected' | 'unconscious' | 'dead' | 'killed' | 'stunned'; -type StoredCrewMemberState = Exclude; +type StoredCrewMemberState = CrewMemberState; + +/** Crew who can currently operate the unit or make a skill check. */ +export function isCrewMemberAvailable(state: CrewMemberState): boolean { + return state === 'healthy'; +} + +/** Crew still present in the unit and affected by unit-wide damage or fall checks. */ +export function isCrewMemberAboard(state: CrewMemberState): boolean { + return state !== 'ejected' && state !== 'dead' && state !== 'killed'; +} export interface CrewMemberDetails { id: number; @@ -73,7 +83,7 @@ export class CrewMember { } isDead(): boolean { - return this.hits >= DEAD_CREW_HIT_THRESHOLD || this.unit.rules.isCrewCockpitDestroyed(this.getId()); + return this.state === 'dead' || this.unit.rules.isCrewCockpitDestroyed(this.getId()); } isCrippled(): boolean { @@ -88,6 +98,7 @@ export class CrewMember { } setState(state: StoredCrewMemberState) { + if (this.isDead() && state !== 'dead') return; if (this.state === state) return; this.state = state; this.unit.setCrewMember(this.id, this); @@ -146,6 +157,7 @@ export class CrewMember { const normalized = normalizeCrewHits(hits); if (normalized === this.hits) return; this.hits = normalized; + if (normalized < DEAD_CREW_HIT_THRESHOLD && this.state === 'dead') this.state = 'healthy'; this.unit.setCrewMember(this.id, this); this.unit.setModified(); } @@ -190,15 +202,18 @@ export class CrewMember { if (data.asfGunnerySkill !== this.asfGunnerySkill) this.asfGunnerySkill = data.asfGunnerySkill; if (data.asfPilotingSkill !== this.asfPilotingSkill) this.asfPilotingSkill = data.asfPilotingSkill; const hits = normalizeCrewHits(data.hits); - if (hits !== this.hits) this.hits = hits; + if (hits !== this.hits) { + this.hits = hits; + if (hits < DEAD_CREW_HIT_THRESHOLD && this.state === 'dead') this.state = 'healthy'; + } const newState = CrewMember.deserializeStoredState(data.state, this.unit); - if (newState !== this.state) this.state = newState; + if ((!this.isDead() || newState === 'dead') && newState !== this.state) this.state = newState; } private static deserializeStoredState(state: number, unit: CBTForceUnit): StoredCrewMemberState { if (state === 1) return 'unconscious'; - // 'dead' (2) is excluded, we derive it + if (state === 2) return 'dead'; if (state === 3) return 'ejected'; if (state === 4) return 'killed'; if (state === 5) return 'stunned'; @@ -207,7 +222,7 @@ export class CrewMember { private serializeState(): number { if (this.state === 'unconscious') return 1; - // 'dead' (2) is excluded, we derive it + if (this.state === 'dead') return 2; if (this.state === 'ejected') return 3; if (this.state === 'killed') return 4; if (this.state === 'stunned') return 5; diff --git a/src/app/models/force-serialization.spec.ts b/src/app/models/force-serialization.spec.ts index b4076241c..99b753b80 100644 --- a/src/app/models/force-serialization.spec.ts +++ b/src/app/models/force-serialization.spec.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { CBT_SERIALIZED_STATE_SCHEMA, C3_NETWORK_GROUP_SCHEMA, FORCE_TAG_MAX_COUNT, HEAT_SCHEMA, sanitizeForceTagLabels, sanitizeForceTags, TURN_STATE_SCHEMA } from './force-serialization'; +import { AS_SERIALIZED_GROUP_SCHEMA, CBT_SERIALIZED_GROUP_SCHEMA, CBT_SERIALIZED_STATE_SCHEMA, C3_NETWORK_GROUP_SCHEMA, CRIT_SLOT_SCHEMA, FORCE_TAG_MAX_COUNT, HEAT_SCHEMA, sanitizeForceTagLabels, sanitizeForceTags, TURN_STATE_SCHEMA } from './force-serialization'; import { Sanitizer } from '../utils/sanitizer.util'; import { C3NetworkType } from './c3-network.model'; @@ -44,6 +44,21 @@ describe('C3 network serialization compatibility', () => { }); }); +describe('formation target serialization', () => { + it('preserves a string target id in both game-system group schemas', () => { + const group = { id: 'support', formationTargetGroupId: 'target', units: [] }; + + expect(Sanitizer.sanitize(group, CBT_SERIALIZED_GROUP_SCHEMA).formationTargetGroupId).toBe('target'); + expect(Sanitizer.sanitize(group, AS_SERIALIZED_GROUP_SCHEMA).formationTargetGroupId).toBe('target'); + }); + + it('drops malformed target ids without changing the schema version', () => { + const group = { id: 'support', formationTargetGroupId: 7, units: [] }; + + expect(Sanitizer.sanitize(group, AS_SERIALIZED_GROUP_SCHEMA).formationTargetGroupId).toBeUndefined(); + }); +}); + describe('force tag sanitization', () => { const manyTags = [ '11', '12', '123', '13', '133', '14', '15', '16', '17', '18', '19', '233', @@ -95,6 +110,31 @@ describe('heat state sanitization', () => { }); }); + it('sanitizes turn chronology as non-negative integer counters', () => { + expect(Sanitizer.sanitize({ turnCounter: 4.9 }, TURN_STATE_SCHEMA)) + .toEqual({ turnCounter: 4 }); + expect(Sanitizer.sanitize({ turnCounter: -2 }, TURN_STATE_SCHEMA)) + .toEqual({ turnCounter: 0 }); + expect(Sanitizer.sanitize({ turnCounter: Number.NaN }, TURN_STATE_SCHEMA)) + .toEqual({}); + + expect(Sanitizer.sanitize({ id: 'crit', destroyedTurn: 7.8 }, CRIT_SLOT_SCHEMA)) + .toEqual({ id: 'crit', destroyedTurn: 7 }); + expect(Sanitizer.sanitize({ id: 'crit', destroyedTurn: Number.POSITIVE_INFINITY }, CRIT_SLOT_SCHEMA)) + .toEqual({ id: 'crit' }); + }); + + it('accepts only resumable end-turn checkpoints', () => { + expect(Sanitizer.sanitize({ endTurnCheckpoint: 'phase-ended' }, TURN_STATE_SCHEMA)) + .toEqual({ endTurnCheckpoint: 'phase-ended' }); + expect(Sanitizer.sanitize({ endTurnCheckpoint: 'heat-staged' }, TURN_STATE_SCHEMA)) + .toEqual({ endTurnCheckpoint: 'heat-staged' }); + expect(Sanitizer.sanitize({ endTurnCheckpoint: 'complete' }, TURN_STATE_SCHEMA)) + .toEqual({}); + expect(Sanitizer.sanitize({ endTurnCheckpoint: true }, TURN_STATE_SCHEMA)) + .toEqual({}); + }); + it('sanitizes consumed heat dissipation as a non-negative finite number', () => { expect(Sanitizer.sanitize({ heatDissipationConsumed: '6' }, TURN_STATE_SCHEMA)).toEqual({ heatDissipationConsumed: 6, @@ -149,6 +189,217 @@ describe('heat state sanitization', () => { psrOutcomes: { first: 'success', second: 'failed' }, }); }); + + it('sanitizes one ordered pending-event queue with strict kind-specific payloads', () => { + expect(Sanitizer.sanitize({ + pendingEvents: [ + { + type: 'unit-check', + id: ' check:1 ', + kind: 'consciousness', + pilotDamageGroup: ' P ', + crewId: 2, + target: 7, + result: { kind: 'roll', dice: [3, 2] }, + description: 'not persisted', + }, + { + type: 'unit-check', + id: 'check:2', + kind: 'heat-life-support', + result: { kind: 'automatic', outcome: 'failed' }, + hits: 2, + }, + { + type: 'unit-check', + id: 'check:restart', + kind: 'shutdown-recovery', + target: 6, + result: { kind: 'roll', dice: [3, 4] }, + }, + { + type: 'mek-fall', + id: 'fall:1', + source: 'stand-attempt', + levelsFallen: 1, + }, + { + type: 'mek-critical-chance', + id: 'chance:1', + location: ' CT ', + consolidateImmediately: true, + explosionProtection: 'case-ii', + hardenedArmorApplies: false, + throughArmorHitArc: 'rear', + roll: [5, 5], + result: 2, + pilotDamageGroup: ' turn-closed:immediate:end-turn:heat ', + }, + { + type: 'mek-critical-hit', + id: 'critical:1', + location: ' LT ', + targetLocation: ' CT ', + remainingHits: 2, + locationDestroyed: true, + chanceOrigin: { + explosionProtection: 'case', + hardenedArmorApplies: true, + }, + caseII: { status: 'passed' }, + roll: [3, 4], + }, + { + type: 'unit-check', + id: 'check:3', + kind: 'seatbelt', + crewId: 0, + target: 5, + }, + { + type: 'mek-critical-hit', + id: 'critical:floating', + location: 'RT', + targetLocation: 'RT', + remainingHits: 1, + chanceOrigin: { throughArmorHitArc: 'right' }, + floatingLocation: { + hitArc: 'right', + locationRoll: 9, + dice: [4, 5], + }, + }, + { type: 'unit-check', id: 'check:1', kind: 'heat-shutdown', target: 5 }, + { type: 'unknown', id: 'unknown:1' }, + ], + }, TURN_STATE_SCHEMA)).toEqual({ + pendingEvents: [ + { + type: 'unit-check', + id: 'check:1', + kind: 'consciousness', + pilotDamageGroup: 'P', + crewId: 2, + target: 7, + result: { kind: 'roll', dice: [3, 2] }, + }, + { + type: 'unit-check', + id: 'check:2', + kind: 'heat-life-support', + result: { kind: 'automatic', outcome: 'failed' }, + hits: 2, + }, + { + type: 'unit-check', + id: 'check:restart', + kind: 'shutdown-recovery', + target: 6, + result: { kind: 'roll', dice: [3, 4] }, + }, + { type: 'mek-fall', id: 'fall:1', source: 'stand-attempt', levelsFallen: 1 }, + { + type: 'mek-critical-chance', + id: 'chance:1', + location: 'CT', + consolidateImmediately: true, + explosionProtection: 'case-ii', + hardenedArmorApplies: false, + throughArmorHitArc: 'rear', + roll: [5, 5], + result: 2, + pilotDamageGroup: 'turn-closed:immediate:end-turn:heat', + }, + { + type: 'mek-critical-hit', + id: 'critical:1', + location: 'LT', + targetLocation: 'CT', + remainingHits: 2, + locationDestroyed: true, + chanceOrigin: { + explosionProtection: 'case', + hardenedArmorApplies: true, + }, + caseII: { status: 'passed' }, + roll: [3, 4], + }, + { + type: 'unit-check', + id: 'check:3', + kind: 'seatbelt', + crewId: 0, + target: 5, + }, + { + type: 'mek-critical-hit', + id: 'critical:floating', + location: 'RT', + targetLocation: 'RT', + remainingHits: 1, + chanceOrigin: { throughArmorHitArc: 'right' }, + floatingLocation: { + hitArc: 'right', + locationRoll: 9, + dice: [4, 5], + }, + }, + ], + }); + }); + + it('rejects malformed events atomically and ignores removed split-array APIs', () => { + expect(Sanitizer.sanitize({ + pendingEvents: [ + { type: 'unit-check', id: 'bad:1', kind: 'seatbelt', target: 5 }, + { type: 'unit-check', id: 'bad:2', kind: 'heat-shutdown', result: { kind: 'manual', outcome: 'failed' } }, + { type: 'mek-critical-hit', id: 'bad:3', location: 'CT', targetLocation: 'CT', remainingHits: 0 }, + { + type: 'mek-critical-hit', + id: 'bad:origin', + location: 'CT', + targetLocation: 'CT', + remainingHits: 1, + chanceOrigin: { hardenedArmorApplies: 'yes' }, + }, + { + type: 'mek-critical-hit', + id: 'bad:floating', + location: 'RT', + targetLocation: 'RT', + remainingHits: 1, + floatingLocation: { hitArc: 'right', locationRoll: 9, dice: [6, 6] }, + }, + { type: 'mek-critical-chance', id: 'bad:4', location: 'CT', result: 5 }, + { type: 'mek-fall', id: 'bad:5', source: 'manual', levelsFallen: 0 }, + ], + pendingUnitChecks: [{ id: 'legacy:1' }], + pendingCriticals: [{ id: 'legacy:2' }], + pendingCriticalChances: [{ id: 'legacy:3' }], + }, TURN_STATE_SCHEMA)).toEqual({}); + }); + + it('preserves an empty critical-chance origin because its presence is the undo marker', () => { + expect(Sanitizer.sanitize({ + pendingEvents: [{ + type: 'mek-critical-hit', + id: 'critical:undo', + location: 'CT', + targetLocation: 'CT', + remainingHits: 1, + chanceOrigin: {}, + }], + }, TURN_STATE_SCHEMA)).toEqual({ + pendingEvents: [{ + type: 'mek-critical-hit', + id: 'critical:undo', + location: 'CT', + targetLocation: 'CT', + remainingHits: 1, + chanceOrigin: {}, + }], + }); + }); }); describe('rule check sanitization', () => { diff --git a/src/app/models/force-serialization.ts b/src/app/models/force-serialization.ts index ab699fffa..eaa2c4576 100644 --- a/src/app/models/force-serialization.ts +++ b/src/app/models/force-serialization.ts @@ -10,6 +10,22 @@ import type { C3NetworkType } from './c3-network.model'; import type { MotiveModes } from './motiveModes.model'; import { DEFAULT_GUNNERY_SKILL, DEFAULT_PILOTING_SKILL } from './crew-member.model'; import { deserializeUnitCover, serializeUnitCover, type SerializedUnitCover } from './unit-cover.model'; +import type { MekExplosionProtection } from './rules/game-rules'; +import { + PENDING_UNIT_CHECK_KINDS, + UNIT_CHECK_CAUSE, + UNIT_CHECK_KIND, + type PendingUnitCheckKind, + type UnitCheckCause, +} from './unit-check.model'; + +export { + PENDING_UNIT_CHECK_KINDS, + UNIT_CHECK_CAUSE, + UNIT_CHECK_KIND, + type PendingUnitCheckKind, + type UnitCheckCause, +} from './unit-check.model'; export const FORCE_NOTE_MAX_LENGTH = 2000; export const FORCE_TAG_MAX_LENGTH = 48; @@ -79,11 +95,178 @@ export interface SerializedPSRChecks { shutdown?: boolean; } +export type SerializedMekCriticalChanceResult = 'none' | 'blown-off' | 1 | 2 | 3 | 4; + +interface SerializedPendingEventBase { + readonly id: string; +} + +interface SerializedPendingMekCriticalBase extends SerializedPendingEventBase { + readonly location: string; + readonly locationDestroyed?: true; + readonly consolidateImmediately?: true; + /** Preserves the originating pilot-damage event across a paused critical chain. */ + readonly pilotDamageGroup?: string; +} + +export type MekHitArc = 'front' | 'rear' | 'left' | 'right'; + +/** One unresolved Mek critical-chance stage. */ +export interface SerializedPendingMekCriticalChance extends SerializedPendingMekCriticalBase { + readonly type: 'mek-critical-chance'; + readonly explosionProtection?: MekExplosionProtection; + readonly hardenedArmorApplies?: boolean; + /** Marks a through-armor result that may use the Floating Critical optional rule. */ + readonly throughArmorHitArc?: MekHitArc; + /** Exact virtual dice retained while the rolled result is awaiting confirmation. */ + readonly roll?: readonly [number, number]; + /** Exact choice retained when the dialog is closed before it is applied. */ + readonly result?: SerializedMekCriticalChanceResult; +} + +/** Chance-only facts retained until the first critical hit is committed, so the choice can be corrected. */ +export interface SerializedPendingMekCriticalChanceOrigin { + readonly explosionProtection?: MekExplosionProtection; + readonly hardenedArmorApplies?: boolean; + readonly throughArmorHitArc?: MekHitArc; +} + +/** Persisted location-table choice awaiting confirmation before slot resolution. */ +export interface SerializedPendingMekFloatingCriticalLocation { + readonly hitArc: MekHitArc; + readonly locationRoll?: number; + readonly dice?: readonly [number, number]; + readonly tripodLegRoll?: number; +} + +export type SerializedPendingMekCriticalCaseII = + | { + readonly status: 'pending'; + readonly result?: 'resolve' | 'discard'; + readonly roll?: readonly [number, number]; + } + | { readonly status: 'passed' }; + +/** One unresolved Mek critical-hit stage. */ +export interface SerializedPendingMekCritical extends SerializedPendingMekCriticalBase { + readonly type: 'mek-critical-hit'; + readonly targetLocation: string; + readonly remainingHits: number; + /** Presence, including an empty object, means this untouched hit stage can return to its chance stage. */ + readonly chanceOrigin?: SerializedPendingMekCriticalChanceOrigin; + /** Presence means a Floating Critical location must be selected before rolling a slot. */ + readonly floatingLocation?: SerializedPendingMekFloatingCriticalLocation; + readonly caseII?: SerializedPendingMekCriticalCaseII; + /** Exact unresolved slot roll retained while its review is paused. */ + readonly roll?: readonly number[]; +} + +export type CBTMekFallSource = 'psr' | 'stand-attempt'; + +/** A fall remains queued until its damage is accepted or explicitly ignored. */ +export interface SerializedPendingMekFall extends SerializedPendingEventBase { + readonly type: 'mek-fall'; + readonly source: CBTMekFallSource; + readonly levelsFallen: number; +} + +export type SerializedPendingCheckResult = + | { readonly kind: 'manual'; readonly outcome: RuleCheckOutcome } + | { readonly kind: 'automatic'; readonly outcome: RuleCheckOutcome } + | { readonly kind: 'roll'; readonly dice: readonly [number, number] }; + +type SerializedPendingCheckResolution = + | { + readonly target: number; + readonly result?: SerializedPendingCheckResult; + } + | { + readonly target?: never; + readonly result: Extract; + }; + +interface SerializedPendingUnitCheckBase extends SerializedPendingEventBase { + readonly type: 'unit-check'; + readonly pilotDamageGroup?: string; +} + +type SerializedPendingBasicUnitCheckKind = + | typeof UNIT_CHECK_KIND.HEAT_SHUTDOWN + | typeof UNIT_CHECK_KIND.SHUTDOWN_RECOVERY + | typeof UNIT_CHECK_KIND.HEAT_RANDOM_MOVEMENT; +type SerializedPendingBasicUnitCheck = { + [K in SerializedPendingBasicUnitCheckKind]: SerializedPendingUnitCheckBase + & SerializedPendingCheckResolution + & { readonly kind: K }; +}[SerializedPendingBasicUnitCheckKind]; + +type SerializedPendingAmmoExplosionCheck = SerializedPendingUnitCheckBase & SerializedPendingCheckResolution & { + readonly kind: typeof UNIT_CHECK_KIND.HEAT_AMMO_EXPLOSION; + readonly selectionId?: string; +}; + +type SerializedPendingPilotDamageCheckKind = + | typeof UNIT_CHECK_KIND.HEAT_PILOT_DAMAGE + | typeof UNIT_CHECK_KIND.HEAT_LIFE_SUPPORT + | typeof UNIT_CHECK_KIND.LIFE_SUPPORT_DROWNING; +type SerializedPendingPilotDamageCheck = { + [K in SerializedPendingPilotDamageCheckKind]: SerializedPendingUnitCheckBase + & SerializedPendingCheckResolution + & { readonly kind: K; readonly hits: number }; +}[SerializedPendingPilotDamageCheckKind]; + +type SerializedPendingAeroRecoveryCheck = SerializedPendingUnitCheckBase & SerializedPendingCheckResolution & { + readonly kind: typeof UNIT_CHECK_KIND.AERO_CONTROL_RECOVERY; + readonly readyTurn: number; + readonly cause?: UnitCheckCause; +}; + +type SerializedPendingSeatbeltCheck = SerializedPendingUnitCheckBase & SerializedPendingCheckResolution & { + readonly kind: typeof UNIT_CHECK_KIND.SEATBELT; + readonly crewId: number; +}; + +type SerializedPendingConsciousnessCheck = SerializedPendingUnitCheckBase & SerializedPendingCheckResolution & { + readonly kind: typeof UNIT_CHECK_KIND.CONSCIOUSNESS; + readonly pilotDamageGroup: string; + readonly crewId: number; +}; + +type SerializedPendingConsciousnessRecoveryCheck = SerializedPendingUnitCheckBase & SerializedPendingCheckResolution & { + readonly kind: typeof UNIT_CHECK_KIND.CONSCIOUSNESS_RECOVERY; + readonly crewId: number; + readonly readyTurn: number; +}; + +/** Stable, kind-specific facts needed to resume a pending check. */ +export type SerializedPendingUnitCheck = + | SerializedPendingBasicUnitCheck + | SerializedPendingAmmoExplosionCheck + | SerializedPendingPilotDamageCheck + | SerializedPendingAeroRecoveryCheck + | SerializedPendingSeatbeltCheck + | SerializedPendingConsciousnessCheck + | SerializedPendingConsciousnessRecoveryCheck; + +export type SerializedPendingEvent = + | SerializedPendingMekCriticalChance + | SerializedPendingMekCritical + | SerializedPendingMekFall + | SerializedPendingUnitCheck; + +export type PendingEventInput = + T extends SerializedPendingEvent ? Omit : never; + +export type SerializedEndTurnCheckpoint = 'phase-ended' | 'heat-staged'; + export interface SerializedTurnState { + turnCounter?: number; + endTurnCheckpoint?: SerializedEndTurnCheckpoint; airborne?: boolean; moveMode?: MotiveModes; moveDistance?: number; standAttempts?: number; + carefulStand?: boolean; cover?: SerializedUnitCover; dmgReceived?: number; weaponsHeat?: number; @@ -91,6 +274,7 @@ export interface SerializedTurnState { heatDissipationConsumed?: number; psrOutcomes?: Record; psrChecks?: SerializedPSRChecks; + pendingEvents?: SerializedPendingEvent[]; applyMovePSR?: boolean; spotting?: boolean; equipmentStateChanged?: boolean; @@ -129,6 +313,8 @@ export interface SerializedGroup { color?: string; formationId?: string; formationLock?: boolean; + /** ID of another group whose formation bonus is copied by this group. */ + formationTargetGroupId?: string; units: SerializedUnit[]; } @@ -373,6 +559,7 @@ export interface CriticalSlot { consumed?: number; // If is an ammo slot: how much ammo have been consumed. If is a F_MODULAR_ARMOR, is the armor points used destroying?: number; // If this location is in the process of being destroyed. Contains the timestamp of when the destruction started destroyed?: number; // If this location is destroyed (can be from 0 hits if the structure is completely destroyed). Contains the timestamp of the destruction + destroyedTurn?: number; // Per-unit turn counter when this slot was destroyed originalName?: string; // saved original name in case we override the current name armored?: boolean; // If this critical slot is armored (for locations that can be armored) el?: SVGElement; @@ -436,11 +623,290 @@ function sanitizePSRChecks(value: unknown): SerializedPSRChecks | undefined { return Object.keys(checks).length > 0 ? checks : undefined; } +function sanitizePendingString(value: unknown, maxLength: number): string | undefined { + if (typeof value !== 'string') return undefined; + const normalized = value.trim(); + return normalized.length > 0 && normalized.length <= maxLength ? normalized : undefined; +} + +function sanitizePendingInteger(value: unknown, min: number, max: number): number | undefined { + return typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max + ? value + : undefined; +} + +function sanitizeD6Roll(value: unknown, lengths: readonly number[]): readonly number[] | undefined { + return Array.isArray(value) + && lengths.includes(value.length) + && value.every(die => Number.isInteger(die) && die >= 1 && die <= 6) + ? [...value] as number[] + : undefined; +} + +function sanitizePendingCriticalBase(record: Record): SerializedPendingMekCriticalBase | null { + const id = sanitizePendingString(record['id'], 256); + const location = sanitizePendingString(record['location'], 32); + if (!id || !location) return null; + const pilotDamageGroup = sanitizePendingString(record['pilotDamageGroup'], 80); + return { + id, + location, + ...(record['locationDestroyed'] === true ? { locationDestroyed: true } : {}), + ...(record['consolidateImmediately'] === true ? { consolidateImmediately: true } : {}), + ...(pilotDamageGroup ? { pilotDamageGroup } : {}), + }; +} + +function sanitizePendingCriticalChanceFacts( + record: Record, +): SerializedPendingMekCriticalChanceOrigin | null { + const explosionProtection = record['explosionProtection']; + if (explosionProtection !== undefined + && explosionProtection !== 'none' + && explosionProtection !== 'case' + && explosionProtection !== 'case-ii') return null; + if (record['hardenedArmorApplies'] !== undefined + && typeof record['hardenedArmorApplies'] !== 'boolean') return null; + const throughArmorHitArc = record['throughArmorHitArc']; + if (throughArmorHitArc !== undefined + && throughArmorHitArc !== 'front' + && throughArmorHitArc !== 'rear' + && throughArmorHitArc !== 'left' + && throughArmorHitArc !== 'right') return null; + return { + ...(explosionProtection !== undefined ? { explosionProtection } : {}), + ...(typeof record['hardenedArmorApplies'] === 'boolean' + ? { hardenedArmorApplies: record['hardenedArmorApplies'] } + : {}), + ...(throughArmorHitArc !== undefined ? { throughArmorHitArc } : {}), + }; +} + +function sanitizePendingFloatingCriticalLocation( + value: unknown, +): SerializedPendingMekFloatingCriticalLocation | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record; + const hitArc = record['hitArc']; + if (hitArc !== 'front' && hitArc !== 'rear' && hitArc !== 'left' && hitArc !== 'right') return null; + const locationRoll = record['locationRoll'] === undefined + ? undefined + : sanitizePendingInteger(record['locationRoll'], 2, 12); + if (record['locationRoll'] !== undefined && locationRoll === undefined) return null; + const dice = record['dice'] === undefined ? undefined : sanitizeD6Roll(record['dice'], [2]); + if (record['dice'] !== undefined && !dice) return null; + if (dice && locationRoll !== dice[0] + dice[1]) return null; + const tripodLegRoll = record['tripodLegRoll'] === undefined + ? undefined + : sanitizePendingInteger(record['tripodLegRoll'], 1, 6); + if (record['tripodLegRoll'] !== undefined && tripodLegRoll === undefined) return null; + return { + hitArc, + ...(locationRoll !== undefined ? { locationRoll } : {}), + ...(dice ? { dice: dice as readonly [number, number] } : {}), + ...(tripodLegRoll !== undefined ? { tripodLegRoll } : {}), + }; +} + +function sanitizePendingCheckResolution(record: Record): SerializedPendingCheckResolution | null { + const target = sanitizePendingInteger(record['target'], 2, 12); + const rawResult = record['result']; + if (target !== undefined) { + if (rawResult === undefined) return { target }; + if (!rawResult || typeof rawResult !== 'object' || Array.isArray(rawResult)) return null; + const result = rawResult as Record; + if (result['kind'] === 'automatic' + && (result['outcome'] === 'success' || result['outcome'] === 'failed')) { + return { target, result: { kind: 'automatic', outcome: result['outcome'] } }; + } + if (result['kind'] === 'manual' + && (result['outcome'] === 'success' || result['outcome'] === 'failed')) { + return { target, result: { kind: 'manual', outcome: result['outcome'] } }; + } + const dice = result['kind'] === 'roll' ? sanitizeD6Roll(result['dice'], [2]) : undefined; + return dice + ? { target, result: { kind: 'roll', dice: dice as readonly [number, number] } } + : null; + } + + if (!rawResult || typeof rawResult !== 'object' || Array.isArray(rawResult)) return null; + const result = rawResult as Record; + return result['kind'] === 'automatic' + && (result['outcome'] === 'success' || result['outcome'] === 'failed') + ? { result: { kind: 'automatic', outcome: result['outcome'] } } + : null; +} + +function sanitizePendingUnitCheck(record: Record): SerializedPendingUnitCheck | null { + const id = sanitizePendingString(record['id'], 256); + const rawKind = record['kind']; + const resolution = sanitizePendingCheckResolution(record); + if (!id || !PENDING_UNIT_CHECK_KINDS.includes(rawKind as PendingUnitCheckKind) || !resolution) return null; + const kind = rawKind as PendingUnitCheckKind; + + const pilotDamageGroup = sanitizePendingString(record['pilotDamageGroup'], 80); + const base = { + type: 'unit-check' as const, + id, + ...resolution, + ...(pilotDamageGroup ? { pilotDamageGroup } : {}), + }; + switch (kind) { + case UNIT_CHECK_KIND.HEAT_SHUTDOWN: + case UNIT_CHECK_KIND.SHUTDOWN_RECOVERY: + case UNIT_CHECK_KIND.HEAT_RANDOM_MOVEMENT: + return { ...base, kind }; + case UNIT_CHECK_KIND.HEAT_AMMO_EXPLOSION: { + const selectionId = sanitizePendingString(record['selectionId'], 256); + return { ...base, kind, ...(selectionId ? { selectionId } : {}) }; + } + case UNIT_CHECK_KIND.HEAT_PILOT_DAMAGE: + case UNIT_CHECK_KIND.HEAT_LIFE_SUPPORT: + case UNIT_CHECK_KIND.LIFE_SUPPORT_DROWNING: { + const hits = sanitizePendingInteger(record['hits'], 1, 100); + return hits === undefined ? null : { ...base, kind, hits }; + } + case UNIT_CHECK_KIND.AERO_CONTROL_RECOVERY: { + const readyTurn = sanitizePendingInteger(record['readyTurn'], 0, Number.MAX_SAFE_INTEGER); + const cause = record['cause']; + if (readyTurn === undefined + || (cause !== undefined && cause !== UNIT_CHECK_CAUSE.HEAT_RANDOM_MOVEMENT)) return null; + return { ...base, kind, readyTurn, ...(cause ? { cause: cause as UnitCheckCause } : {}) }; + } + case UNIT_CHECK_KIND.SEATBELT: { + const crewId = sanitizePendingInteger(record['crewId'], 0, 255); + return crewId !== undefined ? { ...base, kind, crewId } : null; + } + case UNIT_CHECK_KIND.CONSCIOUSNESS: { + const crewId = sanitizePendingInteger(record['crewId'], 0, 255); + return crewId !== undefined && pilotDamageGroup + ? { ...base, kind, crewId, pilotDamageGroup } + : null; + } + case UNIT_CHECK_KIND.CONSCIOUSNESS_RECOVERY: { + const crewId = sanitizePendingInteger(record['crewId'], 0, 255); + const readyTurn = sanitizePendingInteger(record['readyTurn'], 0, Number.MAX_SAFE_INTEGER); + return crewId !== undefined && readyTurn !== undefined + ? { ...base, kind, crewId, readyTurn } + : null; + } + } +} + +function sanitizePendingEvent(record: Record): SerializedPendingEvent | null { + switch (record['type']) { + case 'mek-critical-chance': { + const base = sanitizePendingCriticalBase(record); + const chanceFacts = sanitizePendingCriticalChanceFacts(record); + if (!base || !chanceFacts) return null; + const roll = record['roll'] === undefined ? undefined : sanitizeD6Roll(record['roll'], [2]); + if (record['roll'] !== undefined && !roll) return null; + const result = record['result']; + const validResult = result === 'none' || result === 'blown-off' + || result === 1 || result === 2 || result === 3 || result === 4; + if (result !== undefined && !validResult) return null; + return { + ...base, + type: 'mek-critical-chance', + ...chanceFacts, + ...(roll ? { roll: roll as readonly [number, number] } : {}), + ...(validResult ? { result } : {}), + }; + } + case 'mek-critical-hit': { + const base = sanitizePendingCriticalBase(record); + const targetLocation = sanitizePendingString(record['targetLocation'], 32); + const remainingHits = sanitizePendingInteger(record['remainingHits'], 1, 4); + if (!base || !targetLocation || remainingHits === undefined) return null; + let chanceOrigin: SerializedPendingMekCriticalChanceOrigin | undefined; + if (record['chanceOrigin'] !== undefined) { + if (!record['chanceOrigin'] || typeof record['chanceOrigin'] !== 'object' + || Array.isArray(record['chanceOrigin'])) return null; + const sanitizedOrigin = sanitizePendingCriticalChanceFacts( + record['chanceOrigin'] as Record, + ); + if (!sanitizedOrigin) return null; + chanceOrigin = sanitizedOrigin; + } + let caseII: SerializedPendingMekCriticalCaseII | undefined; + if (record['caseII'] !== undefined) { + if (!record['caseII'] || typeof record['caseII'] !== 'object' || Array.isArray(record['caseII'])) return null; + const rawCaseII = record['caseII'] as Record; + if (rawCaseII['status'] === 'passed') { + caseII = { status: 'passed' }; + } else if (rawCaseII['status'] === 'pending' + && (rawCaseII['result'] === undefined + || rawCaseII['result'] === 'resolve' + || rawCaseII['result'] === 'discard')) { + const roll = rawCaseII['roll'] === undefined + ? undefined + : sanitizeD6Roll(rawCaseII['roll'], [2]); + if (rawCaseII['roll'] !== undefined && !roll) return null; + caseII = { + status: 'pending', + ...(rawCaseII['result'] ? { result: rawCaseII['result'] as 'resolve' | 'discard' } : {}), + ...(roll ? { roll: roll as readonly [number, number] } : {}), + }; + } else { + return null; + } + } + const floatingLocation = record['floatingLocation'] === undefined + ? undefined + : sanitizePendingFloatingCriticalLocation(record['floatingLocation']); + if (record['floatingLocation'] !== undefined && !floatingLocation) return null; + const roll = record['roll'] === undefined ? undefined : sanitizeD6Roll(record['roll'], [1, 2]); + if ((record['roll'] !== undefined && !roll) + || (roll && (caseII?.status === 'pending' || floatingLocation))) return null; + return { + ...base, + type: 'mek-critical-hit', + targetLocation, + remainingHits, + ...(chanceOrigin ? { chanceOrigin } : {}), + ...(floatingLocation ? { floatingLocation } : {}), + ...(caseII ? { caseII } : {}), + ...(roll ? { roll } : {}), + }; + } + case 'mek-fall': { + const id = sanitizePendingString(record['id'], 256); + const levelsFallen = sanitizePendingInteger(record['levelsFallen'], 0, 100); + const source = record['source']; + return id && levelsFallen !== undefined && (source === 'psr' || source === 'stand-attempt') + ? { type: 'mek-fall', id, source, levelsFallen } + : null; + } + case 'unit-check': + return sanitizePendingUnitCheck(record); + default: + return null; + } +} + +function sanitizePendingEvents(value: unknown): SerializedPendingEvent[] | undefined { + if (!Array.isArray(value)) return undefined; + const seenIds = new Set(); + const events: SerializedPendingEvent[] = []; + for (const candidate of value.slice(0, 256)) { + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue; + const event = sanitizePendingEvent(candidate as Record); + if (!event || seenIds.has(event.id)) continue; + seenIds.add(event.id); + events.push(event); + } + return events.length > 0 ? events : undefined; +} + export const TURN_STATE_SCHEMA = Sanitizer.schema() + .custom('turnCounter', sanitizeOptionalNonNegativeInteger) + .custom('endTurnCheckpoint', (value: unknown) => + value === 'phase-ended' || value === 'heat-staged' ? value : undefined) .custom('airborne', (value: unknown) => typeof value === 'boolean' ? value : undefined) .custom('moveMode', (value: unknown) => MOTIVE_MODE_VALUES.includes(value as MotiveModes) ? value as MotiveModes : undefined) .custom('moveDistance', sanitizeOptionalNonNegativeNumber) .custom('standAttempts', sanitizeOptionalNonNegativeNumber) + .custom('carefulStand', (value: unknown) => typeof value === 'boolean' ? value : undefined) .custom('cover', sanitizeOptionalCover) .custom('dmgReceived', sanitizeOptionalNonNegativeNumber) .custom('weaponsHeat', sanitizeOptionalNonNegativeNumber) @@ -454,6 +920,7 @@ export const TURN_STATE_SCHEMA = Sanitizer.schema() return Object.keys(outcomes).length > 0 ? outcomes : undefined; }) .custom('psrChecks', sanitizePSRChecks) + .custom('pendingEvents', sanitizePendingEvents) .custom('applyMovePSR', (value: unknown) => typeof value === 'boolean' ? value : undefined) .custom('spotting', (value: unknown) => typeof value === 'boolean' ? value : undefined) .custom('equipmentStateChanged', (value: unknown) => value === true ? true : undefined) @@ -484,6 +951,7 @@ export const CRIT_SLOT_SCHEMA = Sanitizer.schema() if (typeof value === 'number') return value; return undefined; }) + .custom('destroyedTurn', sanitizeOptionalNonNegativeInteger) .string('originalName') .boolean('armored') .build(); @@ -525,6 +993,11 @@ function sanitizeOptionalNonNegativeNumber(value: unknown): number | undefined { return Number.isFinite(parsed) ? Math.max(0, parsed) : undefined; } +function sanitizeOptionalNonNegativeInteger(value: unknown): number | undefined { + const parsed = sanitizeOptionalNonNegativeNumber(value); + return parsed === undefined ? undefined : Math.floor(parsed); +} + function sanitizeOptionalCover(value: unknown): SerializedUnitCover | undefined { const cover = deserializeUnitCover(value); return cover === undefined ? undefined : serializeUnitCover(cover); @@ -615,7 +1088,7 @@ export const CREW_MEMBER_SCHEMA = Sanitizer.schema() .number('pilotingSkill', { default: DEFAULT_PILOTING_SKILL, min: 0, max: 8 }) .number('asfGunnerySkill') .number('asfPilotingSkill') - .number('hits', { default: 0, min: 0 }) + .number('hits', { default: 0, min: 0, max: 6 }) .number('state', { default: 0, min: 0, max: 2 }) .build(); @@ -702,6 +1175,9 @@ export const CBT_SERIALIZED_GROUP_SCHEMA = Sanitizer.schema( .string('color') .string('formationId') .boolean('formationLock') + .custom('formationTargetGroupId', (value: unknown) => ( + typeof value === 'string' && value.length > 0 ? value : undefined + )) .custom('units', (value: unknown) => { if (!Array.isArray(value)) return []; return Sanitizer.sanitizeArray(value, CBT_SERIALIZED_UNIT_SCHEMA); @@ -873,6 +1349,9 @@ export const AS_SERIALIZED_GROUP_SCHEMA = Sanitizer.schema() .string('color') .string('formationId') .boolean('formationLock') + .custom('formationTargetGroupId', (value: unknown) => ( + typeof value === 'string' && value.length > 0 ? value : undefined + )) .custom('units', (value: unknown) => { if (!Array.isArray(value)) return []; return Sanitizer.sanitizeArray(value, AS_SERIALIZED_UNIT_SCHEMA); diff --git a/src/app/models/options.model.ts b/src/app/models/options.model.ts index 02e235c10..8e1c96f68 100644 --- a/src/app/models/options.model.ts +++ b/src/app/models/options.model.ts @@ -32,13 +32,13 @@ export type AutomationMode = typeof OPTION_VALUES.automationMode[number]; export interface CBTAutomationOptions { pilotSkillCheck: AutomationMode; - heatAndDissipation: AutomationMode; - heatEffects: AutomationMode; - pilotHitsAndConsciousness: AutomationMode; - internalExplosions: AutomationMode; - criticalHitChance: AutomationMode; - breachAndFlood: AutomationMode; - falling: AutomationMode; + heatAndDissipationResolution: AutomationMode; + heatEffectsCheck: AutomationMode; + pilotHitsAndConsciousnessCheck: AutomationMode; + internalExplosionsCheck: AutomationMode; + criticalHitChanceCheck: AutomationMode; + breachAndFloodCheck: AutomationMode; + fallingCheck: AutomationMode; } export type CBTAutomationKey = keyof CBTAutomationOptions; @@ -76,6 +76,7 @@ export interface ForceGeneratorOptions { export type ForceViewerBVPVDisplay = typeof OPTION_VALUES.forceViewerBVPVDisplay[number]; export interface CBTOptionalRules { + floatingCriticals: boolean; forcedWithdrawal: boolean; extremeRange: boolean; } diff --git a/src/app/models/rules/aero-rules.ts b/src/app/models/rules/aero-rules.ts index 27116f7fc..278544726 100644 --- a/src/app/models/rules/aero-rules.ts +++ b/src/app/models/rules/aero-rules.ts @@ -76,8 +76,9 @@ export class AeroRules extends UnitTypeRulesBase { // ── PSR / Control Rolls ────────────────────────────────────────────────── override getStandardControlRollTarget(): number { + const pilotCrewId = this.getActivePilotCrewId(); return this.getBasePilotingSkill() - + (this.unit.getCrewMember(0)?.getHits() ?? 0) + + (pilotCrewId === null ? 0 : this.unit.getCrewMember(pilotCrewId)?.getHits() ?? 0) + this.destroyedCriticalBoxes('avionics_hit') + this.destroyedCriticalBoxes('life_support_hit'); } diff --git a/src/app/models/rules/mek-rules.spec.ts b/src/app/models/rules/mek-rules.spec.ts index 336cbf230..5f1c93518 100644 --- a/src/app/models/rules/mek-rules.spec.ts +++ b/src/app/models/rules/mek-rules.spec.ts @@ -1875,22 +1875,22 @@ describe('MekRules', () => { } }); - it('uses the first active alternate pilot with a modifier when the Tripod dedicated pilot is disabled', () => { + it('uses the best available alternate pilot with a modifier when the Tripod dedicated pilot is disabled', () => { const forceUnit = createForceUnitHarness({ subtype: 'Tripod BattleMek', crewStates: ['unconscious', 'healthy', 'healthy'] }); forceUnit.getCrewMember(0).setSkill('piloting', 5); forceUnit.getCrewMember(1).setSkill('piloting', 6); forceUnit.getCrewMember(2).setSkill('piloting', 4); const rules = forceUnit.rules as MekRules; - expect(rules.getBasePilotingSkill()).toBe(6); - expect(rules.getActivePilotCrewId()).toBe(1); + expect(rules.getBasePilotingSkill()).toBe(4); + expect(rules.getActivePilotCrewId()).toBe(2); const punchModifiers = rules.getEquipmentToHitModifiers(punchEntry(forceUnit)); expect(toHitModifierTotal(punchModifiers)).toBe(2); expect(punchModifiers).toEqual([ { label: 'Dedicated Pilot disabled', modifier: 2, weakened: true }, ]); expect(rules.PSRModifiers().modifier).toBe(1); - expect(rules.PSRTargetRoll()).toBe(7); + expect(rules.PSRTargetRoll()).toBe(5); }); it('applies the Tripod dedicated pilot modifier to physical attacks', () => { @@ -2018,7 +2018,7 @@ describe('MekRules', () => { ]); }); - it('uses crew order instead of best skill for non-Tripod Mek target-number skills', () => { + it('keeps crew 0 as pilot while available, then uses the best alternate piloting skill', () => { const forceUnit = createForceUnitHarness({ crewStates: ['healthy', 'healthy', 'healthy'] }); forceUnit.getCrewMember(0).setSkill('gunnery', 5); forceUnit.getCrewMember(0).setSkill('piloting', 6); @@ -2030,11 +2030,13 @@ describe('MekRules', () => { expect(rules.getBaseGunnerySkill()).toBe(5); expect(rules.getBasePilotingSkill()).toBe(6); + expect(rules.getActivePilotCrewId()).toBe(0); forceUnit.getCrewMember(0).setState('unconscious'); expect(rules.getBaseGunnerySkill()).toBe(4); - expect(rules.getBasePilotingSkill()).toBe(5); + expect(rules.getBasePilotingSkill()).toBe(3); + expect(rules.getActivePilotCrewId()).toBe(2); }); it('ignores small cockpit PSR modifiers for drone operating system Meks', () => { @@ -2396,9 +2398,11 @@ describe('MekRules', () => { }); it('marks Meks abandoned when every crew member is dead or ejected', () => { - const rules = createRulesHarness({ crewStates: ['healthy', 'ejected'], crewHits: [DEAD_CREW_HIT_THRESHOLD] }); + const forceUnit = createForceUnitHarness({ crewStates: ['healthy', 'ejected'], crewHits: [DEAD_CREW_HIT_THRESHOLD] }); - expect(rules.hasComputedCondition('abandoned')).toBeTrue(); + forceUnit.endPhase(); + + expect(forceUnit.rules.hasComputedCondition('abandoned')).toBeTrue(); }); it('does not mark Meks abandoned while any crew member is alive in the unit', () => { @@ -4204,12 +4208,27 @@ describe('MekRules', () => { expect(turnState.autoFall()).toBeFalse(); forceUnit.setLocationCondition('LL', 'blown-off', true); + expect(turnState.resolveAutomaticFall()).toBeTrue(); forceUnit.endPhase(); expect(turnState.autoFall()).toBeFalse(); expect(forceUnit.getCondition('prone')).toBeTrue(); }); + it('treats a flooded CORE leg as a destroyed leg and immediate fall trigger', () => { + const forceUnit = createForceUnitHarness({ internalLocations: ['LL', 'RL'] }); + const turnState = forceUnit.turnState(); + + forceUnit.setLocationCondition('LL', 'flooded', true); + + expect(turnState.getPSRCheckState().legsDestroyed).toEqual(new Set(['LL'])); + expect(turnState.autoFall()).toBeTrue(); + expect(turnState.getPSRChecks()).toContain(jasmine.objectContaining({ + loc: 'LL', + reason: 'Leg destroyed', + })); + }); + it('treats the first pending blown-off quad leg as an immediate fall trigger', () => { const forceUnit = createForceUnitHarness({ internalLocations: ['FLL', 'FRL', 'RLL', 'RRL'] }); const turnState = forceUnit.turnState(); @@ -4219,6 +4238,7 @@ describe('MekRules', () => { expect(turnState.getPSRCheckState().legsDestroyed).toEqual(new Set(['FLL'])); expect(turnState.autoFall()).toBeTrue(); + expect(turnState.resolveAutomaticFall()).toBeTrue(); forceUnit.endPhase(); expect(turnState.autoFall()).toBeFalse(); diff --git a/src/app/models/rules/mek-rules.ts b/src/app/models/rules/mek-rules.ts index 3f9f78841..56b9a184c 100644 --- a/src/app/models/rules/mek-rules.ts +++ b/src/app/models/rules/mek-rules.ts @@ -4,7 +4,7 @@ import { computed } from '@angular/core'; import type { CBTForceUnit, EquipmentAction } from '../cbt-force-unit.model'; -import type { CrewMember, SkillType } from '../crew-member.model'; +import { isCrewMemberAvailable, type CrewMember } from '../crew-member.model'; import type { MountedEquipment } from '../mounted-equipment.model'; import type { CriticalSlot, RuleCheckOutcome } from '../force-serialization'; import { CrewStateControlDefinition, CrewStateDefinition, crewStateDefinitions, NARC_CONDITION_COLOR, sortPSRModifiers, UnitConditionControl, unitConditionControls, UnitTypeRulesBase, type ChargeDamage, type LocationConditionControl, type PSRCheck, type PSRCheckKind, type UnitHeatSource, type UnitModifierBreakdownEntry, type UnitRuleModifier } from './unit-type-rules'; @@ -16,6 +16,7 @@ import { getDefaultAttackerMovementModifier, TN_PRONE, TN_PRONE_ADJACENT, TN_PRO import { getMekLegLocations, getMekLimbLocations, + getMekLocationParent, inferMekConfigFromLocations, isMekLegLocation, LEG_LOCATIONS, @@ -34,7 +35,7 @@ import { uuidv7 } from '../../utils/uuid.util'; type ArmLocation = 'LA' | 'RA'; -const LEG_DAMAGE_MOVEMENT_CRITICAL_NAMES: Record = { +const LEG_DAMAGE_MOVEMENT_CRITICAL_NAMES: Partial> = { 'damaged-leg-actuator-movement': ['Leg', 'Foot', 'Hip'], 'damaged-hip-movement': ['Hip'], }; @@ -209,7 +210,9 @@ export class MekRules extends UnitTypeRulesBase { const config = inferMekConfigFromLocations(internalLocations.keys()); const limbLocations = getMekLimbLocations(config); - const isDestroyed = (loc: string) => this.unit.isInternalLocCommittedDestroyed(loc); + const isDestroyed = (loc: string) => isMekLegLocation(config, loc) + ? this.isLegDestroyed(loc, true) + : this.unit.isInternalLocCommittedDestroyed(loc); if (config !== 'Tripod') return limbLocations.every(isDestroyed); const armsDestroyed = limbLocations @@ -350,13 +353,13 @@ export class MekRules extends UnitTypeRulesBase { const config = inferMekConfigFromLocations(internalLocations?.keys() ?? []); const legs = getMekLegLocations(config); const destroyedLegs = legs.filter(loc => - internalLocations?.has(loc) && this.unit.isInternalLocDestroyed(loc) + internalLocations?.has(loc) && this.isLegDestroyed(loc) ); const hasIntactLeg = legs.some(loc => - internalLocations?.has(loc) && !this.unit.isInternalLocDestroyed(loc) + internalLocations?.has(loc) && !this.isLegDestroyed(loc) ); const allLegsIntact = legs.every(loc => - internalLocations?.has(loc) && !this.unit.isInternalLocDestroyed(loc) + internalLocations?.has(loc) && !this.isLegDestroyed(loc) ); const destroyedArms = ['LA', 'RA'].filter(loc => internalLocations?.has(loc) && this.unit.isInternalLocDestroyed(loc) @@ -364,6 +367,47 @@ export class MekRules extends UnitTypeRulesBase { return { config, destroyedLegs, destroyedArms, hasIntactLeg, allLegsIntact }; }); + /** CORE treats a breached leg as destroyed; TW overrides this with physical destruction only. */ + protected isLegDestroyed(location: string, committed = false): boolean { + if (committed) return this.unit.isInternalLocCommittedDestroyed(location); + if (this.unit.isInternalLocDestroyed(location)) return true; + const internalLocations = this.unit.locations?.internal; + const parent = internalLocations + ? getMekLocationParent(internalLocations.keys(), location) + : null; + return parent !== null && this.unit.isInternalLocDestroyed(parent); + } + + protected floodAffectedLegLocations(location: string): string[] { + const internalLocations = this.unit.locations?.internal; + if (!internalLocations) return []; + const config = inferMekConfigFromLocations(internalLocations.keys()); + return getMekLegLocations(config).filter(leg => + internalLocations.has(leg) + && (leg === location || getMekLocationParent(internalLocations.keys(), leg) === location) + ); + } + + protected isLegFlooded(location: string): boolean { + if (this.unit.getLocationCondition(location, 'flooded')) return true; + const internalLocations = this.unit.locations?.internal; + const parent = internalLocations + ? getMekLocationParent(internalLocations.keys(), location) + : null; + return parent !== null && this.unit.getLocationCondition(parent, 'flooded'); + } + + protected hasOtherLegFloodSource(location: string, source: string): boolean { + if (location !== source && this.unit.getLocationCondition(location, 'flooded')) return true; + const internalLocations = this.unit.locations?.internal; + const parent = internalLocations + ? getMekLocationParent(internalLocations.keys(), location) + : null; + return parent !== null + && parent !== source + && this.unit.getLocationCondition(parent, 'flooded'); + } + // ── PSR ────────────────────────────────────────────────────────────────── override readonly autoFall = computed(() => { @@ -421,6 +465,7 @@ export class MekRules extends UnitTypeRulesBase { checks.push({ fallCheck: 3, pilotCheck: 3, + kind: 'shutdown', reason: 'Shutdown' }); } @@ -596,7 +641,7 @@ export class MekRules extends UnitTypeRulesBase { const damagedLegLocations: string[] = []; this.unit.locations?.internal?.forEach((_value, loc) => { if (!LEG_LOCATIONS.has(loc)) return; - if (this.unit.isInternalLocCommittedDestroyed(loc)) { + if (this.isLegDestroyed(loc, true)) { damagedLegLocations.push(loc); } }); @@ -606,7 +651,7 @@ export class MekRules extends UnitTypeRulesBase { const hasDamagedLegActuators = critSlots.some(slot => { if (!slot.name || !slot.loc || !this.isCritUnavailable(slot)) return false; if (!LEG_LOCATIONS.has(slot.loc)) return false; - if (this.unit.isInternalLocCommittedDestroyed(slot.loc)) return false; + if (this.isLegDestroyed(slot.loc, true)) return false; return this.isNamedCrit(slot, 'Leg') || this.isNamedCrit(slot, 'Foot') || this.isNamedCrit(slot, 'Hip'); @@ -740,7 +785,7 @@ export class MekRules extends UnitTypeRulesBase { override evaluateLegDestroyed(location: string, hits: number): void { if (!LEG_LOCATIONS.has(location)) return; const turnState = this.unit.turnState(); - const destroyed = this.unit.isInternalLocDestroyed(location); + const destroyed = this.isLegDestroyed(location); let isPsrRelevant = false; const psr = turnState.getPSRCheckState(); if (destroyed) { @@ -762,31 +807,25 @@ export class MekRules extends UnitTypeRulesBase { } } + override evaluateLocationFlooded(location: string, active: boolean): void { + for (const leg of this.floodAffectedLegLocations(location)) { + if (this.hasOtherLegFloodSource(leg, location)) continue; + this.evaluateLegDestroyed(leg, active ? 1 : -1); + } + } + override evaluateCritSlotHit(crit: CriticalSlot): void { if (!crit.loc) return; - let isPsrRelevant = false; const delta = (crit.destroying) ? 1 : -1; + if (LEG_LOCATIONS.has(crit.loc)) { + this.evaluateLegActuatorDamage(crit, delta); + return; + } + + let isPsrRelevant = false; const turnState = this.unit.turnState(); const psr = turnState.getPSRCheckState(); - if (LEG_LOCATIONS.has(crit.loc)) { - if ((this.footHitsCausePSR && crit.name?.includes('Foot')) || crit.name?.includes('Leg')) { - if (!psr.legActuators) { - psr.legActuators = new Map(); - } - psr.legActuators.set(crit.loc, Math.max(0, (psr.legActuators.get(crit.loc) || 0) + delta)); - isPsrRelevant = true; - } else if (crit.name?.includes('Hip')) { - if (!psr.hipsHit) { - psr.hipsHit = new Set(); - } - if (delta > 0) { - psr.hipsHit.add(crit.loc); - } else { - psr.hipsHit.delete(crit.loc); - } - isPsrRelevant = true; - } - } else if (crit.name?.includes('Gyro')) { + if (crit.name?.includes('Gyro')) { psr.gyroHit = Math.max(0, (psr.gyroHit || 0) + delta); isPsrRelevant = true; const critSlots = this.unit.getCritSlots(); @@ -806,6 +845,23 @@ export class MekRules extends UnitTypeRulesBase { } } + protected evaluateLegActuatorDamage(crit: CriticalSlot, delta: number): void { + if (!crit.loc || !LEG_LOCATIONS.has(crit.loc)) return; + const turnState = this.unit.turnState(); + const psr = turnState.getPSRCheckState(); + if ((this.footHitsCausePSR && crit.name?.includes('Foot')) || crit.name?.includes('Leg')) { + psr.legActuators ??= new Map(); + psr.legActuators.set(crit.loc, Math.max(0, (psr.legActuators.get(crit.loc) || 0) + delta)); + } else if (crit.name?.includes('Hip')) { + psr.hipsHit ??= new Set(); + if (delta > 0) psr.hipsHit.add(crit.loc); + else psr.hipsHit.delete(crit.loc); + } else { + return; + } + turnState.setPSRCheckState(psr); + } + protected gyroDestructionHitThreshold(): number { return this.hasHeavyDutyGyro() ? 4 : 2; } @@ -1009,7 +1065,7 @@ export class MekRules extends UnitTypeRulesBase { if (!destroyedLegAES) { destroyedLegAES = critSlots.some(slot => slot.loc == loc && this.isNamedCrit(slot, 'AES') && this.isCritUnavailable(slot)); } - if (this.unit.isInternalLocCommittedDestroyed(loc)) { + if (this.isLegDestroyed(loc, true)) { destroyedLegsCount++; } else { destroyedHipsCount += critSlots.filter(slot => slot.loc === loc && this.isNamedCrit(slot, 'Hip') && this.isCritUnavailable(slot)).length; @@ -1448,17 +1504,7 @@ export class MekRules extends UnitTypeRulesBase { override getBaseGunnerySkill(): number { const gunnerCrewId = this.isTripodMek() ? 1 : 0; - return this.getTargetNumberCrewSkill('gunnery', gunnerCrewId) ?? super.getBaseGunnerySkill(); - } - - override getBasePilotingSkill(): number { - return this.getTargetNumberCrewSkill('piloting', 0) ?? super.getBasePilotingSkill(); - } - - override getActivePilotCrewId(): number | null { - return this.getActiveCrewMember(0)?.getId() - ?? this.getFirstActiveAlternateCrewMember(0)?.getId() - ?? null; + return this.getGunneryCrewSkill(gunnerCrewId) ?? super.getBaseGunnerySkill(); } protected override buildRuleModifiers(): UnitRuleModifier[] { @@ -1531,13 +1577,13 @@ export class MekRules extends UnitTypeRulesBase { } private isActiveCrewMember(crewMember: CrewMember): boolean { - return crewMember.getState() === 'healthy'; + return isCrewMemberAvailable(crewMember.getState()); } - private getTargetNumberCrewSkill(skillType: SkillType, primaryCrewId: number): number | null { + private getGunneryCrewSkill(primaryCrewId: number): number | null { const primaryCrewMember = this.getActiveCrewMember(primaryCrewId); - if (primaryCrewMember) return primaryCrewMember.getSkill(skillType); - return this.getFirstActiveAlternateCrewMember(primaryCrewId)?.getSkill(skillType) ?? null; + if (primaryCrewMember) return primaryCrewMember.getSkill('gunnery'); + return this.getFirstActiveAlternateCrewMember(primaryCrewId)?.getSkill('gunnery') ?? null; } private getFirstActiveAlternateCrewMember(primaryCrewId: number): CrewMember | null { @@ -1557,7 +1603,7 @@ export class MekRules extends UnitTypeRulesBase { const config = isTripod ? 'Tripod' : 'Quad'; let modifier = isTripod ? 1 : 0; for (const loc of getMekLegLocations(config)) { - if (!this.unit.locations?.internal?.has(loc) || this.unit.isInternalLocCommittedDestroyed(loc)) { + if (!this.unit.locations?.internal?.has(loc) || this.isLegDestroyed(loc, true)) { modifier = TN_PRONE_ATTACKER; } } diff --git a/src/app/models/rules/tw-rules.spec.ts b/src/app/models/rules/tw-rules.spec.ts index 1dc5d62d5..3fea4f4ea 100644 --- a/src/app/models/rules/tw-rules.spec.ts +++ b/src/app/models/rules/tw-rules.spec.ts @@ -150,6 +150,69 @@ describe('TWMekRules', () => { expect(forceUnit.rules.PSRModifiers().modifier).toBe(5); }); + it('forces a single TW gyro or leg-system PSR to fail while the pilot is unconscious', () => { + const cases: readonly { label: string; critical: CriticalSlot }[] = [ + { label: 'gyro', critical: { id: 'gyro', name: 'Gyro', loc: 'CT', slot: 0 } }, + { label: 'hip', critical: legActuatorCrit('hip', 'Hip', 'LL', false) }, + { label: 'upper leg', critical: legActuatorCrit('upper-leg', 'Upper Leg Actuator', 'LL', false) }, + { label: 'lower leg', critical: legActuatorCrit('lower-leg', 'Lower Leg Actuator', 'LL', false) }, + { label: 'foot', critical: legActuatorCrit('foot', 'Foot Actuator', 'LL', false) }, + ]; + + for (const { label, critical } of cases) { + const forceUnit = createTWForceUnit([critical]); + forceUnit.setCrewState(0, 'unconscious'); + hitCrit(forceUnit, critical.loc!, critical.slot!); + + const turnState = forceUnit.turnState(); + const checks = turnState.getPSRChecks(); + expect(turnState.autoFall()).withContext(label).toBeFalse(); + expect(checks.length).withContext(label).toBe(1); + expect(checks[0].failureOutcome).withContext(label).toBe('Fall'); + expect(turnState.isPSRCheckAutomaticFailure(checks[0])).withContext(label).toBeTrue(); + expect(turnState.actionablePSRRollsCount()).withContext(label).toBe(0); + } + }); + + it('applies a flooded TW leg as four actuator losses, not a destroyed leg', () => { + const forceUnit = createTWForceUnit([ + legActuatorCrit('hip', 'Hip', 'LL', false), + legActuatorCrit('upper-leg', 'Upper Leg Actuator', 'LL', false), + legActuatorCrit('lower-leg', 'Lower Leg Actuator', 'LL', false), + legActuatorCrit('foot', 'Foot', 'LL', false), + { id: 'weapon', name: 'Medium Laser', loc: 'LL', slot: 4 }, + ]); + const turnState = forceUnit.turnState(); + + forceUnit.setLocationCondition('LL', 'flooded', true); + + expect(turnState.getPSRCheckState().legsDestroyed).toBeUndefined(); + expect(turnState.autoFall()).toBeFalse(); + expect(turnState.getPSRChecks().map(check => check.reason)).toEqual([ + 'Leg actuator hit', + 'Leg actuator hit', + 'Leg actuator hit', + 'Hip hit', + ]); + expect(forceUnit.rules.PSRModifiers().modifier).toBe(5); + + forceUnit.endPhase(); + + const rules = forceUnit.rules as TWMekRules; + expect(forceUnit.getCondition('prone')).toBeFalse(); + expect(forceUnit.isInternalLocCommittedPhysicallyDestroyed('LL')).toBeFalse(); + expect(rules.systemsStatus()).toEqual(jasmine.objectContaining({ + destroyedLegsCount: 0, + destroyedHipsCount: 1, + destroyedLegActuatorsCount: 2, + destroyedFeetCount: 1, + })); + expect(rules.movementState()).toEqual(jasmine.objectContaining({ walk: 0, run: 0 })); + expect(forceUnit.getCritSlots().every(slot => !forceUnit.isEquipmentOperational(slot))).toBeTrue(); + expect(forceUnit.getCritSlots().every(slot => slot.destroyed === undefined)).toBeTrue(); + expect(forceUnit.rules.PSRModifiers().modifier).toBe(5); + }); + it('keeps TW actuator hits independent across both legs', () => { const forceUnit = createTWForceUnit(); const turnState = forceUnit.turnState(); diff --git a/src/app/models/rules/tw-rules.ts b/src/app/models/rules/tw-rules.ts index 9fd2ed977..efe3b95a9 100644 --- a/src/app/models/rules/tw-rules.ts +++ b/src/app/models/rules/tw-rules.ts @@ -63,6 +63,41 @@ export class TWMekRules extends MekRules { protected override get shieldBashPunchBonusEnabled(): boolean { return false; } protected override get standaloneShieldDamageEnabled(): boolean { return true; } + protected override isLegDestroyed(location: string, committed = false): boolean { + return committed + ? this.unit.isInternalLocCommittedPhysicallyDestroyed(location) + : this.unit.isInternalLocPhysicallyDestroyed(location); + } + + override evaluateLocationFlooded(location: string, active: boolean): void { + for (const leg of this.floodAffectedLegLocations(location)) { + if ((active && this.isLegDestroyed(leg)) || this.hasOtherLegFloodSource(leg, location)) continue; + this.unit.getCritSlots() + .filter(slot => slot.loc === leg + && slot.destroyed === undefined + && slot.destroying === undefined + && this.isLegActuator(slot)) + .forEach(slot => this.evaluateLegActuatorDamage(slot, active ? 1 : -1)); + } + } + + override evaluateCritSlotHit(crit: CriticalSlot): void { + // Flooding already made this actuator nonfunctional; a later critical hit + // can still occupy the slot, but must not apply its gameplay effects twice. + if (crit.loc + && LEG_LOCATIONS.has(crit.loc) + && this.isLegFlooded(crit.loc) + && this.isLegActuator(crit)) return; + super.evaluateCritSlotHit(crit); + } + + private isLegActuator(slot: CriticalSlot): boolean { + return this.isNamedCrit(slot, 'Hip') + || this.isNamedCrit(slot, 'Upper Leg') + || this.isNamedCrit(slot, 'Lower Leg') + || this.isNamedCrit(slot, 'Foot'); + } + protected override shieldRetainsMobilityPenalty(entry: MountedEquipment): boolean { if (entry.committedDestroyed()) return false; const criticals = this.entryCriticalSlots(entry); @@ -148,9 +183,16 @@ export class TWMekRules extends MekRules { ): { modifier: number; modifiers: PSRCheck[] } { let modifier = 0; const modifiers: PSRCheck[] = []; + const turnState = this.unit.turnState(); + const currentPSR = turnState.getPSRCheckState(); + const activeHipHits = new Set(turnState.getPSRChecks() + .filter(check => check.reason === 'Hip hit' && check.loc) + .map(check => check.loc!)); const destroyedHips = critSlots.filter(slot => slot.loc && LEG_LOCATIONS.has(slot.loc) - && slot.destroyed !== undefined + && !this.isLegDestroyed(slot.loc, true) + && !this.unit.isEquipmentOperational(slot) + && !activeHipHits.has(slot.loc) && !ignoreLeg.has(slot.loc) && this.isNamedCrit(slot, 'Hip')); for (const hip of destroyedHips) { @@ -159,7 +201,8 @@ export class TWMekRules extends MekRules { } const destroyedActuators = this.effectiveCommittedLegActuators( critSlots, - this.unit.turnState().getPSRCheckState().hipsHit, + currentPSR.hipsHit, + currentPSR.legActuators, ) .filter(slot => !ignoreLeg.has(slot.loc!)); const destroyedActuatorCounts = new Map(); @@ -183,6 +226,7 @@ export class TWMekRules extends MekRules { private effectiveCommittedLegActuators( critSlots: readonly CriticalSlot[], currentTurnHipHits: ReadonlySet | undefined = undefined, + currentTurnActuatorHits: ReadonlyMap | undefined = undefined, ): CriticalSlot[] { // BMM: a hip replaces same-leg actuator modifiers from earlier turns; // actuator hits from the hip's turn or a later turn remain cumulative. @@ -190,13 +234,17 @@ export class TWMekRules extends MekRules { for (const slot of critSlots) { if (!slot.loc || !LEG_LOCATIONS.has(slot.loc) - || slot.destroyed === undefined + || this.isLegDestroyed(slot.loc, true) + || this.unit.isEquipmentOperational(slot) || !this.isNamedCrit(slot, 'Hip')) continue; + const disabledTurn = slot.destroyed === undefined + ? Number.MAX_SAFE_INTEGER + : slot.destroyedTurn ?? 0; hipDestroyedOnTurnByLeg.set( slot.loc, Math.max( hipDestroyedOnTurnByLeg.get(slot.loc) ?? 0, - slot.destroyedTurn ?? 0, + disabledTurn, ), ); } @@ -210,15 +258,25 @@ export class TWMekRules extends MekRules { return critSlots.filter(slot => { if (!slot.loc || !LEG_LOCATIONS.has(slot.loc) - || slot.destroyed === undefined - || this.unit.isInternalLocCommittedDestroyed(slot.loc) + || this.isLegDestroyed(slot.loc, true) + || this.unit.isEquipmentOperational(slot) || (!this.isNamedCrit(slot, 'Leg') && !this.isNamedCrit(slot, 'Foot'))) return false; + if (slot.destroyed === undefined + && currentTurnActuatorHits?.has(slot.loc) + && this.isCommittedFloodedLeg(slot.loc)) return false; const hipDestroyedOnTurn = hipDestroyedOnTurnByLeg.get(slot.loc); - const actuatorDestroyedOnTurn = slot.destroyedTurn ?? 0; + const actuatorDestroyedOnTurn = slot.destroyed === undefined + ? Number.MAX_SAFE_INTEGER + : slot.destroyedTurn ?? 0; return hipDestroyedOnTurn === undefined || actuatorDestroyedOnTurn >= hipDestroyedOnTurn; }); } + private isCommittedFloodedLeg(location: string): boolean { + return this.unit.isInternalLocCommittedDestroyed(location) + && !this.isLegDestroyed(location, true); + } + protected override legActuatorMovementReduction(): number { return this.effectiveCommittedLegActuators(this.unit.getCritSlots()).length; } @@ -250,7 +308,7 @@ export class TWMekRules extends MekRules { } private sideTorsoDestroyedOrDestroying(): boolean { - return MEK_SIDE_TORSO_LOCATIONS.some(loc => this.unit.isInternalLocDestroyed(loc)); + return MEK_SIDE_TORSO_LOCATIONS.some(loc => this.unit.isInternalLocPhysicallyDestroyed(loc)); } private internalStructureCrippledOrCrippling(): boolean { diff --git a/src/app/models/rules/unit-type-rules.ts b/src/app/models/rules/unit-type-rules.ts index 272299fbd..ebe5f93d0 100644 --- a/src/app/models/rules/unit-type-rules.ts +++ b/src/app/models/rules/unit-type-rules.ts @@ -9,7 +9,7 @@ import { WeaponEquipment, type Equipment } from '../equipment.model'; import type { CriticalSlot, RuleCheckOutcome, SerializedC3NetworkGroup } from '../force-serialization'; import { getMotiveModeLabel, type MotiveModes } from '../motiveModes.model'; import type { TurnState } from '../turn-state.model'; -import type { CrewMemberState } from '../crew-member.model'; +import { isCrewMemberAvailable, type CrewMember, type CrewMemberState } from '../crew-member.model'; import { getTargetMovementBracketForDistance, getTargetMovementDistanceModifier, @@ -30,7 +30,7 @@ import type { UnitSystemStatusFacts, } from '../equipment-status.model'; -export type PSRCheckKind = 'damaged-leg-actuator-movement' | 'damaged-hip-movement'; +export type PSRCheckKind = 'shutdown' | 'damaged-leg-actuator-movement' | 'damaged-hip-movement'; export interface PSRCheck { id?: string; @@ -369,6 +369,9 @@ export interface UnitTypeRules { /** Evaluate whether internal damage creates unit-type-specific control-roll checks. */ evaluateLegDestroyed(location: string, hits: number): void; + /** Apply the ruleset-specific consequences of flooding a location. */ + evaluateLocationFlooded(location: string, active: boolean): void; + /** Evaluate whether critical damage creates unit-type-specific control-roll checks. */ evaluateCritSlotHit(crit: CriticalSlot): void; @@ -717,6 +720,9 @@ export abstract class UnitTypeRulesBase implements UnitTypeRules { evaluateLegDestroyed(_location: string, _hits: number): void { } + evaluateLocationFlooded(_location: string, _active: boolean): void { + } + evaluateCritSlotHit(_crit: CriticalSlot): void { } @@ -748,7 +754,15 @@ export abstract class UnitTypeRulesBase implements UnitTypeRules { } getActivePilotCrewId(): number | null { - return this.unit.getCrewMember(0)?.getState() === 'healthy' ? 0 : null; + const primaryPilot = this.unit.getCrewMember(0); + if (primaryPilot && isCrewMemberAvailable(primaryPilot.getState())) return 0; + + return this.unit.getCrewMembers().reduce((best, crew) => { + if (crew.getId() === 0 || !isCrewMemberAvailable(crew.getState())) return best; + if (!best || crew.getSkill('piloting') < best.getSkill('piloting')) return crew; + if (crew.getSkill('piloting') === best.getSkill('piloting') && crew.getId() < best.getId()) return crew; + return best; + }, null)?.getId() ?? null; } getMaxDistanceForMoveMode(_moveMode: MotiveModes): number | null { @@ -784,7 +798,9 @@ export abstract class UnitTypeRulesBase implements UnitTypeRules { } getBasePilotingSkill(): number { - return this.unit.getCrewMember(0)?.getSkill('piloting') ?? this.unit.pilotingSkill(); + const crewId = this.getActivePilotCrewId(); + return (crewId === null ? null : this.unit.getCrewMember(crewId)?.getSkill('piloting')) + ?? this.unit.pilotingSkill(); } getStandardControlRollTarget(): number { diff --git a/src/app/models/rules/vehicle-rules.ts b/src/app/models/rules/vehicle-rules.ts index 0b796bb6d..453f75352 100644 --- a/src/app/models/rules/vehicle-rules.ts +++ b/src/app/models/rules/vehicle-rules.ts @@ -263,7 +263,7 @@ export class VehicleRules extends UnitTypeRulesBase { }; }); - override readonly PSRTargetRoll = computed(() => this.unit.pilotingSkill() + this.PSRModifiers().modifier); + override readonly PSRTargetRoll = computed(() => this.getBasePilotingSkill() + this.PSRModifiers().modifier); override getUnitSystemStatusFacts(): UnitSystemStatusFacts { return { diff --git a/src/app/models/turn-state.model.spec.ts b/src/app/models/turn-state.model.spec.ts index 7233d4386..fde229745 100644 --- a/src/app/models/turn-state.model.spec.ts +++ b/src/app/models/turn-state.model.spec.ts @@ -28,11 +28,16 @@ interface TurnStateHarnessOptions { internalLocations?: string[]; unit?: Partial; destroyed?: boolean; + immobile?: boolean; prone?: boolean; shutdown?: boolean; skidding?: boolean; rulesType?: 'mek' | 'infantry' | 'aero'; rulesId?: 'core2026' | 'tw'; + crewState?: 'healthy' | 'unconscious' | 'ejected' | 'killed'; + outOfControl?: boolean; + phaseTracking?: boolean; + turnCounter?: number; } interface TurnStateHarness { @@ -85,6 +90,12 @@ function createTurnStateHarness(options: TurnStateHarnessOptions = {}): TurnStat const heatSourceHandlers = [new PpcCapacitorHandler()]; const ruleChecks = new Map(); const setCondition = jasmine.createSpy('setCondition'); + const crew = { + getId: () => 0, + getState: () => options.crewState ?? 'healthy', + getHits: () => 0, + getSkill: () => 5, + }; let turnState: TurnState; const unit = { @@ -93,8 +104,15 @@ function createTurnStateHarness(options: TurnStateHarnessOptions = {}): TurnStat isLoaded: () => true, destroyed: options.destroyed ?? false, shutdown: options.shutdown ?? false, - getCondition: () => false, - getCrewMembers: () => [{ getState: () => 'healthy' }], + getCondition: (condition: string) => { + if (condition === 'immobile') return options.immobile ?? false; + if (condition === 'shutdown') return options.shutdown ?? false; + if (condition === 'prone') return options.prone ?? false; + if (condition === 'out-of-control') return options.outOfControl ?? false; + return false; + }, + getCrewMembers: () => [crew], + getCrewMember: (id: number) => id === 0 ? crew : undefined, getCritSlots: () => critSlots(), getInventory: () => inventory(), getHeat: () => heat(), @@ -105,9 +123,13 @@ function createTurnStateHarness(options: TurnStateHarnessOptions = {}): TurnStat createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY), ) ?? [])), getRunMovementMultiplierBonus: () => 0, + automationMode: () => 'yes', + tracksPhaseAndTurn: () => options.phaseTracking ?? true, usesForcedWithdrawal: () => true, isInternalLocCommittedDestroyed: (loc: string) => committedDestroyedLegs.has(loc), + isInternalLocCommittedPhysicallyDestroyed: (loc: string) => committedDestroyedLegs.has(loc), isInternalLocDestroyed: (loc: string) => currentDestroyedLegs.has(loc) || committedDestroyedLegs.has(loc), + isInternalLocPhysicallyDestroyed: (loc: string) => currentDestroyedLegs.has(loc) || committedDestroyedLegs.has(loc), getEquipmentStatus: (source: MountedEquipment | CriticalSlot) => { if (source instanceof MountedEquipment) return source.committedDestroyed() ? 'destroyed' : 'available'; return source.destroyed || (source.loc ? committedDestroyedLegs.has(source.loc) : false) @@ -124,6 +146,7 @@ function createTurnStateHarness(options: TurnStateHarnessOptions = {}): TurnStat return true; }, setCondition, + queueFall: jasmine.createSpy('queueFall'), getUnit: () => ({ type: 'Mek', comp: [], ...options.unit } as UnitSummary), getAvailableMotiveModes: () => [ { mode: 'stationary' as const, label: 'Stationary' }, @@ -143,6 +166,7 @@ function createTurnStateHarness(options: TurnStateHarnessOptions = {}): TurnStat hasUnconsolidatedLocations: computed(() => false), hasUnconsolidatedInventory: computed(() => false), hasCondition: (state: string) => { + if (state === 'immobile') return options.immobile ?? false; if (state === 'prone') return options.prone ?? false; if (state === 'skidding') return options.skidding ?? false; return false; @@ -150,7 +174,7 @@ function createTurnStateHarness(options: TurnStateHarnessOptions = {}): TurnStat skidding: () => options.skidding ?? false, } as unknown as CBTForceUnitState; - turnState = new TurnState(unitState); + turnState = new TurnState(unitState, options.turnCounter ?? 0); const rules = options.rulesId === 'tw' ? options.rulesType === 'infantry' ? new TWInfantryRules(unit as any) @@ -344,6 +368,38 @@ describe('TurnState', () => { }); describe('serialization', () => { + it('round-trips the resumable end-turn checkpoint', () => { + const { turnState } = createTurnStateHarness(); + turnState.markEndTurnPhaseEnded(); + + expect(turnState.dirty()).toBeTrue(); + expect(turnState.serialize()).toEqual({ endTurnCheckpoint: 'phase-ended' }); + + const { turnState: restored } = createTurnStateHarness(); + restored.update(turnState.serialize()); + restored.markEndTurnHeatStaged(); + + expect(restored.getEndTurnCheckpoint()).toBe('heat-staged'); + expect(restored.serialize()).toEqual({ endTurnCheckpoint: 'heat-staged' }); + }); + + it('closes every queued pilot-damage group before end-turn consequences resolve', () => { + const { turnState } = createTurnStateHarness(); + expect(turnState.queuePendingUnitCheck({ + id: 'seatbelt', + kind: 'seatbelt', + pilotDamageGroup: 'combat:fall', + crewId: 0, + target: 5, + })).toBeTrue(); + + turnState.completePilotDamageTurn(); + turnState.completePilotDamageTurn(); + + expect(turnState.getPendingUnitCheck('seatbelt')?.pilotDamageGroup) + .toBe('turn-closed:combat:fall'); + }); + it('keeps stand attempts undefined by default and round-trips an explicit zero', () => { const { turnState } = createTurnStateHarness(); @@ -362,6 +418,33 @@ describe('TurnState', () => { expect(restored.serialize()).toEqual({ standAttempts: 0 }); }); + it('round-trips a careful stand and clears it with an empty update', () => { + const { turnState } = createTurnStateHarness({ rulesId: 'tw' }); + turnState.carefulStand.set(true); + + expect(turnState.serialize()).toEqual({ carefulStand: true }); + + const { turnState: restored } = createTurnStateHarness({ rulesId: 'tw' }); + restored.update(turnState.serialize()); + + expect(restored.carefulStand()).toBeTrue(); + expect(restored.serialize()).toEqual({ carefulStand: true }); + + restored.update(undefined); + + expect(restored.carefulStand()).toBeFalse(); + expect(restored.serialize()).toBeUndefined(); + }); + + it('discards careful-stand state under Core rules', () => { + const { turnState } = createTurnStateHarness({ rulesId: 'core2026' }); + + turnState.update({ carefulStand: true }); + + expect(turnState.carefulStand()).toBeFalse(); + expect(turnState.serialize()).toBeUndefined(); + }); + it('omits no cover and round-trips active cover', () => { const { turnState } = createTurnStateHarness(); @@ -422,6 +505,7 @@ describe('TurnState', () => { expect(turnState.resolvePSRCheck(checks[1].id!, 'failed')).toBeTrue(); expect(turnState.getPSROutcome(checks[0].id!)).toBe('failed'); expect(turnState.getPSROutcome(checks[1].id!)).toBe('failed'); + expect(turnState.unitState.unit.queueFall).toHaveBeenCalledOnceWith('psr'); expect(turnState.unitState.unit.setCondition).toHaveBeenCalledOnceWith('prone', true); expect(turnState.PSRRollsCount()).toBe(0); }); @@ -442,6 +526,73 @@ describe('TurnState', () => { expect(turnState.getPSROutcome(control.id!)).toBeUndefined(); }); + it('does not expose fall rolls made moot by an automatic fall', () => { + const { turnState, rules } = createTurnStateHarness(); + spyOn(rules, 'getPSRChecks').and.returnValue([ + { reason: 'First fall check', fallCheck: 0, failureOutcome: 'Fall' }, + { reason: 'Second fall check', fallCheck: 1, failureOutcome: 'Fall' }, + { reason: 'Control check', fallCheck: 2, failureOutcome: 'Immobilized' }, + ]); + + expect(turnState.PSRRollsCount()).toBe(3); + expect(turnState.actionablePSRRollsCount()).toBe(3); + + turnState.setPSRCheckState({ legsDestroyed: new Set(['LL']) }); + + expect(turnState.autoFall()).toBeTrue(); + expect(turnState.PSRRollsCount()).toBe(3); + expect(turnState.actionablePSRRollsCount()).toBe(1); + }); + + it('does not trigger another fall when a fall PSR is resolved while already prone', () => { + const { turnState, rules } = createTurnStateHarness({ prone: true }); + spyOn(rules, 'getPSRChecks').and.returnValue([ + { reason: 'Fall check', fallCheck: 0, failureOutcome: 'Fall' }, + { reason: 'Control check', fallCheck: 1, failureOutcome: 'Immobilized' }, + ]); + + const fallCheck = turnState.getPSRChecks().find(check => check.reason === 'Fall check'); + expect(fallCheck?.id).toBeDefined(); + + expect(turnState.resolvePSRCheck(fallCheck!.id!, 'failed')).toBeTrue(); + + expect(turnState.unitState.unit.queueFall).not.toHaveBeenCalled(); + expect(turnState.unitState.unit.setCondition).not.toHaveBeenCalled(); + }); + + it('does not offer any PSR to a unit without a conscious pilot', () => { + const { turnState, rules } = createTurnStateHarness({ crewState: 'unconscious' }); + spyOn(rules, 'getPSRChecks').and.returnValue([ + { reason: 'Fall check', fallCheck: 0, failureOutcome: 'Fall' }, + { reason: 'System check', fallCheck: 1, failureOutcome: 'Crippled' }, + ]); + + expect(turnState.automaticPSRFailure()).toBeTrue(); + expect(turnState.PSRRollsCount()).toBe(2); + expect(turnState.actionablePSRRollsCount()).toBe(0); + }); + + it('keeps the initial shutdown PSR rollable while forcing later PSRs for a standing shutdown Mek', () => { + const mixed = createTurnStateHarness({ shutdown: true }); + const forcedOnly = createTurnStateHarness({ shutdown: true }); + const prone = createTurnStateHarness({ shutdown: true, prone: true }); + const shutdown = { + kind: 'shutdown', reason: 'Shutdown', fallCheck: 3, failureOutcome: 'Fall', + } as const; + const later = { reason: 'Received 20 damage', fallCheck: 1, failureOutcome: 'Fall' } as const; + spyOn(mixed.rules, 'getPSRChecks').and.returnValue([shutdown, later]); + spyOn(forcedOnly.rules, 'getPSRChecks').and.returnValue([later]); + spyOn(prone.rules, 'getPSRChecks').and.returnValue([later]); + + expect(mixed.turnState.isPSRCheckAutomaticFailure(shutdown)).toBeFalse(); + expect(mixed.turnState.isPSRCheckAutomaticFailure(later)).toBeTrue(); + expect(mixed.turnState.automaticPSRFailure()).toBeFalse(); + expect(mixed.turnState.actionablePSRRollsCount()).toBe(1); + expect(forcedOnly.turnState.automaticPSRFailure()).toBeTrue(); + expect(forcedOnly.turnState.actionablePSRRollsCount()).toBe(0); + expect(prone.turnState.isPSRCheckAutomaticFailure(later)).toBeFalse(); + }); + it('round-trips turn signals and PSR check state through a plain object', () => { const { turnState } = createTurnStateHarness(); turnState.airborne.set(true); @@ -499,6 +650,660 @@ describe('TurnState', () => { expect(restored.serialize()).toBeUndefined(); }); + it('starts at turn zero and round-trips the current turn counter', () => { + const { turnState } = createTurnStateHarness(); + + expect(turnState.getTurnCounter()).toBe(0); + expect(turnState.serialize()).toBeUndefined(); + + const { turnState: restored } = createTurnStateHarness(); + restored.update(turnState.serialize()); + + expect(restored.getTurnCounter()).toBe(0); + }); + + it('round-trips pending critical counts and unresolved dice', () => { + const { turnState } = createTurnStateHarness(); + expect(turnState.queuePendingCriticalHits({ + id: 'critical:1', + location: 'LT', + targetLocation: 'CT', + remainingHits: 2, + locationDestroyed: true, + })).toBeTrue(); + expect(turnState.setPendingCriticalRoll('critical:1', [3, 4])).toBeTrue(); + + expect(turnState.pendingCriticalHitCount()).toBe(2); + expect(turnState.dirty()).toBeFalse(); + expect(turnState.dirtyPhase()).toBeFalse(); + expect(turnState.serialize()?.pendingEvents).toEqual([{ + type: 'mek-critical-hit', + id: 'critical:1', + location: 'LT', + targetLocation: 'CT', + remainingHits: 2, + locationDestroyed: true, + roll: [3, 4], + }]); + + const { turnState: restored } = createTurnStateHarness(); + restored.update(turnState.serialize()); + expect(restored.getPendingCriticalHits()).toEqual([{ + type: 'mek-critical-hit', + id: 'critical:1', + location: 'LT', + targetLocation: 'CT', + remainingHits: 2, + locationDestroyed: true, + roll: [3, 4], + }]); + + expect(restored.resolvePendingCriticalHit('critical:1')).toBeTrue(); + expect(restored.getPendingCriticalHit('critical:1')).toEqual({ + type: 'mek-critical-hit', + id: 'critical:1', + location: 'LT', + targetLocation: 'CT', + remainingHits: 1, + locationDestroyed: true, + }); + expect(restored.resolvePendingCriticalHit('critical:1')).toBeTrue(); + expect(restored.pendingCriticalHitCount()).toBe(0); + }); + + it('persists and resets the per-critical Total Warfare CASE II check', () => { + const { turnState } = createTurnStateHarness(); + expect(turnState.queuePendingCriticalHits({ + id: 'critical:case-ii', + location: 'LT', + targetLocation: 'LT', + remainingHits: 2, + caseII: { status: 'pending' }, + })).toBeTrue(); + expect(turnState.setPendingCriticalRoll('critical:case-ii', [1, 1])).toBeFalse(); + expect(turnState.setPendingCriticalCaseIICheckResult( + 'critical:case-ii', + 'resolve', + [3, 3], + )).toBeTrue(); + expect(turnState.getPendingCriticalHit('critical:case-ii')?.caseII).toEqual({ + status: 'pending', + result: 'resolve', + roll: [3, 3], + }); + const { turnState: paused } = createTurnStateHarness(); + paused.update(turnState.serialize()); + expect(paused.getPendingCriticalHit('critical:case-ii')?.caseII).toEqual({ + status: 'pending', + result: 'resolve', + roll: [3, 3], + }); + expect(turnState.passPendingCriticalCaseIICheck('critical:case-ii')).toBeTrue(); + expect(turnState.setPendingCriticalRoll('critical:case-ii', [1, 1])).toBeTrue(); + + const { turnState: restored } = createTurnStateHarness(); + restored.update(turnState.serialize()); + expect(restored.getPendingCriticalHit('critical:case-ii')).toEqual({ + type: 'mek-critical-hit', + id: 'critical:case-ii', + location: 'LT', + targetLocation: 'LT', + remainingHits: 2, + caseII: { status: 'passed' }, + roll: [1, 1], + }); + + expect(restored.resolvePendingCriticalHit('critical:case-ii')).toBeTrue(); + expect(restored.getPendingCriticalHit('critical:case-ii')).toEqual({ + type: 'mek-critical-hit', + id: 'critical:case-ii', + location: 'LT', + targetLocation: 'LT', + remainingHits: 1, + caseII: { status: 'pending' }, + }); + }); + + it('round-trips pending critical chances without making turn controls dirty', () => { + const { turnState } = createTurnStateHarness(); + expect(turnState.queuePendingCriticalChance({ + id: 'chance:1', + location: 'CT', + explosionProtection: 'case-ii', + hardenedArmorApplies: true, + })).toBeTrue(); + expect(turnState.setPendingCriticalChanceRoll('chance:1', [5, 5])).toBeTrue(); + expect(turnState.setPendingCriticalChanceResult('chance:1', 2)).toBeTrue(); + + expect(turnState.pendingCriticalChanceCount()).toBe(1); + expect(turnState.dirty()).toBeFalse(); + expect(turnState.dirtyPhase()).toBeFalse(); + + const { turnState: restored } = createTurnStateHarness(); + restored.update(turnState.serialize()); + expect(restored.getPendingCriticalChances()).toEqual([{ + type: 'mek-critical-chance', + id: 'chance:1', + location: 'CT', + explosionProtection: 'case-ii', + hardenedArmorApplies: true, + roll: [5, 5], + result: 2, + }]); + + restored.preparePendingCriticalWorkAfterPhaseCommit(); + expect(restored.getPendingCriticalChance('chance:1')?.consolidateImmediately).toBeTrue(); + expect(restored.discardPendingCriticalChance('chance:1')).toBeTrue(); + expect(restored.pendingCriticalChanceCount()).toBe(0); + }); + + it('round-trips a floating-critical location draft before slot resolution', () => { + const { turnState } = createTurnStateHarness(); + expect(turnState.queuePendingCriticalChance({ + id: 'chance:floating', + location: 'RT', + throughArmorHitArc: 'right', + })).toBeTrue(); + expect(turnState.replacePendingCriticalChanceWithHits({ + id: 'chance:floating', + targetLocation: 'RT', + remainingHits: 1, + floatingLocation: { hitArc: 'right' }, + })).toBeTrue(); + expect(turnState.setPendingCriticalRoll('chance:floating', [2, 3])).toBeFalse(); + expect(turnState.setPendingFloatingCriticalLocation( + 'chance:floating', + 10, + [4, 6], + )).toBeTrue(); + + const { turnState: restored } = createTurnStateHarness(); + restored.update(turnState.serialize()); + + expect(restored.getPendingCriticalHit('chance:floating')).toEqual({ + type: 'mek-critical-hit', + id: 'chance:floating', + location: 'RT', + targetLocation: 'RT', + remainingHits: 1, + chanceOrigin: { throughArmorHitArc: 'right' }, + floatingLocation: { + hitArc: 'right', + locationRoll: 10, + dice: [4, 6], + }, + }); + expect(restored.resolvePendingCriticalHit('chance:floating')).toBeFalse(); + expect(restored.resolvePendingFloatingCriticalLocation('chance:floating', 'LA')).toBeTrue(); + expect(restored.getPendingCriticalHit('chance:floating')).toEqual(jasmine.objectContaining({ + targetLocation: 'LA', + chanceOrigin: { throughArmorHitArc: 'right' }, + })); + expect(restored.getPendingCriticalHit('chance:floating')?.floatingLocation).toBeUndefined(); + }); + + it('atomically undoes an untouched chance-to-hit transition and locks it after one hit', () => { + const { turnState } = createTurnStateHarness(); + expect(turnState.queuePendingCriticalChance({ + id: 'chance:undo', + location: 'LT', + locationDestroyed: true, + consolidateImmediately: true, + explosionProtection: 'case-ii', + hardenedArmorApplies: false, + pilotDamageGroup: 'combat:test', + roll: [5, 5], + result: 2, + })).toBeTrue(); + + expect(turnState.replacePendingCriticalChanceWithHits({ + id: 'chance:undo', + targetLocation: 'LT', + remainingHits: 2, + caseII: { status: 'pending' }, + })).toBeTrue(); + expect(turnState.getPendingCriticalHit('chance:undo')).toEqual({ + type: 'mek-critical-hit', + id: 'chance:undo', + location: 'LT', + targetLocation: 'LT', + remainingHits: 2, + locationDestroyed: true, + consolidateImmediately: true, + pilotDamageGroup: 'combat:test', + chanceOrigin: { + explosionProtection: 'case-ii', + hardenedArmorApplies: false, + }, + caseII: { status: 'pending' }, + }); + + const { turnState: restored } = createTurnStateHarness(); + restored.update(turnState.serialize()); + expect(restored.replacePendingCriticalHitWithChance('chance:undo')).toBeTrue(); + expect(restored.getPendingCriticalChance('chance:undo')).toEqual({ + type: 'mek-critical-chance', + id: 'chance:undo', + location: 'LT', + locationDestroyed: true, + consolidateImmediately: true, + pilotDamageGroup: 'combat:test', + explosionProtection: 'case-ii', + hardenedArmorApplies: false, + }); + + expect(restored.replacePendingCriticalChanceWithHits({ + id: 'chance:undo', + targetLocation: 'LT', + remainingHits: 2, + })).toBeTrue(); + expect(restored.resolvePendingCriticalHit('chance:undo')).toBeTrue(); + expect(restored.getPendingCriticalHit('chance:undo')?.chanceOrigin).toBeUndefined(); + expect(restored.replacePendingCriticalHitWithChance('chance:undo')).toBeFalse(); + }); + + it('preserves one ordered critical sequence across chance-to-hit replacement', () => { + const { turnState } = createTurnStateHarness(); + expect(turnState.queuePendingCriticalChance({ + id: 'chance:first', + location: 'LT', + })).toBeTrue(); + expect(turnState.queuePendingCriticalHits({ + id: 'hit:second', + location: 'CT', + targetLocation: 'CT', + remainingHits: 1, + })).toBeTrue(); + + expect(turnState.getNextPendingCriticalEvent()?.id).toBe('chance:first'); + expect(turnState.replacePendingCriticalChanceWithHits({ + id: 'chance:first', + targetLocation: 'LT', + remainingHits: 1, + })).toBeTrue(); + expect(turnState.getNextPendingCriticalEvent()).toEqual(jasmine.objectContaining({ + type: 'mek-critical-hit', + id: 'chance:first', + })); + + expect(turnState.resolvePendingCriticalHit('chance:first')).toBeTrue(); + expect(turnState.getNextPendingCriticalEvent()?.id).toBe('hit:second'); + }); + + it('round-trips resumable checks and exposes work at its absolute ready turn', () => { + const { turnState } = createTurnStateHarness(); + expect(turnState.queuePendingUnitCheck({ + id: 'recovery:1', + kind: 'consciousness-recovery', + crewId: 0, + target: 6, + readyTurn: 1, + })).toBeTrue(); + expect(turnState.pendingUnitCheckCount()).toBe(0); + + const { turnState: nextTurn } = createTurnStateHarness({ turnCounter: 1 }); + nextTurn.update(turnState.serialize()); + expect(nextTurn.pendingUnitCheckCount()).toBe(1); + expect(nextTurn.setPendingUnitCheckOutcome('recovery:1', 'success', [3, 4])).toBeTrue(); + + const { turnState: restored } = createTurnStateHarness(); + restored.update(nextTurn.serialize()); + expect(restored.getPendingUnitChecks()).toEqual([{ + type: 'unit-check', + id: 'recovery:1', + kind: 'consciousness-recovery', + crewId: 0, + target: 6, + readyTurn: 1, + result: { kind: 'roll', dice: [3, 4] }, + }]); + expect(restored.dirty()).toBeFalse(); + expect(restored.dirtyPhase()).toBeFalse(); + }); + + it('auto-fails later consciousness and seatbelt rows after consciousness fails', () => { + const { turnState } = createTurnStateHarness({ rulesId: 'tw' }); + const group = 'immediate:test'; + expect(turnState.queuePendingUnitCheck({ + id: 'consciousness:first', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: group, + target: 3, + })).toBeTrue(); + expect(turnState.queuePendingUnitCheck({ + id: 'seatbelt:next', + kind: 'seatbelt', + crewId: 0, + target: 5, + })).toBeTrue(); + expect(turnState.queuePendingUnitCheck({ + id: 'consciousness:next', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: group, + target: 5, + })).toBeTrue(); + expect(turnState.setPendingUnitCheckOutcome('seatbelt:next', 'success')).toBeTrue(); + expect(turnState.setPendingUnitCheckOutcome('consciousness:next', 'success')).toBeTrue(); + + expect(turnState.setPendingUnitCheckOutcome('consciousness:first', 'failed')).toBeTrue(); + + expect(turnState.getPendingUnitCheck('seatbelt:next')).toEqual(jasmine.objectContaining({ + target: 5, + result: { kind: 'automatic', outcome: 'failed' }, + })); + expect(turnState.getPendingUnitCheck('consciousness:next')).toEqual(jasmine.objectContaining({ + target: 5, + result: { kind: 'automatic', outcome: 'failed' }, + })); + + const { turnState: restored } = createTurnStateHarness({ rulesId: 'tw' }); + restored.update(turnState.serialize()); + expect(restored.getPendingUnitCheck('seatbelt:next')?.result) + .toEqual({ kind: 'automatic', outcome: 'failed' }); + expect(restored.getPendingUnitCheck('consciousness:next')?.result) + .toEqual({ kind: 'automatic', outcome: 'failed' }); + + expect(restored.setPendingUnitCheckOutcome('consciousness:first', 'success')).toBeTrue(); + expect(restored.getPendingUnitCheck('seatbelt:next')?.result).toBeUndefined(); + expect(restored.getPendingUnitCheck('consciousness:next')?.result).toBeUndefined(); + }); + + it('keeps later unit checks hidden while a critical chain is pending', () => { + const { turnState } = createTurnStateHarness(); + expect(turnState.queuePendingUnitCheck({ + id: 'heat:1', + kind: 'heat-shutdown', + target: 4, + })).toBeTrue(); + expect(turnState.pendingUnitCheckCount()).toBe(1); + + expect(turnState.queuePendingCriticalChance({ + id: 'critical:1', + location: 'CT', + })).toBeTrue(); + + expect(turnState.pendingUnitCheckCount()).toBe(0); + expect(turnState.discardPendingCriticalChance('critical:1')).toBeTrue(); + expect(turnState.pendingUnitCheckCount()).toBe(1); + }); + + it('opens Core combat consciousness only after closing its phase and retains the critical origin', () => { + const { turnState } = createTurnStateHarness(); + turnState.moveMode.set('stationary'); + const group = turnState.currentPilotDamageGroup(); + expect(turnState.queuePendingUnitCheck({ + id: 'consciousness:1', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: group, + target: 5, + })).toBeTrue(); + expect(turnState.queuePendingCriticalChance({ + id: 'critical:1', + location: 'CT', + pilotDamageGroup: group, + })).toBeTrue(); + + expect(turnState.actionablePendingUnitChecks()).toEqual([]); + turnState.completePilotDamagePhase(); + + expect(turnState.getPendingUnitCheck('consciousness:1')?.pilotDamageGroup) + .toBe(`phase-closed:${group}`); + expect(turnState.getPendingCriticalChance('critical:1')?.pilotDamageGroup) + .toBe(`phase-closed:${group}`); + expect(turnState.discardPendingCriticalChance('critical:1')).toBeTrue(); + expect(turnState.pendingUnitCheckCount()).toBe(1); + }); + + it('offers open Core combat consciousness to END PHASE without closing the group early', () => { + const { turnState } = createTurnStateHarness(); + turnState.moveMode.set('stationary'); + const group = turnState.currentPilotDamageGroup(); + expect(turnState.queuePendingUnitCheck({ + id: 'consciousness:phase-end', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: group, + target: 5, + })).toBeTrue(); + + expect(turnState.pendingUnitCheckCount()).toBe(0); + expect(turnState.pendingUnitCheckCountAtPhaseEnd()).toBe(1); + expect(turnState.getPendingUnitCheck('consciousness:phase-end')?.pilotDamageGroup) + .toBe(group); + }); + + it('uses immediately actionable consciousness checks without a tracked phase boundary', () => { + const { turnState } = createTurnStateHarness({ phaseTracking: false }); + turnState.moveMode.set('stationary'); + const group = turnState.currentPilotDamageGroup(); + expect(turnState.queuePendingUnitCheck({ + id: 'consciousness:1', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: group, + target: 5, + })).toBeTrue(); + + expect(group).toMatch(/^immediate:/); + expect(turnState.actionablePendingUnitChecks().map(check => check.id)) + .toEqual(['consciousness:1']); + expect(turnState.pendingUnitCheckCount()).toBe(1); + }); + + it('refreshes a deferred consciousness recovery target from current pilot damage', () => { + const { turnState } = createTurnStateHarness({ turnCounter: 1 }); + const unit = turnState.unitState.unit; + (unit as unknown as { getCrewMember(id: number): unknown }).getCrewMember = () => ({ + getState: () => 'unconscious', + getHits: () => 4, + }); + expect(turnState.queuePendingUnitCheck({ + id: 'recovery:1', + kind: 'consciousness-recovery', + crewId: 0, + target: 3, + readyTurn: 1, + })).toBeTrue(); + + turnState.refreshPendingUnitCheckTargets(); + + expect(turnState.getPendingUnitCheck('recovery:1')).toEqual({ + type: 'unit-check', + id: 'recovery:1', + kind: 'consciousness-recovery', + crewId: 0, + target: 10, + readyTurn: 1, + }); + }); + + it('re-evaluates a persisted recovery roll when later pilot damage changes its target', () => { + const { turnState } = createTurnStateHarness(); + const unit = turnState.unitState.unit; + (unit as unknown as { getCrewMember(id: number): unknown }).getCrewMember = () => ({ + getState: () => 'unconscious', + getHits: () => 4, + }); + expect(turnState.queuePendingUnitCheck({ + id: 'recovery:rolled', + kind: 'consciousness-recovery', + crewId: 0, + target: 7, + readyTurn: 0, + result: { kind: 'roll', dice: [3, 3] }, + })).toBeTrue(); + + turnState.refreshPendingUnitCheckTargets(); + + expect(turnState.getPendingUnitCheck('recovery:rolled')).toEqual({ + type: 'unit-check', + id: 'recovery:rolled', + kind: 'consciousness-recovery', + crewId: 0, + target: 10, + readyTurn: 0, + result: { kind: 'roll', dice: [3, 3] }, + }); + }); + + it('retargets an aggregated Core Heat Phase consciousness roll after a pilot-hit correction', () => { + const { turnState } = createTurnStateHarness(); + const unit = turnState.unitState.unit; + (unit as unknown as { getCrewMember(id: number): unknown }).getCrewMember = () => ({ + getState: () => 'healthy', + getHits: () => 2, + }); + expect(turnState.queuePendingUnitCheck({ + id: 'consciousness:heat', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: 'turn-closed:heat:end-turn:test', + target: 7, + })).toBeTrue(); + + turnState.refreshPendingUnitCheckTargets(); + + expect(turnState.getPendingUnitCheck('consciousness:heat')).toEqual({ + type: 'unit-check', + id: 'consciousness:heat', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: 'turn-closed:heat:end-turn:test', + target: 5, + }); + }); + + it('clears a persisted manual recovery choice when later damage changes its target', () => { + const { turnState } = createTurnStateHarness(); + const unit = turnState.unitState.unit; + (unit as unknown as { getCrewMember(id: number): unknown }).getCrewMember = () => ({ + getState: () => 'unconscious', + getHits: () => 4, + }); + expect(turnState.queuePendingUnitCheck({ + id: 'recovery:manual', + kind: 'consciousness-recovery', + crewId: 0, + target: 7, + readyTurn: 0, + result: { kind: 'manual', outcome: 'success' }, + })).toBeTrue(); + + turnState.refreshPendingUnitCheckTargets(); + + expect(turnState.getPendingUnitCheck('recovery:manual')).toEqual({ + type: 'unit-check', + id: 'recovery:manual', + kind: 'consciousness-recovery', + crewId: 0, + target: 10, + readyTurn: 0, + }); + }); + + it('keeps automatic rule results immutable until they are applied', () => { + const { turnState } = createTurnStateHarness(); + expect(turnState.queuePendingUnitCheck({ + id: 'life-support:1', + kind: 'heat-life-support', + result: { kind: 'automatic', outcome: 'failed' }, + hits: 2, + })).toBeTrue(); + + expect(turnState.setPendingUnitCheckOutcome('life-support:1', 'success')).toBeFalse(); + expect(turnState.getPendingUnitCheck('life-support:1')).toEqual({ + type: 'unit-check', + id: 'life-support:1', + kind: 'heat-life-support', + result: { kind: 'automatic', outcome: 'failed' }, + hits: 2, + }); + }); + + it('turns a pending seatbelt roll into automatic failure if its crew becomes unconscious', () => { + const { turnState } = createTurnStateHarness(); + const unit = turnState.unitState.unit; + (unit as unknown as { getCrewMember(id: number): unknown }).getCrewMember = () => ({ + getState: () => 'unconscious', + }); + expect(turnState.queuePendingUnitCheck({ + id: 'seatbelt:unconscious', + kind: 'seatbelt', + crewId: 0, + target: 6, + })).toBeTrue(); + + turnState.refreshPendingUnitCheckTargets(); + + expect(turnState.getPendingUnitCheck('seatbelt:unconscious')).toEqual({ + type: 'unit-check', + id: 'seatbelt:unconscious', + kind: 'seatbelt', + crewId: 0, + result: { kind: 'automatic', outcome: 'failed' }, + }); + }); + + it('keeps Aero control recovery pending while an unconscious pilot can still wake', () => { + const { turnState } = createTurnStateHarness({ + rulesType: 'aero', + rulesId: 'tw', + crewState: 'unconscious', + outOfControl: true, + unit: { type: 'Aero' }, + }); + expect(turnState.queuePendingUnitCheck({ + id: 'control:unconscious', + kind: 'aero-control-recovery', + target: 5, + readyTurn: 0, + })).toBeTrue(); + + turnState.refreshPendingUnitCheckTargets(); + + expect(turnState.getPendingUnitCheck('control:unconscious')).toEqual({ + type: 'unit-check', + id: 'control:unconscious', + kind: 'aero-control-recovery', + readyTurn: 0, + result: { kind: 'automatic', outcome: 'failed' }, + }); + }); + + it('discards an impossible Aero control recovery after its controller is permanently gone', () => { + const { turnState } = createTurnStateHarness({ + rulesType: 'aero', + rulesId: 'tw', + crewState: 'ejected', + outOfControl: true, + unit: { type: 'Aero' }, + }); + expect(turnState.queuePendingUnitCheck({ + id: 'control:ejected', + kind: 'aero-control-recovery', + target: 5, + readyTurn: 0, + })).toBeTrue(); + + turnState.refreshPendingUnitCheckTargets(); + + expect(turnState.getPendingUnitCheck('control:ejected')).toBeUndefined(); + }); + + it('keeps pending critical IDs unique and rejects invalid rolls', () => { + const { turnState } = createTurnStateHarness(); + const pending = { id: 'critical:1', location: 'LT', targetLocation: 'LT', remainingHits: 1 }; + + expect(turnState.queuePendingCriticalHits(pending)).toBeTrue(); + expect(turnState.queuePendingCriticalHits(pending)).toBeFalse(); + expect(turnState.setPendingCriticalRoll('critical:1', [0, 7])).toBeFalse(); + expect(turnState.setPendingCriticalRoll('missing', [1, 1])).toBeFalse(); + expect(turnState.pendingCriticalHitCount()).toBe(1); + }); + it('persists disabled movement PSRs while omitting other false and empty state', () => { const { turnState } = createTurnStateHarness(); turnState.airborne.set(false); @@ -513,7 +1318,7 @@ describe('TurnState', () => { const serialized = turnState.serialize(); - expect(serialized).toEqual({ applyMovePSR: false }); + expect(turnState.serialize()).toEqual({ applyMovePSR: false }); const { turnState: restored } = createTurnStateHarness(); restored.update(serialized); @@ -586,6 +1391,101 @@ describe('TurnState', () => { expect(turnState.moveDistance()).toBe(8); }); + + it('keeps the movement-distance range editable while prone in either ruleset', () => { + for (const rulesId of ['core2026', 'tw'] as const) { + const { turnState } = createTurnStateHarness({ + prone: true, + rulesId, + unit: { walk: 4, walk2: 4, run: 6, run2: 6 }, + }); + turnState.moveMode.set('walk'); + turnState.moveDistance.set(1); + + expect(turnState.movementCapacityCurrentMoveMode()).withContext(rulesId).toBe(4); + expect(turnState.maxDistanceCurrentMoveMode()).withContext(rulesId).toBe(4); + + turnState.clampMoveDistanceToCurrentModeRange(); + + expect(turnState.moveDistance()).withContext(rulesId).toBe(1); + } + }); + + it('retains Run 2 capacity for a Core Quad with three destroyed legs', () => { + const { turnState, rules } = createTurnStateHarness({ + prone: false, + internalLocations: ['FLL', 'FRL', 'RLL', 'RRL'], + committedDestroyedLegs: ['FLL', 'FRL', 'RLL'], + unit: { walk: 4, walk2: 4, run: 6, run2: 6 }, + }); + turnState.moveMode.set('run'); + + expect((rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 1, run: 2 })); + expect(turnState.movementCapacityCurrentMoveMode()).toBe(2); + expect(turnState.maxDistanceCurrentMoveMode()).toBe(2); + }); + + it('retains Run 2 capacity for a prone Core Quad with three destroyed legs', () => { + const { turnState, rules } = createTurnStateHarness({ + prone: true, + internalLocations: ['FLL', 'FRL', 'RLL', 'RRL'], + committedDestroyedLegs: ['FLL', 'FRL', 'RLL'], + unit: { walk: 4, walk2: 4, run: 6, run2: 6 }, + }); + turnState.moveMode.set('run'); + + expect((rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 1, run: 2 })); + expect(turnState.movementCapacityCurrentMoveMode()).toBe(2); + expect(turnState.maxDistanceCurrentMoveMode()).toBe(2); + }); + + it('preserves movement already completed when the unit becomes prone', () => { + for (const rulesId of ['core2026', 'tw'] as const) { + const { turnState } = createTurnStateHarness({ + prone: true, + rulesId, + unit: { walk: 4, walk2: 4, run: 6, run2: 6 }, + }); + turnState.moveMode.set('run'); + turnState.moveDistance.set(6); + + expect(turnState.movementCapacityCurrentMoveMode()).withContext(rulesId).toBe(6); + expect(turnState.maxDistanceCurrentMoveMode()).withContext(rulesId).toBe(6); + + turnState.clampMoveDistanceToCurrentModeRange(); + + expect(turnState.moveDistance()).withContext(rulesId).toBe(6); + } + }); + + it('subtracts two movement points per stand attempt in Core and TW', () => { + for (const rulesId of ['core2026', 'tw'] as const) { + const { turnState } = createTurnStateHarness({ + rulesId, + unit: { walk: 4, walk2: 4, run: 6, run2: 6 }, + }); + turnState.moveMode.set('walk'); + turnState.moveDistance.set(4); + + turnState.adjustStandAttempts(1); + + expect(turnState.movementCapacityCurrentMoveMode()).withContext(rulesId).toBe(4); + expect(turnState.maxDistanceCurrentMoveMode()).withContext(rulesId).toBe(2); + expect(turnState.moveDistance()).withContext(rulesId).toBe(2); + + turnState.adjustStandAttempts(1); + + expect(turnState.maxDistanceCurrentMoveMode()).withContext(rulesId).toBe(0); + expect(turnState.moveDistance()).withContext(rulesId).toBe(0); + + turnState.moveMode.set('run'); + + expect(turnState.movementCapacityCurrentMoveMode()).withContext(rulesId).toBe(6); + expect(turnState.maxDistanceCurrentMoveMode()).withContext(rulesId).toBe(2); + } + }); }); describe('standing up', () => { @@ -602,22 +1502,149 @@ describe('TurnState', () => { expect(turnState.standAttempts()).toBe(0); }); + it('reconciles stand-attempt heat through the selected rules', () => { + const core = createTurnStateHarness(); + core.turnState.moveMode.set('run'); + core.turnState.acknowledgeHeatSources(); + + core.turnState.adjustStandAttempts(1); + + expect(core.turnState.heatSources()).toEqual([]); + + const tw = createTurnStateHarness({ rulesId: 'tw' }); + tw.turnState.moveMode.set('run'); + tw.turnState.acknowledgeHeatSources(); + + tw.turnState.adjustStandAttempts(1); + + expect(getMovementHeat(tw.turnState)).toBe(3); + tw.turnState.acknowledgeHeatSources(); + + tw.turnState.resetStandAttempts(); + + expect(getMovementHeat(tw.turnState)).toBe(2); + tw.turnState.acknowledgeHeatSources(); + + tw.turnState.resetStandAttempts(); + + expect(tw.turnState.heatSources()).toEqual([]); + }); + it('records outcomes and removes prone only after success', () => { const { turnState } = createTurnStateHarness({ prone: true }); expect(turnState.resolveStandAttempt('failed')).toBeTrue(); expect(turnState.standAttempts()).toBe(1); expect(turnState.unitState.unit.setCondition).not.toHaveBeenCalled(); + expect(turnState.unitState.unit.queueFall).toHaveBeenCalledOnceWith('stand-attempt'); expect(turnState.resolveStandAttempt('success')).toBeTrue(); expect(turnState.standAttempts()).toBe(2); expect(turnState.unitState.unit.setCondition).toHaveBeenCalledOnceWith('prone', false); }); - it('disables standing for stationary movement and too many destroyed legs', () => { + it('supports careful stand only in TW and requires at least three remaining Walking MP', () => { + const core = createTurnStateHarness({ + prone: true, + rulesId: 'core2026', + unit: { walk: 5, walk2: 5, run: 8, run2: 8 }, + }); + const exactThreshold = createTurnStateHarness({ + prone: true, + rulesId: 'tw', + unit: { walk: 3, walk2: 3, run: 5, run2: 5 }, + }); + const belowThreshold = createTurnStateHarness({ + prone: true, + rulesId: 'tw', + unit: { walk: 2, walk2: 2, run: 3, run2: 3 }, + }); + const threeRemaining = createTurnStateHarness({ + prone: true, + rulesId: 'tw', + unit: { walk: 5, walk2: 5, run: 8, run2: 8 }, + }); + const twoRemaining = createTurnStateHarness({ + prone: true, + rulesId: 'tw', + unit: { walk: 4, walk2: 4, run: 6, run2: 6 }, + }); + threeRemaining.turnState.adjustStandAttempts(1); + twoRemaining.turnState.adjustStandAttempts(1); + + expect(core.rules.supportsCarefulStand).toBeFalse(); + expect(core.rules.canCarefulStand(core.turnState)).toBeFalse(); + expect(core.turnState.resolveStandAttempt('failed', { carefulStand: true })).toBeFalse(); + expect(core.turnState.standAttempts()).toBeUndefined(); + + expect(exactThreshold.rules.supportsCarefulStand).toBeTrue(); + expect(exactThreshold.rules.canCarefulStand(exactThreshold.turnState)).toBeTrue(); + expect(belowThreshold.rules.canCarefulStand(belowThreshold.turnState)).toBeFalse(); + expect(threeRemaining.rules.canCarefulStand(threeRemaining.turnState)).toBeTrue(); + expect(twoRemaining.rules.canCarefulStand(twoRemaining.turnState)).toBeFalse(); + expect(belowThreshold.turnState.resolveStandAttempt('failed', { carefulStand: true })).toBeFalse(); + expect(belowThreshold.turnState.standAttempts()).toBeUndefined(); + }); + + it('spends the entire phase on a careful stand whether it succeeds or fails', () => { + for (const outcome of ['success', 'failed'] as const) { + const { turnState, rules } = createTurnStateHarness({ + prone: true, + rulesId: 'tw', + unit: { walk: 5, walk2: 5, run: 8, run2: 8 }, + }); + turnState.moveMode.set('stationary'); + + expect(turnState.resolveStandAttempt(outcome, { carefulStand: true })) + .withContext(outcome) + .toBeTrue(); + expect(turnState.carefulStand()).withContext(outcome).toBeTrue(); + expect(turnState.moveMode()).withContext(outcome).toBe('walk'); + expect(turnState.standAttempts()).withContext(outcome).toBe(1); + expect(turnState.movementCapacityCurrentMoveMode()).withContext(outcome).toBe(5); + expect(rules.getMovementPointsSpent(turnState)).withContext(outcome).toBe(5); + expect(turnState.maxDistanceCurrentMoveMode()).withContext(outcome).toBe(0); + expect(turnState.canStandUp()).withContext(outcome).toBeFalse(); + } + }); + + it('retains Run as the worse movement mode when making a careful stand', () => { + const { turnState, rules } = createTurnStateHarness({ + prone: true, + rulesId: 'tw', + unit: { walk: 5, walk2: 5, run: 8, run2: 8 }, + }); + turnState.moveMode.set('run'); + turnState.moveDistance.set(0); + + expect(turnState.resolveStandAttempt('failed', { carefulStand: true })).toBeTrue(); + + expect(turnState.moveMode()).toBe('run'); + expect(turnState.movementCapacityCurrentMoveMode()).toBe(8); + expect(rules.getMovementPointsSpent(turnState)).toBe(8); + expect(turnState.maxDistanceCurrentMoveMode()).toBe(0); + + turnState.adjustStandAttempts(-1); + + expect(turnState.carefulStand()).toBeFalse(); + expect(turnState.standAttempts()).toBe(0); + expect(turnState.movementCapacityCurrentMoveMode()).toBe(8); + expect(rules.getMovementPointsSpent(turnState)).toBe(0); + expect(turnState.maxDistanceCurrentMoveMode()).toBe(8); + }); + + it('applies the Core and TW limb requirements for standing', () => { const stationary = createTurnStateHarness({ prone: true }); stationary.turnState.moveMode.set('stationary'); - expect(stationary.turnState.canStandUp()).toBeFalse(); + expect(stationary.turnState.canStandUp()).toBeTrue(); + expect(stationary.turnState.prepareStandAttempt()).toBeTrue(); + expect(stationary.turnState.moveMode()).toBe('walk'); + expect(stationary.turnState.moveDistance()).toBe(0); + + expect(createTurnStateHarness({ + prone: true, + currentDestroyedLegs: ['LL'], + }).turnState.canStandUp()).toBeTrue(); expect(createTurnStateHarness({ prone: true, @@ -634,14 +1661,156 @@ describe('TurnState', () => { prone: true, internalLocations: ['FLL', 'FRL', 'RLL', 'RRL'], currentDestroyedLegs: ['FLL', 'FRL', 'RLL'], + }).turnState.canStandUp()).toBeTrue(); + + expect(createTurnStateHarness({ + prone: true, + rulesId: 'tw', + internalLocations: ['FLL', 'FRL', 'RLL', 'RRL'], + currentDestroyedLegs: ['FLL', 'FRL', 'RLL'], + }).turnState.canStandUp()).toBeFalse(); + + expect(createTurnStateHarness({ + prone: true, + internalLocations: ['FLL', 'FRL', 'RLL', 'RRL'], + currentDestroyedLegs: ['FLL', 'FRL', 'RLL', 'RRL'], + }).turnState.canStandUp()).toBeFalse(); + + expect(createTurnStateHarness({ + prone: true, + rulesId: 'tw', + internalLocations: ['LA', 'RA', 'LL', 'RL'], + currentDestroyedLegs: ['LA', 'RA'], + }).turnState.canStandUp()).toBeTrue(); + + expect(createTurnStateHarness({ + prone: true, + rulesId: 'tw', + internalLocations: ['LA', 'RA', 'LL', 'RL'], + currentDestroyedLegs: ['LA', 'RA', 'LL'], }).turnState.canStandUp()).toBeFalse(); }); + it('classifies each TW destroyed-leg exception as running and reports its one-attempt limit', () => { + const scenarios = [ + { label: 'biped with one leg', locations: ['LL', 'RL'], destroyed: ['LL'] }, + { label: 'tripod with two legs', locations: ['LL', 'CL', 'RL'], destroyed: ['LL'] }, + { + label: 'quad with two legs', + locations: ['FLL', 'FRL', 'RLL', 'RRL'], + destroyed: ['FLL', 'FRL'], + }, + ]; + + for (const scenario of scenarios) { + const { turnState, rules } = createTurnStateHarness({ + prone: true, + rulesId: 'tw', + internalLocations: scenario.locations, + committedDestroyedLegs: scenario.destroyed, + unit: { walk: 5, walk2: 5, run: 8, run2: 8, jump: 0, umu: 0, heat: 0 }, + }); + turnState.moveMode.set('walk'); + + expect((rules as MekRules).movementState()) + .withContext(scenario.label) + .toEqual(jasmine.objectContaining({ walk: 1, run: 0 })); + expect(rules.isMotiveModeAvailable('run')).withContext(scenario.label).toBeTrue(); + expect(turnState.canStandUp()).withContext(scenario.label).toBeTrue(); + expect(rules.getStandAttemptLimit(turnState)).withContext(scenario.label).toBe(1); + + expect(turnState.prepareStandAttempt()).withContext(scenario.label).toBeTrue(); + + expect(turnState.moveMode()).withContext(scenario.label).toBe('run'); + expect(turnState.moveDistance()).withContext(scenario.label).toBe(0); + expect(turnState.movementCapacityCurrentMoveMode()).withContext(scenario.label).toBe(1); + expect(turnState.maxDistanceCurrentMoveMode()).withContext(scenario.label).toBe(1); + expect(turnState.standAttempts()).withContext(scenario.label).toBeUndefined(); + + expect(turnState.resolveStandAttempt('failed')).withContext(scenario.label).toBeTrue(); + + expect(getMovementHeat(turnState)).withContext(scenario.label).toBe(3); + expect(turnState.standAttempts()).withContext(scenario.label).toBe(1); + expect(turnState.movementCapacityCurrentMoveMode()).withContext(scenario.label).toBe(1); + expect(turnState.maxDistanceCurrentMoveMode()).withContext(scenario.label).toBe(0); + expect(turnState.canStandUp()).withContext(scenario.label).toBeTrue(); + } + }); + + it('classifies a Core one-legged stand as running while retaining ordinary Run movement', () => { + const { turnState, rules } = createTurnStateHarness({ + prone: true, + committedDestroyedLegs: ['LL'], + unit: { walk: 5, walk2: 5, run: 8, run2: 8, jump: 0, umu: 0, heat: 0 }, + }); + turnState.moveMode.set('walk'); + + expect((rules as MekRules).movementState()) + .toEqual(jasmine.objectContaining({ walk: 1, run: 2 })); + expect(rules.isMotiveModeAvailable('run')).toBeTrue(); + + expect(turnState.prepareStandAttempt()).toBeTrue(); + + expect(turnState.moveMode()).toBe('run'); + expect(turnState.moveDistance()).toBe(0); + + expect(turnState.resolveStandAttempt('failed')).toBeTrue(); + + expect(getMovementHeat(turnState)).toBe(2); + }); + + it('does not apply the TW one-attempt exception to a quad with only one destroyed leg', () => { + const { turnState } = createTurnStateHarness({ + prone: true, + rulesId: 'tw', + internalLocations: ['FLL', 'FRL', 'RLL', 'RRL'], + committedDestroyedLegs: ['FLL'], + unit: { walk: 5, walk2: 5, run: 8, run2: 8, jump: 0, umu: 0, heat: 0 }, + }); + turnState.moveMode.set('walk'); + + expect(turnState.unitState.unit.rules.getStandAttemptLimit(turnState)).toBeNull(); + + expect(turnState.resolveStandAttempt('failed')).toBeTrue(); + + expect(turnState.moveMode()).toBe('walk'); + expect(turnState.canStandUp()).toBeTrue(); + }); + + it('does not classify TW units that cannot stand as destroyed-leg stand exceptions', () => { + const scenarios = [ + { label: 'biped with no legs', locations: ['LL', 'RL'], destroyed: ['LL', 'RL'] }, + { + label: 'quad with one leg', + locations: ['FLL', 'FRL', 'RLL', 'RRL'], + destroyed: ['FLL', 'FRL', 'RLL'], + }, + ]; + + for (const scenario of scenarios) { + const { turnState, rules } = createTurnStateHarness({ + prone: true, + rulesId: 'tw', + internalLocations: scenario.locations, + committedDestroyedLegs: scenario.destroyed, + }); + turnState.moveMode.set('walk'); + + expect(turnState.canStandUp()).withContext(scenario.label).toBeFalse(); + expect(rules.getStandAttemptLimit(turnState)).withContext(scenario.label).toBeNull(); + expect(rules.getStandAttemptMovementMode(turnState)).withContext(scenario.label).toBe('walk'); + } + }); + it('lets only an intact quad stand without a PSR', () => { const quad = createTurnStateHarness({ prone: true, internalLocations: ['FLL', 'FRL', 'RLL', 'RRL'], }); + const standingQuad = createTurnStateHarness({ + prone: false, + internalLocations: ['FLL', 'FRL', 'RLL', 'RRL'], + }); const damagedQuad = createTurnStateHarness({ prone: true, internalLocations: ['FLL', 'FRL', 'RLL', 'RRL'], @@ -650,9 +1819,21 @@ describe('TurnState', () => { const biped = createTurnStateHarness({ prone: true }); expect(quad.turnState.canStandWithoutPSR()).toBeTrue(); + expect(standingQuad.turnState.canStandUp()).toBeFalse(); + expect(standingQuad.turnState.canStandWithoutPSR()).toBeTrue(); expect(damagedQuad.turnState.canStandWithoutPSR()).toBeFalse(); expect(biped.turnState.canStandWithoutPSR()).toBeFalse(); }); + + it('does not apply Mek standing rules to other unit types', () => { + const infantry = createTurnStateHarness({ prone: true, rulesType: 'infantry' }); + const aero = createTurnStateHarness({ prone: true, rulesType: 'aero' }); + + expect(infantry.turnState.canStandUp()).toBeFalse(); + expect(infantry.turnState.canStandWithoutPSR()).toBeFalse(); + expect(aero.turnState.canStandUp()).toBeFalse(); + expect(aero.turnState.canStandWithoutPSR()).toBeFalse(); + }); }); describe('getPSRChecks', () => { @@ -696,6 +1877,70 @@ describe('TurnState', () => { expect(getReasons(turnState)).toContain('Leg Actuator hit'); expect(getReasons(turnState)).not.toContain('Jumping with damaged leg actuator'); }); + + it('does not expose separate TW movement fall PSRs while prone during a destroyed-leg stand', () => { + const scenarios = [ + { + label: 'biped with damaged gyro', + locations: ['LL', 'RL'], + destroyedLegs: ['LL'], + crit: createCritSlot('Gyro', 'CT', { destroyed: 1 }), + movementReason: 'Running with damaged gyro', + }, + { + label: 'biped with damaged hip', + locations: ['LL', 'RL'], + destroyedLegs: ['LL'], + crit: createCritSlot('Hip', 'RL', { destroyed: 1 }), + movementReason: 'Running with damaged hip', + }, + { + label: 'quad with damaged gyro', + locations: ['FLL', 'FRL', 'RLL', 'RRL'], + destroyedLegs: ['FLL', 'FRL'], + crit: createCritSlot('Gyro', 'CT', { destroyed: 1 }), + movementReason: 'Running with damaged gyro', + }, + ]; + + for (const scenario of scenarios) { + const { turnState, rules } = createTurnStateHarness({ + prone: true, + rulesId: 'tw', + internalLocations: scenario.locations, + committedDestroyedLegs: scenario.destroyedLegs, + critSlots: [scenario.crit], + unit: { subtype: 'BattleMek' }, + }); + turnState.moveMode.set('run'); + turnState.moveDistance.set(0); + + const standModifier = rules.PSRModifiers().modifier; + expect(getReasons(turnState)).withContext(scenario.label).not.toContain(scenario.movementReason); + expect(turnState.PSRRollsCount()).withContext(scenario.label).toBe(0); + + expect(turnState.resolveStandAttempt('failed')).withContext(scenario.label).toBeTrue(); + + expect(getReasons(turnState)).withContext(scenario.label).not.toContain(scenario.movementReason); + expect(turnState.PSRRollsCount()).withContext(scenario.label).toBe(0); + expect(rules.PSRModifiers().modifier).withContext(scenario.label).toBe(standModifier); + } + }); + + it('does not create an ordinary TW running fall PSR after a failed stand', () => { + const { turnState } = createTurnStateHarness({ + prone: true, + rulesId: 'tw', + critSlots: [createCritSlot('Gyro', 'CT', { destroyed: 1 })], + }); + turnState.moveMode.set('run'); + turnState.moveDistance.set(0); + + expect(turnState.resolveStandAttempt('failed')).toBeTrue(); + + expect(getReasons(turnState)).not.toContain('Running with damaged gyro'); + expect(turnState.PSRRollsCount()).toBe(0); + }); }); describe('modifier breakdowns', () => { @@ -774,6 +2019,27 @@ describe('TurnState', () => { }); describe('movement distance limits', () => { + it('treats a Core Immobile unit as stationary without storing a movement selection', () => { + const { turnState } = createTurnStateHarness({ immobile: true }); + + expect(turnState.moveMode()).toBeNull(); + expect(turnState.effectiveMoveMode()).toBe('stationary'); + expect(turnState.getAttackMovementModifier()).toBe(0); + expect(turnState.missingAttackMovementModifier()).toBeFalse(); + expect(turnState.currentPhase()).toBe('W'); + expect(turnState.dirty()).toBeFalse(); + }); + + it('does not assign an effective movement mode to a TW Immobile unit', () => { + const { turnState } = createTurnStateHarness({ immobile: true, rulesId: 'tw' }); + + expect(turnState.moveMode()).toBeNull(); + expect(turnState.effectiveMoveMode()).toBeNull(); + expect(turnState.missingAttackMovementModifier()).toBeTrue(); + expect(turnState.currentPhase()).toBe('M'); + expect(turnState.dirty()).toBeFalse(); + }); + it('uses unit rules for minimum movement distance', () => { const { turnState } = createTurnStateHarness({ rulesType: 'infantry', diff --git a/src/app/models/turn-state.model.ts b/src/app/models/turn-state.model.ts index b60e8f463..b1c05f1d2 100644 --- a/src/app/models/turn-state.model.ts +++ b/src/app/models/turn-state.model.ts @@ -4,11 +4,40 @@ import { computed, signal, type WritableSignal } from "@angular/core"; import { canChangeAirborneGround, getMotiveModeMaxDistance, type MotiveModes } from "./motiveModes.model"; -import { getMekLegLocations, inferMekConfigFromLocations } from "./entity/types"; import type { CBTForceUnitState } from "./cbt-force-unit-state.model"; -import type { RuleCheckOutcome, SerializedPSRChecks, SerializedTurnState } from "./force-serialization"; +import type { + PendingEventInput, + RuleCheckOutcome, + SerializedEndTurnCheckpoint, + SerializedMekCriticalChanceResult, + SerializedPendingEvent, + SerializedPendingMekCriticalCaseII, + SerializedPendingMekCritical, + SerializedPendingMekCriticalChance, + SerializedPendingMekFloatingCriticalLocation, + SerializedPendingMekFall, + SerializedPendingUnitCheck, + SerializedPSRChecks, + SerializedTurnState, +} from "./force-serialization"; import { calculateModifierTotal, type PSRCheck, type UnitHeatSource, type UnitModifierBreakdownEntry, type UnitModifierTotal } from "./rules/unit-type-rules"; import { deserializeUnitCover, isUnitBuildingLevel, isUnitWaterDepth, resolveUnitBuildingCoverState, resolveUnitWaterState, serializeUnitCover, type UnitCover } from "./unit-cover.model"; +import { + closePilotDamagePhase, + closePilotDamageTurn, + createPilotDamageGroup, + isOpenCombatPilotDamageGroup, +} from "../utils/pilot-damage-group.util"; +import { + isAmmoExplosionCheck, + isCascadeUnitCheck, + isConsciousnessCheck, + pendingUnitCheckOutcome, + pendingUnitCheckList, + pendingUnitCheckPriority, + refreshPendingUnitCheck, + type CascadeUnitCheck, +} from '../utils/unit-check.util'; export type { PSRCheck } from "./rules/unit-type-rules"; @@ -50,6 +79,7 @@ export function calculateHeatProjection(current: number, sources: readonly UnitH } export class TurnState { + private pilotDamageGroup = createPilotDamageGroup('combat'); private static readonly HEAT_DISSIPATION_DEFICIT_SOURCE_ID = 'heat-dissipation-deficit'; unitState: CBTForceUnitState; private suppressModified = false; @@ -57,11 +87,25 @@ export class TurnState { private readonly acknowledgedHeatSources = this.modifiedSignal>({}); private readonly heatDissipationConsumed = this.modifiedSignal(0); private readonly psrOutcomes = this.modifiedSignal>({}); + private readonly pendingEvents = this.modifiedSignal([]); + private readonly endTurnCheckpoint = this.modifiedSignal(undefined); private readonly equipmentStateChanged = this.modifiedSignal(false); + /** Per-unit turn sequence, retained across phase commits. */ + private turnCounter: number; airborne = this.modifiedSignal(null, 'movement'); moveMode = this.modifiedSignal(null, 'movement'); moveDistance = this.modifiedSignal(null, 'movement'); + /** Movement mode used by rules; Core defaults an unassigned Immobile unit to stationary. */ + effectiveMoveMode = computed(() => { + const selectedMoveMode = this.moveMode(); + if (selectedMoveMode !== null) return selectedMoveMode; + const unit = this.unitState.unit; + return unit.gameRules.id === 'core2026' && unit.getCondition('immobile') + ? 'stationary' + : null; + }); standAttempts = this.modifiedSignal(undefined); + carefulStand = this.modifiedSignal(false); cover = this.modifiedSignal(undefined); private readonly waterState = computed(() => { const cover = this.cover(); @@ -91,6 +135,7 @@ export class TurnState { const moveMode = this.moveMode(); const moveDistance = this.moveDistance(); const standAttempts = this.standAttempts(); + const carefulStand = this.carefulStand(); const cover = this.cover(); const dmgReceived = this.dmgReceived(); const weaponsHeat = this.weaponsHeat(); @@ -101,6 +146,7 @@ export class TurnState { || moveMode !== null || moveDistance !== null || standAttempts !== undefined + || carefulStand || cover !== undefined || dmgReceived != 0 || weaponsHeat > 0 @@ -110,6 +156,7 @@ export class TurnState { || unconsolidatedLocations || unconsolidatedInventory || this.equipmentStateChanged() + || this.endTurnCheckpoint() !== undefined || this.passiveHeatSourceSignature() !== this.passiveHeatSourceBaseline() || Object.keys(this.acknowledgedHeatSources()).length > 0 || this.heatDissipationConsumed() > 0 @@ -147,53 +194,16 @@ export class TurnState { }); }); - canRun = computed(() => { - const unit = this.unitState.unit; - let damagedLegsCount = 0; - const internalLocations = unit.locations?.internal; - const config = inferMekConfigFromLocations(internalLocations?.keys() ?? []); - // Calculate pre-existing leg destruction modifiers. If a leg is gone, is gone. - for (const loc of getMekLegLocations(config)) { - if (!internalLocations?.has(loc)) continue; - if (unit.isInternalLocCommittedDestroyed(loc)) { - damagedLegsCount++; - } - } - return config === 'Quad' ? damagedLegsCount < 2 : damagedLegsCount < 1; - }); - - private readonly standingLegState = computed(() => { - const unit = this.unitState.unit; - const internalLocations = unit.locations?.internal; - const config = inferMekConfigFromLocations(internalLocations?.keys() ?? []); - const legs = getMekLegLocations(config); - const destroyedLegs = legs.filter(loc => - internalLocations?.has(loc) && unit.isInternalLocDestroyed(loc) - ).length; - return { config, legs, destroyedLegs, internalLocations }; - }); - canStandUp = computed(() => { - if (!this.unitState.hasCondition('prone') || this.moveMode() === 'stationary') return false; - const { config, destroyedLegs } = this.standingLegState(); - return destroyedLegs < (config === 'Quad' ? 3 : 2); + return this.unitState.unit.rules.canStandUp(this); }); canStandWithoutPSR = computed(() => { - if (!this.canStandUp()) return false; - const unit = this.unitState.unit; - const { config, legs, internalLocations } = this.standingLegState(); - return config === 'Quad' && legs.every(loc => - internalLocations?.has(loc) && !unit.isInternalLocDestroyed(loc) - ); - }); - - getSpottingModifier = computed(() => { - return this.spotting() ? this.unitState.unit.rules.getSpottingModifier() : 0; + return this.unitState.unit.rules.canStandWithoutPSR(this); }); getAttackMovementModifier = computed(() => { - return this.unitState.unit.rules.getAttackMovementModifier(this.moveMode(), this.airborne() ?? false); + return this.unitState.unit.rules.getAttackMovementModifier(this.effectiveMoveMode(), this.airborne() ?? false); }); attackMovementModifierCanApply = computed(() => { @@ -210,7 +220,7 @@ export class TurnState { }); missingAttackMovementModifier = computed(() => { - return this.moveMode() === null && this.attackMovementModifierCanApply(); + return this.effectiveMoveMode() === null && this.attackMovementModifierCanApply(); }); getAttackModifierBreakdown = computed(() => { @@ -225,13 +235,40 @@ export class TurnState { return this.unitState.unit.rules.getDefenseModifierBreakdown(this); }); - PSRRollsCount = computed(() => { + private unresolvedPSRChecks(): readonly PSRCheck[] { const outcomes = this.psrOutcomes(); return this.getPSRChecks().filter(entry => entry.fallCheck !== undefined && entry.id !== undefined && outcomes[entry.id] === undefined - ).length; + ); + } + + PSRRollsCount = computed(() => this.unresolvedPSRChecks().length); + + actionablePSRRollsCount = computed(() => { + const checks = this.unresolvedPSRChecks() + .filter(check => !this.isPSRCheckAutomaticFailure(check)); + return this.autoFall() + ? checks.filter(check => check.failureOutcome !== 'Fall').length + : checks.length; }); + automaticPSRFailure = computed(() => { + const unit = this.unitState.unit; + if (unit.rules.getActivePilotCrewId() === null) return true; + const checks = this.unresolvedPSRChecks(); + return checks.length > 0 + && checks.every(check => this.isPSRCheckAutomaticFailure(check)); + }); + + isPSRCheckAutomaticFailure(check: PSRCheck): boolean { + const unit = this.unitState.unit; + return unit.rules.getActivePilotCrewId() === null + || (unit.getUnit().type === 'Mek' + && unit.getCondition('shutdown') + && !unit.getCondition('prone') + && check.kind !== 'shutdown'); + } + getPSROutcome(checkId: string): RuleCheckOutcome | undefined { return this.psrOutcomes()[checkId]; } @@ -251,29 +288,80 @@ export class TurnState { ...current, ...Object.fromEntries(resolvedChecks.map(entry => [entry.id!, outcome])), })); - if (outcome === 'failed') this.unitState.unit.setCondition('prone', true); + if (outcome === 'failed') { + if (check.failureOutcome === 'Fall' && !this.unitState.hasCondition('prone')) { + this.unitState.unit.queueFall('psr'); + this.unitState.unit.setCondition('prone', true); + } + } return true; } - resolveStandAttempt(outcome: RuleCheckOutcome): boolean { - if (!this.canStandUp()) return false; + resolveStandAttempt(outcome: RuleCheckOutcome, options: { carefulStand?: boolean } = {}): boolean { + const carefulStand = options.carefulStand === true; + if (carefulStand && !this.unitState.unit.rules.canCarefulStand(this)) return false; + if (!this.prepareStandAttempt()) return false; this.adjustStandAttempts(1); - if (outcome === 'success') this.unitState.unit.setCondition('prone', false); + if (carefulStand) { + this.carefulStand.set(true); + this.clampMoveDistanceToCurrentModeRange(); + } + if (outcome === 'success') { + this.unitState.unit.setCondition('prone', false); + } else { + this.unitState.unit.queueFall('stand-attempt'); + } + return true; + } + + prepareStandAttempt(): boolean { + if (!this.canStandUp()) return false; + const standingMovementMode = this.unitState.unit.rules.getStandAttemptMovementMode(this); + if (standingMovementMode !== null && standingMovementMode !== this.moveMode()) { + this.moveMode.set(standingMovementMode); + if (this.moveDistance() === null) this.moveDistance.set(0); + } + return true; + } + + failPendingPSRChecks(): void { + const unresolved = this.getPSRChecks().filter(check => + check.resolution || (check.id !== undefined && this.getPSROutcome(check.id) === undefined)); + for (const check of unresolved) { + if (check.resolution) { + this.unitState.unit.resolveRuleCheck(check.resolution.key, check.resolution.token, 'failed'); + } else if (check.id) { + this.resolvePSRCheck(check.id, 'failed'); + } + } + } + + /** Applies an unavoidable fall before the phase is committed. */ + resolveAutomaticFall(): boolean { + if (!this.autoFall() || this.unitState.hasCondition('prone')) return false; + this.unitState.unit.queueFall('psr'); + this.unitState.unit.setCondition('prone', true); return true; } adjustStandAttempts(delta: number): void { if (!Number.isFinite(delta)) return; + const normalizedDelta = Math.trunc(delta); const current = this.standAttempts() ?? 0; - const next = Math.max(0, current + Math.trunc(delta)); - if (next === current) return; + const next = Math.max(0, current + normalizedDelta); + const removedCarefulStand = normalizedDelta < 0 && this.carefulStand(); + if (removedCarefulStand) this.carefulStand.set(false); + if (next === current && !removedCarefulStand) return; this.standAttempts.set(next); - if (this.unitState.unit.gameRules.id === 'tw') this.invalidateHeatSource('movement'); + this.clampMoveDistanceToCurrentModeRange(); + this.reconcileHeatSources(); } resetStandAttempts(): void { this.standAttempts.set(0); - if (this.unitState.unit.gameRules.id === 'tw') this.invalidateHeatSource('movement'); + this.carefulStand.set(false); + this.clampMoveDistanceToCurrentModeRange(); + this.reconcileHeatSources(); } setCover(cover: UnitCover | undefined): void { @@ -294,7 +382,8 @@ export class TurnState { } currentPhase = computed<'I' | 'M' | 'W' | 'P' | 'H'>(() => { - if (this.moveMode() === null || (this.moveMode() !== 'stationary' && this.moveDistance() === null)) { + const moveMode = this.effectiveMoveMode(); + if (moveMode === null || (moveMode !== 'stationary' && this.moveDistance() === null)) { return 'M'; } else { return 'W'; @@ -306,7 +395,7 @@ export class TurnState { }); private unresolvedHeatSources = computed(() => { - if (!(this.unitState.unit.useAutomations?.() ?? true)) return this.committedHeatSources(); + if (this.unitState.unit.automationMode('heatAndDissipationResolution') === 'no') return this.committedHeatSources(); const acknowledged = this.acknowledgedHeatSources(); return this.committedHeatSources().filter(source => acknowledged[source.id] !== this.heatSourceSignature(source)); }); @@ -352,8 +441,9 @@ export class TurnState { heatProjectionVisible = computed(() => this.hasPendingHeatResolution()); - constructor(unitState: CBTForceUnitState) { + constructor(unitState: CBTForceUnitState, turnCounter = 0) { this.unitState = unitState; + this.turnCounter = turnCounter; } capturePassiveHeatSourceBaseline(): void { @@ -430,17 +520,23 @@ export class TurnState { serialize(): SerializedTurnState | undefined { const turnState: SerializedTurnState = {}; + const turnCounter = this.turnCounter; const airborne = this.airborne(); const moveMode = this.moveMode(); const moveDistance = this.moveDistance(); const standAttempts = this.standAttempts(); + const carefulStand = this.carefulStand(); const cover = this.cover(); const psrChecks = this.serializePSRChecks(); + const endTurnCheckpoint = this.endTurnCheckpoint(); + if (turnCounter > 0) turnState.turnCounter = turnCounter; + if (endTurnCheckpoint !== undefined) turnState.endTurnCheckpoint = endTurnCheckpoint; if (airborne === true) turnState.airborne = true; if (moveMode !== null) turnState.moveMode = moveMode; if (moveDistance !== null) turnState.moveDistance = moveDistance; if (standAttempts !== undefined) turnState.standAttempts = standAttempts; + if (carefulStand && this.unitState.unit.rules.supportsCarefulStand) turnState.carefulStand = true; if (cover !== undefined) turnState.cover = serializeUnitCover(cover); if (this.dmgReceived() > 0) turnState.dmgReceived = this.dmgReceived(); if (this.weaponsHeat() > 0) turnState.weaponsHeat = this.weaponsHeat(); @@ -454,6 +550,9 @@ export class TurnState { turnState.psrOutcomes = { ...this.psrOutcomes() }; } if (psrChecks) turnState.psrChecks = psrChecks; + if (this.pendingEvents().length > 0) { + turnState.pendingEvents = this.pendingEvents().map(event => structuredClone(event)); + } if (!this.applyMovePSR()) turnState.applyMovePSR = false; if (this.spotting()) turnState.spotting = true; if (this.equipmentStateChanged()) turnState.equipmentStateChanged = true; @@ -463,10 +562,15 @@ export class TurnState { update(data: SerializedTurnState | undefined) { this.withSuppressedModified(() => { + this.turnCounter = data?.turnCounter ?? this.turnCounter; + this.endTurnCheckpoint.set(data?.endTurnCheckpoint); this.airborne.set(data?.airborne ?? null); this.moveMode.set(data?.moveMode ?? null); this.moveDistance.set(data?.moveDistance ?? null); this.standAttempts.set(data?.standAttempts); + this.carefulStand.set( + data?.carefulStand === true && this.unitState.unit.rules.supportsCarefulStand + ); this.cover.set(deserializeUnitCover(data?.cover)); this.dmgReceived.set(data?.dmgReceived ?? 0); this.weaponsHeat.set(data?.weaponsHeat ?? 0); @@ -474,16 +578,37 @@ export class TurnState { this.heatDissipationConsumed.set(data?.heatDissipationConsumed ?? 0); this.psrOutcomes.set({ ...(data?.psrOutcomes ?? {}) }); this.psrChecks.set(this.deserializePSRChecks(data?.psrChecks)); + this.pendingEvents.set((data?.pendingEvents ?? []).map(event => structuredClone(event))); this.applyMovePSR.set(data?.applyMovePSR ?? true); this.spotting.set(data?.spotting ?? false); this.equipmentStateChanged.set(data?.equipmentStateChanged ?? false); }); } - markEquipmentStateChanged(): void { + getTurnCounter(): number { + return this.turnCounter; + } + + getEndTurnCheckpoint(): SerializedEndTurnCheckpoint | undefined { + return this.endTurnCheckpoint(); + } + + markEndTurnPhaseEnded(): void { + if (this.endTurnCheckpoint() === undefined) this.endTurnCheckpoint.set('phase-ended'); + } + + markEndTurnHeatStaged(): void { + this.endTurnCheckpoint.set('heat-staged'); + } + + markPhaseStateChanged(): void { this.equipmentStateChanged.set(true); } + markEquipmentStateChanged(): void { + this.markPhaseStateChanged(); + } + commitEquipmentStateChanges(): void { this.equipmentStateChanged.set(false); } @@ -541,8 +666,517 @@ export class TurnState { this.dmgReceived.set(0); } + currentPilotDamageGroup(): string { + // Core aggregates all pilot damage in a tracked phase. Total Warfare + // resolves Movement damage immediately; without phase tracking there + // is no safe aggregation boundary for either ruleset. + return !this.unitState.unit.tracksPhaseAndTurn() + || (!this.unitState.unit.gameRules.aggregatedEndPhaseConsciousRolls && this.currentPhase() === 'M') + ? createPilotDamageGroup('immediate') + : this.pilotDamageGroup; + } + + completePilotDamagePhase(): void { + const completedGroup = this.pilotDamageGroup; + this.pendingEvents.update(current => current.map(event => + 'pilotDamageGroup' in event && event.pilotDamageGroup === completedGroup + ? { ...event, pilotDamageGroup: closePilotDamagePhase(completedGroup) } as SerializedPendingEvent + : event)); + this.pilotDamageGroup = createPilotDamageGroup('combat'); + } + + completePilotDamageTurn(): void { + const current = this.pendingEvents(); + let changed = false; + const next = current.map(event => { + if (!('pilotDamageGroup' in event) || typeof event.pilotDamageGroup !== 'string') return event; + const pilotDamageGroup = closePilotDamageTurn(event.pilotDamageGroup); + if (pilotDamageGroup === event.pilotDamageGroup) return event; + changed = true; + return { ...event, pilotDamageGroup } as SerializedPendingEvent; + }); + if (changed) this.pendingEvents.set(next); + } + + getPendingEvents(): readonly SerializedPendingEvent[] { + return this.pendingEvents(); + } + + private queuePendingEvent(event: SerializedPendingEvent): boolean { + if (!event.id || this.pendingEvents().some(candidate => candidate.id === event.id)) return false; + this.pendingEvents.update(current => [...current, structuredClone(event)]); + return true; + } + + private discardPendingEvent(id: string, type: SerializedPendingEvent['type']): boolean { + const current = this.pendingEvents(); + const next = current.filter(event => event.id !== id || event.type !== type); + if (next.length === current.length) return false; + this.pendingEvents.set(next); + return true; + } + + getPendingUnitChecks(): readonly SerializedPendingUnitCheck[] { + return this.pendingEvents().filter( + (event): event is SerializedPendingUnitCheck => event.type === 'unit-check' + ); + } + + getPendingUnitCheck(id: string): SerializedPendingUnitCheck | undefined { + const event = this.pendingEvents().find(candidate => candidate.id === id); + return event?.type === 'unit-check' ? event : undefined; + } + + actionablePendingUnitChecks = computed(() => this.getPendingUnitChecks().filter(pending => + (!('readyTurn' in pending) || pending.readyTurn <= this.turnCounter) + && !(this.unitState.unit.gameRules.aggregatedEndPhaseConsciousRolls + && isConsciousnessCheck(pending) + && isOpenCombatPilotDamageGroup(pending.pilotDamageGroup)))); + + /** Includes this phase's consciousness roll while END PHASE is waiting to commit. */ + phaseEndPendingUnitChecks = computed(() => this.getPendingUnitChecks().filter(pending => + !('readyTurn' in pending) || pending.readyTurn <= this.turnCounter)); + + pendingUnitCheckCount = computed(() => + pendingUnitCheckList(this.unitState.unit).length); + + pendingUnitCheckCountAtPhaseEnd = computed(() => + pendingUnitCheckList(this.unitState.unit, true).length); + + queuePendingUnitCheck(pending: PendingEventInput): boolean { + return this.queuePendingEvent({ type: 'unit-check', ...pending } as SerializedPendingUnitCheck); + } + + setPendingUnitCheckOutcome(id: string, outcome: RuleCheckOutcome, roll?: readonly number[]): boolean { + const pending = this.getPendingUnitCheck(id); + if (!pending || pending.target === undefined + || (roll && (roll.length !== 2 + || roll.some(die => !Number.isInteger(die) || die < 1 || die > 6)))) return false; + const result = roll + ? { kind: 'roll' as const, dice: [roll[0], roll[1]] as const } + : { kind: 'manual' as const, outcome }; + this.pendingEvents.update(current => { + const updated = current.map(candidate => candidate.id === id + && candidate.type === 'unit-check' + ? { ...candidate, result } as SerializedPendingUnitCheck + : candidate); + return isConsciousnessCheck(pending) + ? this.withCascadedConsciousnessFailures(updated) + : updated; + }); + return true; + } + + /** Later rolls for an already-unconscious crew member are automatic failures. */ + private withCascadedConsciousnessFailures( + events: readonly SerializedPendingEvent[], + ): SerializedPendingEvent[] { + const orderedChecks = events.flatMap((event, index) => + event.type === 'unit-check' && isCascadeUnitCheck(event) + ? [{ check: this.withoutCascadedFailure(event), index }] + : []) + .sort((left, right) => + pendingUnitCheckPriority(this.unitState.unit, left.check) + - pendingUnitCheckPriority(this.unitState.unit, right.check) + || left.index - right.index); + const failedCrew = new Set(); + const automaticFailures = new Set(); + for (const { check } of orderedChecks) { + if (failedCrew.has(check.crewId)) { + automaticFailures.add(check.id); + continue; + } + if (isConsciousnessCheck(check) && pendingUnitCheckOutcome(check) === 'failed') { + failedCrew.add(check.crewId); + } + } + + return events.map(event => { + if (event.type !== 'unit-check' || !isCascadeUnitCheck(event)) return event; + const explicit = this.withoutCascadedFailure(event); + return automaticFailures.has(event.id) + ? { + ...explicit, + result: { kind: 'automatic', outcome: 'failed' }, + } as SerializedPendingUnitCheck + : explicit; + }); + } + + private withoutCascadedFailure( + pending: CascadeUnitCheck, + ): CascadeUnitCheck { + // A targeted automatic result is created only by this cascade. Clear + // it first so changing an earlier consciousness result is reversible. + return pending.target !== undefined && pending.result?.kind === 'automatic' + ? this.withoutPendingUnitCheckResult(pending) as typeof pending + : pending; + } + + private withoutPendingUnitCheckResult( + pending: SerializedPendingUnitCheck, + ): SerializedPendingUnitCheck { + const { result: _result, ...facts } = pending; + return facts as SerializedPendingUnitCheck; + } + + setPendingUnitCheckSelection(id: string, selectionId: string): boolean { + const pending = this.getPendingUnitCheck(id); + if (!selectionId || !pending || !isAmmoExplosionCheck(pending)) return false; + this.pendingEvents.update(current => current.map(candidate => candidate.id === id + && candidate.type === 'unit-check' && isAmmoExplosionCheck(candidate) + ? { ...candidate, selectionId } + : candidate)); + return true; + } + + discardPendingUnitCheck(id: string): boolean { + return this.discardPendingEvent(id, 'unit-check'); + } + + discardPendingUnitChecks(predicate: (pending: SerializedPendingUnitCheck) => boolean): number { + const current = this.pendingEvents(); + const next = current.filter(event => event.type !== 'unit-check' || !predicate(event)); + this.pendingEvents.set(next); + return current.length - next.length; + } + + refreshPendingUnitCheckTargets(): void { + this.pendingEvents.update(current => current.flatMap(event => { + if (event.type !== 'unit-check' + || ('readyTurn' in event && event.readyTurn > this.turnCounter)) return [event]; + const refreshed = refreshPendingUnitCheck(this.unitState.unit, event); + return refreshed ? [refreshed] : []; + })); + } + + getPendingCriticalChances(): readonly SerializedPendingMekCriticalChance[] { + return this.pendingEvents().filter( + (event): event is SerializedPendingMekCriticalChance => event.type === 'mek-critical-chance' + ); + } + + getPendingCriticalChance(id: string): SerializedPendingMekCriticalChance | undefined { + const event = this.pendingEvents().find(candidate => candidate.id === id); + return event?.type === 'mek-critical-chance' ? event : undefined; + } + + getNextPendingCriticalEvent(): SerializedPendingMekCriticalChance | SerializedPendingMekCritical | undefined { + return this.pendingEvents().find( + (event): event is SerializedPendingMekCriticalChance | SerializedPendingMekCritical => + event.type === 'mek-critical-chance' || event.type === 'mek-critical-hit', + ); + } + + pendingCriticalChanceCount = computed(() => this.getPendingCriticalChances().length); + + queuePendingCriticalChance(pending: PendingEventInput): boolean { + if (!pending.id || !pending.location) return false; + return this.queuePendingEvent({ + type: 'mek-critical-chance', + ...pending, + } as SerializedPendingMekCriticalChance); + } + + setPendingCriticalChanceResult(id: string, result: SerializedMekCriticalChanceResult | undefined): boolean { + const currentPending = this.getPendingCriticalChance(id); + if (!currentPending || currentPending.result === result) return false; + this.pendingEvents.update(current => current.map(event => { + if (event.id !== id || event.type !== 'mek-critical-chance') return event; + if (result !== undefined) return { ...event, result }; + const { result: _result, ...withoutResult } = event; + return withoutResult; + })); + return true; + } + + setPendingCriticalChanceRoll(id: string, roll: readonly number[] | undefined): boolean { + if (roll && (roll.length !== 2 + || roll.some(die => !Number.isInteger(die) || die < 1 || die > 6))) return false; + const pending = this.getPendingCriticalChance(id); + if (!pending) return false; + const unchanged = roll === undefined + ? pending.roll === undefined + : pending.roll?.[0] === roll[0] && pending.roll?.[1] === roll[1]; + if (unchanged) return false; + this.pendingEvents.update(current => current.map(event => { + if (event.id !== id || event.type !== 'mek-critical-chance') return event; + if (roll) return { ...event, roll: [roll[0], roll[1]] as const }; + const { roll: _roll, ...withoutRoll } = event; + return withoutRoll; + })); + return true; + } + + discardPendingCriticalChance(id: string): boolean { + return this.discardPendingEvent(id, 'mek-critical-chance'); + } + + getPendingCriticalHits(): readonly SerializedPendingMekCritical[] { + return this.pendingEvents().filter( + (event): event is SerializedPendingMekCritical => event.type === 'mek-critical-hit' + ); + } + + getPendingCriticalHit(id: string): SerializedPendingMekCritical | undefined { + const event = this.pendingEvents().find(candidate => candidate.id === id); + return event?.type === 'mek-critical-hit' ? event : undefined; + } + + pendingCriticalHitCount = computed(() => this.getPendingCriticalHits() + .reduce((total, pending) => total + pending.remainingHits, 0)); + + queuePendingCriticalHits(pending: PendingEventInput): boolean { + if (!pending.id || !pending.location || !pending.targetLocation + || !Number.isInteger(pending.remainingHits) || pending.remainingHits < 1 + || pending.remainingHits > 4) { + return false; + } + return this.queuePendingEvent({ type: 'mek-critical-hit', ...pending } as SerializedPendingMekCritical); + } + + replacePendingCriticalChanceWithHits( + pending: Pick, + ): boolean { + const current = this.pendingEvents(); + const index = current.findIndex(event => event.id === pending.id && event.type === 'mek-critical-chance'); + if (index < 0 || !pending.targetLocation || !Number.isInteger(pending.remainingHits) + || pending.remainingHits < 1 || pending.remainingHits > 4) return false; + const chance = current[index] as SerializedPendingMekCriticalChance; + const { + type: _type, + result: _result, + roll: _chanceRoll, + explosionProtection, + hardenedArmorApplies, + throughArmorHitArc, + ...base + } = chance; + const next = [...current]; + next[index] = structuredClone({ + ...base, + type: 'mek-critical-hit', + targetLocation: pending.targetLocation, + remainingHits: pending.remainingHits, + chanceOrigin: { + ...(explosionProtection !== undefined ? { explosionProtection } : {}), + ...(hardenedArmorApplies !== undefined ? { hardenedArmorApplies } : {}), + ...(throughArmorHitArc !== undefined ? { throughArmorHitArc } : {}), + }, + ...(pending.floatingLocation ? { floatingLocation: pending.floatingLocation } : {}), + ...(pending.caseII ? { caseII: pending.caseII } : {}), + } as SerializedPendingMekCritical); + this.pendingEvents.set(next); + return true; + } + + replacePendingCriticalHitWithChance(id: string): boolean { + const current = this.pendingEvents(); + const index = current.findIndex(event => event.id === id && event.type === 'mek-critical-hit'); + if (index < 0) return false; + const pending = current[index] as SerializedPendingMekCritical; + if (pending.chanceOrigin === undefined) return false; + const { + type: _type, + targetLocation: _targetLocation, + remainingHits: _remainingHits, + chanceOrigin, + floatingLocation: _floatingLocation, + caseII: _caseII, + roll: _roll, + ...base + } = pending; + const next = [...current]; + next[index] = structuredClone({ + ...base, + type: 'mek-critical-chance', + ...chanceOrigin, + } as SerializedPendingMekCriticalChance); + this.pendingEvents.set(next); + return true; + } + + setPendingFloatingCriticalLocation( + id: string, + locationRoll: number | null, + dice: readonly number[] | null = null, + tripodLegRoll: number | null = null, + ): boolean { + const pending = this.getPendingCriticalHit(id); + const floating = pending?.floatingLocation; + if (!pending || !floating) return false; + if (locationRoll !== null + && (!Number.isInteger(locationRoll) || locationRoll < 2 || locationRoll > 12)) return false; + if (dice !== null && (dice.length !== 2 + || dice.some(die => !Number.isInteger(die) || die < 1 || die > 6) + || locationRoll !== dice[0] + dice[1])) return false; + if (tripodLegRoll !== null + && (!Number.isInteger(tripodLegRoll) || tripodLegRoll < 1 || tripodLegRoll > 6)) return false; + const next: SerializedPendingMekFloatingCriticalLocation = { + hitArc: floating.hitArc, + ...(locationRoll !== null ? { locationRoll } : {}), + ...(dice !== null ? { dice: [dice[0], dice[1]] as const } : {}), + ...(tripodLegRoll !== null ? { tripodLegRoll } : {}), + }; + this.pendingEvents.update(current => current.map(event => + event.id === id && event.type === 'mek-critical-hit' + ? { ...event, floatingLocation: next } + : event)); + return true; + } + + resolvePendingFloatingCriticalLocation(id: string, targetLocation: string): boolean { + const normalizedLocation = targetLocation.trim(); + const pending = this.getPendingCriticalHit(id); + if (!normalizedLocation || !pending?.floatingLocation) return false; + this.pendingEvents.update(current => current.map(event => { + if (event.id !== id || event.type !== 'mek-critical-hit' || !event.floatingLocation) return event; + const { floatingLocation: _floatingLocation, ...resolved } = event; + return { ...resolved, targetLocation: normalizedLocation }; + })); + return true; + } + + setPendingCriticalCaseIICheckResult( + id: string, + result: Extract['result'], + roll?: readonly number[], + ): boolean { + if (roll && (roll.length !== 2 + || roll.some(die => !Number.isInteger(die) || die < 1 || die > 6))) return false; + const pending = this.getPendingCriticalHit(id); + if (pending?.caseII?.status !== 'pending') return false; + const unchangedRoll = roll === undefined + ? pending.caseII.roll === undefined + : pending.caseII.roll?.[0] === roll[0] && pending.caseII.roll?.[1] === roll[1]; + if (pending.caseII.result === result && unchangedRoll) return false; + this.pendingEvents.update(current => current.map(candidate => { + if (candidate.id !== id || candidate.type !== 'mek-critical-hit') return candidate; + return { + ...candidate, + caseII: { + status: 'pending', + ...(result ? { result } : {}), + ...(roll ? { roll: [roll[0], roll[1]] as const } : {}), + }, + }; + })); + return true; + } + + passPendingCriticalCaseIICheck(id: string): boolean { + const pending = this.getPendingCriticalHit(id); + if (pending?.caseII?.status !== 'pending') return false; + this.pendingEvents.update(current => current.map(candidate => { + if (candidate.id !== id || candidate.type !== 'mek-critical-hit') return candidate; + return { ...candidate, caseII: { status: 'passed' } }; + })); + return true; + } + + setPendingCriticalRoll(id: string, roll: readonly number[]): boolean { + if (roll.length < 1 || roll.length > 2 + || roll.some(die => !Number.isInteger(die) || die < 1 || die > 6)) { + return false; + } + const pending = this.getPendingCriticalHit(id); + if (!pending || pending.caseII?.status === 'pending' || pending.floatingLocation) return false; + this.pendingEvents.update(current => current.map(event => { + if (event.id !== id || event.type !== 'mek-critical-hit') return event; + return { ...event, roll: [...roll] }; + })); + return true; + } + + clearPendingCriticalRoll(id: string): boolean { + if (!this.getPendingCriticalHit(id)?.roll) return false; + this.pendingEvents.update(current => current.map(event => { + if (event.id !== id || event.type !== 'mek-critical-hit' || !event.roll) return event; + const { roll: _roll, ...withoutRoll } = event; + return withoutRoll; + })); + return true; + } + + resolvePendingCriticalHit(id: string): boolean { + if (!this.getPendingCriticalHit(id) || this.getPendingCriticalHit(id)?.floatingLocation) return false; + this.pendingEvents.update(current => current.flatMap(event => { + if (event.id !== id || event.type !== 'mek-critical-hit') return [event]; + if (event.remainingHits <= 1) return []; + const { + roll: _roll, + caseII, + chanceOrigin: _chanceOrigin, + floatingLocation: _floatingLocation, + ...facts + } = event; + return [{ + ...facts, + remainingHits: event.remainingHits - 1, + ...(caseII ? { caseII: { status: 'pending' as const } } : {}), + }]; + })); + return true; + } + + discardPendingCriticalHits(id: string): boolean { + return this.discardPendingEvent(id, 'mek-critical-hit'); + } + + getPendingFalls(): readonly SerializedPendingMekFall[] { + return this.pendingEvents().filter( + (event): event is SerializedPendingMekFall => event.type === 'mek-fall' + ); + } + + getPendingFall(id?: string): SerializedPendingMekFall | undefined { + return id + ? this.getPendingFalls().find(event => event.id === id) + : this.getPendingFalls()[0]; + } + + pendingFallCount = computed(() => this.getPendingFalls().length); + + queuePendingFall(pending: PendingEventInput): boolean { + return this.queuePendingEvent({ type: 'mek-fall', ...pending }); + } + + discardPendingFall(id: string): boolean { + return this.discardPendingEvent(id, 'mek-fall'); + } + + replacePendingFallWithUnitChecks( + id: string, + checks: readonly PendingEventInput[], + ): boolean { + const current = this.pendingEvents(); + const index = current.findIndex(event => event.id === id && event.type === 'mek-fall'); + if (index < 0) return false; + const replacements = checks.map(check => ({ type: 'unit-check' as const, ...check } as SerializedPendingUnitCheck)); + const replacementIds = new Set(replacements.map(check => check.id)); + if (replacementIds.size !== replacements.length + || current.some((event, eventIndex) => eventIndex !== index && replacementIds.has(event.id))) return false; + this.pendingEvents.set([ + ...current.slice(0, index), + ...replacements.map(check => structuredClone(check)), + ...current.slice(index + 1), + ]); + return true; + } + + preparePendingCriticalWorkAfterPhaseCommit(): void { + if (!this.pendingEvents().some(event => + (event.type === 'mek-critical-chance' || event.type === 'mek-critical-hit') + && !event.consolidateImmediately)) return; + this.pendingEvents.update(current => current.map(event => + event.type === 'mek-critical-chance' || event.type === 'mek-critical-hit' + ? { ...event, consolidateImmediately: true } + : event)); + } + addDmgReceived(amount: number) { - this.dmgReceived.update((value)=> { return value + amount }); + this.dmgReceived.update(current => current + amount); } addFiredHeat(amount: number) { @@ -592,7 +1226,7 @@ export class TurnState { return JSON.stringify([source.value, source.replacedByFiringEntryId ?? null, source.signature ?? null]); } - maxDistanceCurrentMoveMode = computed(() => { + movementCapacityCurrentMoveMode = computed(() => { const moveMode = this.moveMode(); if (moveMode === 'stationary') { return 0; @@ -611,6 +1245,12 @@ export class TurnState { return getMotiveModeMaxDistance(moveMode, unit, airborne ?? false); }); + maxDistanceCurrentMoveMode = computed(() => { + const capacity = this.movementCapacityCurrentMoveMode(); + const rules = this.unitState.unit.rules; + return Math.max(0, capacity - rules.getMovementPointsSpent(this)); + }); + minDistanceCurrentMoveMode = computed(() => { const moveMode = this.moveMode(); if (moveMode === 'stationary' || !moveMode) { diff --git a/src/app/models/unit-check.model.spec.ts b/src/app/models/unit-check.model.spec.ts new file mode 100644 index 000000000..67cc6143d --- /dev/null +++ b/src/app/models/unit-check.model.spec.ts @@ -0,0 +1,84 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { + PENDING_UNIT_CHECK_KINDS, + UNIT_CHECK_CAUSE, + UNIT_CHECK_DEFINITIONS, + UNIT_CHECK_KIND, + unitCheckActionLabel, + unitCheckAutomaticEffect, + unitCheckAutomationKey, + unitCheckLabel, + unitCheckPriority, + unitCheckReviewDescription, + unitCheckUsesPilotAutomation, + type PendingUnitCheckKind, + type UnitCheckContext, +} from './unit-check.model'; + +describe('unit-check definitions', () => { + const context: UnitCheckContext = { + target: 6, + heat: 18, + hits: 1, + crewHits: 1, + consciousnessCheckHit: 1, + }; + + it('defines every serialized kind exactly once', () => { + expect(Object.keys(UNIT_CHECK_DEFINITIONS)).toEqual(PENDING_UNIT_CHECK_KINDS); + }); + + it('owns exact heat-review text without service-side formatting', () => { + const cases: readonly [PendingUnitCheckKind, Partial, string][] = [ + [UNIT_CHECK_KIND.HEAT_SHUTDOWN, {}, 'Shutdown check 6+'], + [UNIT_CHECK_KIND.HEAT_SHUTDOWN, { target: undefined }, 'Automatic shutdown!'], + [UNIT_CHECK_KIND.SHUTDOWN_RECOVERY, {}, 'Shutdown recovery check 6+'], + [ + UNIT_CHECK_KIND.SHUTDOWN_RECOVERY, + { target: undefined, heat: 13 }, + 'Engine restarts automatically at heat 13', + ], + [UNIT_CHECK_KIND.HEAT_AMMO_EXPLOSION, {}, 'Ammunition explosion check 6+'], + [UNIT_CHECK_KIND.HEAT_RANDOM_MOVEMENT, {}, 'Random movement check 6+'], + [ + UNIT_CHECK_KIND.HEAT_RANDOM_MOVEMENT, + { target: undefined, heat: 4 }, + 'Heat 4 ends the heat-induced random-movement effect', + ], + [ + UNIT_CHECK_KIND.HEAT_PILOT_DAMAGE, + { target: 9, heat: 27, hits: 2 }, + 'Pilot heat damage check 9+ · 2 pilot hits on failure', + ], + [UNIT_CHECK_KIND.HEAT_LIFE_SUPPORT, { hits: 2 }, 'Damaged life support (2 pilot hits)'], + [UNIT_CHECK_KIND.LIFE_SUPPORT_DROWNING, {}, 'Damaged life support (1 pilot hit)'], + ]; + + for (const [kind, overrides, expected] of cases) { + expect(unitCheckReviewDescription(kind, { ...context, ...overrides })) + .withContext(kind) + .toBe(expected); + } + }); + + it('owns labels, ordering, actions, automation, and result text', () => { + expect(unitCheckLabel(UNIT_CHECK_KIND.SEATBELT, true)).toBe('Seatbelt check · Falling'); + expect(unitCheckPriority(UNIT_CHECK_KIND.CONSCIOUSNESS, false)).toBe(80); + expect(unitCheckPriority(UNIT_CHECK_KIND.CONSCIOUSNESS, true)).toBe(5); + expect(unitCheckActionLabel(UNIT_CHECK_KIND.CONSCIOUSNESS, 'success')).toBe('STAYS CONSCIOUS'); + expect(unitCheckActionLabel(UNIT_CHECK_KIND.CONSCIOUSNESS, 'failed')).toBe('UNCONSCIOUS'); + expect(unitCheckAutomaticEffect(UNIT_CHECK_KIND.HEAT_SHUTDOWN, context, 'failed')) + .toBe('unit shut down'); + expect(unitCheckAutomationKey(UNIT_CHECK_KIND.AERO_CONTROL_RECOVERY, { + ...context, + cause: UNIT_CHECK_CAUSE.HEAT_RANDOM_MOVEMENT, + })).toBe('heatEffectsCheck'); + expect(unitCheckUsesPilotAutomation(UNIT_CHECK_KIND.AERO_CONTROL_RECOVERY, { + ...context, + cause: UNIT_CHECK_CAUSE.HEAT_RANDOM_MOVEMENT, + })).toBeFalse(); + }); +}); diff --git a/src/app/models/unit-check.model.ts b/src/app/models/unit-check.model.ts new file mode 100644 index 000000000..a41882297 --- /dev/null +++ b/src/app/models/unit-check.model.ts @@ -0,0 +1,380 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { CBTAutomationKey } from './options.model'; + +export const UNIT_CHECK_KIND = { + HEAT_SHUTDOWN: 'heat-shutdown', + SHUTDOWN_RECOVERY: 'shutdown-recovery', + HEAT_AMMO_EXPLOSION: 'heat-ammo-explosion', + HEAT_RANDOM_MOVEMENT: 'heat-random-movement', + HEAT_PILOT_DAMAGE: 'heat-pilot-damage', + HEAT_LIFE_SUPPORT: 'heat-life-support', + LIFE_SUPPORT_DROWNING: 'life-support-drowning', + AERO_CONTROL_RECOVERY: 'aero-control-recovery', + SEATBELT: 'seatbelt', + CONSCIOUSNESS: 'consciousness', + CONSCIOUSNESS_RECOVERY: 'consciousness-recovery', +} as const; + +export type PendingUnitCheckKind = typeof UNIT_CHECK_KIND[keyof typeof UNIT_CHECK_KIND]; + +export const PENDING_UNIT_CHECK_KINDS: readonly PendingUnitCheckKind[] = + Object.values(UNIT_CHECK_KIND); + +export const UNIT_CHECK_CAUSE = { + HEAT_RANDOM_MOVEMENT: UNIT_CHECK_KIND.HEAT_RANDOM_MOVEMENT, +} as const; + +export type UnitCheckCause = typeof UNIT_CHECK_CAUSE[keyof typeof UNIT_CHECK_CAUSE]; +export type UnitCheckOutcome = 'success' | 'failed'; + +/** Values needed to present or classify a check, independent of its storage shape. */ +export interface UnitCheckContext { + readonly target?: number; + readonly heat: number; + readonly hits: number; + readonly cause?: UnitCheckCause; + readonly crewName?: string; + readonly crewHits: number; + readonly consciousnessCheckHit: number | null; +} + +interface UnitCheckDefinition { + readonly label: string; + readonly notificationGroupLabel?: string; + readonly reviewLabel?: string; + readonly dialogTitle?: string; + readonly priority: number; + readonly immediatePriority?: number; + readonly heatEffect?: true; + readonly pilotDamagePhase?: 'heat' | 'end'; + readonly approvedAutomatic?: true; + readonly crewOwned?: true; + readonly resolvesBeforePsr?: true; + readonly cascadeParticipant?: true; + readonly requiresAmmoSelection?: true; + readonly automationKey: CBTAutomationKey | ((context: UnitCheckContext) => CBTAutomationKey); + readonly usesPilotAutomation: boolean | ((context: UnitCheckContext) => boolean); + readonly description: (context: UnitCheckContext) => string; + readonly reviewDescription?: (context: UnitCheckContext) => string; + readonly failureOutcome: (context: UnitCheckContext) => string; + readonly successLabel?: string; + readonly failedLabel?: string; + readonly automaticLabel?: string; + readonly automaticEffect: (context: UnitCheckContext, outcome: UnitCheckOutcome) => string | null; +} + +function pilotHits(context: UnitCheckContext): string { + return `${context.hits} pilot hit${context.hits === 1 ? '' : 's'}`; +} + +/** + * The single exhaustive definition of kind-dependent unit-check behavior. + * Adding a kind is a compile error until its presentation, ordering, and + * automation ownership are defined here. + */ +export const UNIT_CHECK_DEFINITIONS = { + [UNIT_CHECK_KIND.HEAT_SHUTDOWN]: { + label: 'Shutdown', + priority: 10, + heatEffect: true, + automationKey: 'heatEffectsCheck', + usesPilotAutomation: false, + description: context => context.target !== undefined ? 'Avoid shutdown.' : 'Automatic shutdown!', + reviewDescription: context => context.target !== undefined + ? `Shutdown check ${context.target}+` + : 'Automatic shutdown!', + failureOutcome: () => 'shutdown', + automaticEffect: (_context, outcome) => outcome === 'failed' ? 'unit shut down' : 'shutdown avoided', + }, + [UNIT_CHECK_KIND.SHUTDOWN_RECOVERY]: { + label: 'Shutdown recovery', + priority: 10, + heatEffect: true, + automationKey: 'heatEffectsCheck', + usesPilotAutomation: false, + description: context => context.target !== undefined ? 'Restart engine.' : 'Heat below 14.', + reviewDescription: context => context.target !== undefined + ? `Shutdown recovery check ${context.target}+` + : `Engine restarts automatically at heat ${context.heat}`, + failureOutcome: () => 'remains shutdown', + successLabel: 'RESTARTS', + failedLabel: 'REMAINS SHUTDOWN', + automaticLabel: 'AUTOMATIC RESTART', + automaticEffect: (_context, outcome) => outcome === 'success' + ? 'unit restarted' + : 'unit remains shut down', + }, + [UNIT_CHECK_KIND.HEAT_AMMO_EXPLOSION]: { + label: 'Ammunition explosion', + priority: 20, + heatEffect: true, + requiresAmmoSelection: true, + automationKey: 'heatEffectsCheck', + usesPilotAutomation: false, + description: () => 'Avoid ammunition explosion.', + reviewDescription: context => `Ammunition explosion check ${context.target}+`, + failureOutcome: () => 'ammunition explosion', + automaticEffect: (_context, outcome) => outcome === 'success' + ? 'ammunition explosion avoided' + : null, + }, + [UNIT_CHECK_KIND.HEAT_RANDOM_MOVEMENT]: { + label: 'Random movement', + priority: 30, + heatEffect: true, + automationKey: 'heatEffectsCheck', + usesPilotAutomation: false, + description: context => context.target !== undefined + ? 'Keep the navigation and piloting systems online.' + : 'Ends the heat-induced random-movement effect.', + reviewDescription: context => context.target !== undefined + ? `Random movement check ${context.target}+` + : `Heat ${context.heat} ends the heat-induced random-movement effect`, + failureOutcome: () => 'random movement', + automaticEffect: (_context, outcome) => outcome === 'success' + ? 'random movement avoided' + : 'random movement and out-of-control applied', + }, + [UNIT_CHECK_KIND.HEAT_PILOT_DAMAGE]: { + label: 'Pilot heat damage', + priority: 40, + heatEffect: true, + pilotDamagePhase: 'heat', + automationKey: 'heatEffectsCheck', + usesPilotAutomation: true, + description: context => `Avoid pilot damage from heat ${context.heat}.`, + reviewDescription: context => `Pilot heat damage check ${context.target}+` + + ` · ${pilotHits(context)} on failure`, + failureOutcome: pilotHits, + automaticEffect: (context, outcome) => outcome === 'failed' + ? `${pilotHits(context)} applied` + : 'pilot damage avoided', + }, + [UNIT_CHECK_KIND.HEAT_LIFE_SUPPORT]: { + label: 'Life Support damage', + priority: 50, + heatEffect: true, + pilotDamagePhase: 'heat', + approvedAutomatic: true, + automationKey: 'pilotHitsAndConsciousnessCheck', + usesPilotAutomation: true, + description: context => `Damaged life support (${pilotHits(context)})`, + failureOutcome: pilotHits, + automaticLabel: 'AUTOMATIC DAMAGE', + automaticEffect: (context, outcome) => outcome === 'failed' + ? `${pilotHits(context)} applied` + : 'pilot damage avoided', + }, + [UNIT_CHECK_KIND.LIFE_SUPPORT_DROWNING]: { + label: 'Life Support drowning', + priority: 85, + heatEffect: true, + pilotDamagePhase: 'end', + approvedAutomatic: true, + automationKey: 'pilotHitsAndConsciousnessCheck', + usesPilotAutomation: true, + description: context => `Damaged life support (${pilotHits(context)})`, + failureOutcome: pilotHits, + automaticLabel: 'AUTOMATIC DAMAGE', + automaticEffect: (context, outcome) => outcome === 'failed' + ? `${pilotHits(context)} applied` + : 'pilot damage avoided', + }, + [UNIT_CHECK_KIND.AERO_CONTROL_RECOVERY]: { + label: 'Regain aerospace control', + priority: 65, + automationKey: context => context.cause === UNIT_CHECK_CAUSE.HEAT_RANDOM_MOVEMENT + ? 'heatEffectsCheck' + : 'pilotHitsAndConsciousnessCheck', + usesPilotAutomation: context => context.cause !== UNIT_CHECK_CAUSE.HEAT_RANDOM_MOVEMENT, + description: context => context.cause === UNIT_CHECK_CAUSE.HEAT_RANDOM_MOVEMENT + ? 'Regain control after heat-induced random movement.' + : 'Regain control after going out of control.', + failureOutcome: () => 'remains out of control', + successLabel: 'REGAINS CONTROL', + failedLabel: 'REMAINS OUT OF CONTROL', + automaticEffect: (_context, outcome) => outcome === 'success' + ? 'control restored' + : 'unit remains out of control', + }, + [UNIT_CHECK_KIND.SEATBELT]: { + label: 'Seatbelt check', + notificationGroupLabel: 'Seatbelt checks', + reviewLabel: 'Seatbelt check · Falling', + priority: 70, + immediatePriority: 6, + crewOwned: true, + cascadeParticipant: true, + automationKey: 'pilotHitsAndConsciousnessCheck', + usesPilotAutomation: true, + description: () => 'Reason: Falling. Avoid pilot damage.', + failureOutcome: () => 'pilot hit', + successLabel: 'PASSED', + failedLabel: 'PILOT HIT', + automaticEffect: (_context, outcome) => outcome === 'failed' + ? '1 pilot hit applied' + : 'pilot damage avoided', + }, + [UNIT_CHECK_KIND.CONSCIOUSNESS]: { + label: 'Consciousness check', + notificationGroupLabel: 'Consciousness checks', + dialogTitle: 'Consciousness Rolls', + priority: 80, + immediatePriority: 5, + crewOwned: true, + resolvesBeforePsr: true, + cascadeParticipant: true, + automationKey: 'pilotHitsAndConsciousnessCheck', + usesPilotAutomation: true, + description: context => { + const hitText = context.consciousnessCheckHit !== null + && context.consciousnessCheckHit < context.crewHits + ? `Pilot hit ${context.consciousnessCheckHit} of ${context.crewHits}` + : `${context.crewHits} pilot hit${context.crewHits === 1 ? '' : 's'}`; + return `${context.crewName ? `${context.crewName}: ` : ''}${hitText}.`; + }, + failureOutcome: () => 'unconsciousness', + successLabel: 'STAYS CONSCIOUS', + failedLabel: 'UNCONSCIOUS', + automaticEffect: (_context, outcome) => outcome === 'failed' + ? 'crew member rendered unconscious' + : 'crew member remains conscious', + }, + [UNIT_CHECK_KIND.CONSCIOUSNESS_RECOVERY]: { + label: 'Consciousness recovery', + notificationGroupLabel: 'Consciousness recovery', + dialogTitle: 'Recover Consciousness', + priority: 82, + immediatePriority: 60, + crewOwned: true, + resolvesBeforePsr: true, + automationKey: 'pilotHitsAndConsciousnessCheck', + usesPilotAutomation: true, + description: context => `${context.crewName ? `${context.crewName}: ` : ''}` + + 'Restores consciousness; the unit may act next turn.', + failureOutcome: () => 'remains unconscious', + successLabel: 'WAKES UP', + failedLabel: 'STAYS UNCONSCIOUS', + automaticEffect: (_context, outcome) => outcome === 'success' + ? 'crew member regained consciousness' + : 'crew member remains unconscious', + }, +} as const satisfies Readonly>; + +type UnitCheckDefinitions = typeof UNIT_CHECK_DEFINITIONS; + +export type HeatEffectKind = { + [K in PendingUnitCheckKind]: UnitCheckDefinitions[K] extends { readonly heatEffect: true } ? K : never; +}[PendingUnitCheckKind]; + +export interface HeatEffectDescriptor { + readonly kind: HeatEffectKind; + readonly target?: number; + readonly result?: { readonly kind: 'automatic'; readonly outcome: UnitCheckOutcome }; + readonly hits?: number; +} + +export function unitCheckDefinition(kind: PendingUnitCheckKind): UnitCheckDefinition { + return UNIT_CHECK_DEFINITIONS[kind]; +} + +export function unitCheckLabel(kind: PendingUnitCheckKind, review = false): string { + const definition = unitCheckDefinition(kind); + return review ? definition.reviewLabel ?? definition.label : definition.label; +} + +export function unitCheckNotificationGroupLabel(kind: PendingUnitCheckKind): string { + const definition = unitCheckDefinition(kind); + return definition.notificationGroupLabel ?? definition.label; +} + +export function unitCheckPriority(kind: PendingUnitCheckKind, immediate: boolean): number { + const definition = unitCheckDefinition(kind); + return immediate ? definition.immediatePriority ?? definition.priority : definition.priority; +} + +export function unitCheckAutomationKey( + kind: PendingUnitCheckKind, + context: UnitCheckContext, +): CBTAutomationKey { + const automationKey = unitCheckDefinition(kind).automationKey; + return typeof automationKey === 'function' ? automationKey(context) : automationKey; +} + +export function unitCheckUsesPilotAutomation( + kind: PendingUnitCheckKind, + context: UnitCheckContext, +): boolean { + const usesPilotAutomation = unitCheckDefinition(kind).usesPilotAutomation; + return typeof usesPilotAutomation === 'function' + ? usesPilotAutomation(context) + : usesPilotAutomation; +} + +export function unitCheckDescription(kind: PendingUnitCheckKind, context: UnitCheckContext): string { + return unitCheckDefinition(kind).description(context); +} + +export function unitCheckReviewDescription(kind: PendingUnitCheckKind, context: UnitCheckContext): string { + const definition = unitCheckDefinition(kind); + return (definition.reviewDescription ?? definition.description)(context); +} + +export function unitCheckFailureOutcome(kind: PendingUnitCheckKind, context: UnitCheckContext): string { + return unitCheckDefinition(kind).failureOutcome(context); +} + +export function unitCheckActionLabel(kind: PendingUnitCheckKind, outcome: UnitCheckOutcome): string { + const definition = unitCheckDefinition(kind); + return outcome === 'success' + ? definition.successLabel ?? 'SUCCESS' + : definition.failedLabel ?? 'FAILED'; +} + +export function unitCheckAutomaticLabel(kind: PendingUnitCheckKind, outcome: UnitCheckOutcome): string { + return unitCheckDefinition(kind).automaticLabel + ?? (outcome === 'success' ? 'AUTOMATIC SUCCESS' : 'AUTOMATIC FAILURE'); +} + +export function unitCheckAutomaticEffect( + kind: PendingUnitCheckKind, + context: UnitCheckContext, + outcome: UnitCheckOutcome, +): string | null { + return unitCheckDefinition(kind).automaticEffect(context, outcome); +} + +export function unitCheckDialogTitle(kind: PendingUnitCheckKind): string | undefined { + return unitCheckDefinition(kind).dialogTitle; +} + +export function unitCheckIsPilotHitHeatEffect(kind: PendingUnitCheckKind): boolean { + return unitCheckDefinition(kind).pilotDamagePhase !== undefined; +} + +export function unitCheckPilotDamagePhase(kind: PendingUnitCheckKind): 'heat' | 'end' | undefined { + return unitCheckDefinition(kind).pilotDamagePhase; +} + +export function unitCheckIsApprovedAutomatic(kind: PendingUnitCheckKind): boolean { + return unitCheckDefinition(kind).approvedAutomatic === true; +} + +export function unitCheckIsCrewOwned(kind: PendingUnitCheckKind): boolean { + return unitCheckDefinition(kind).crewOwned === true; +} + +export function unitCheckResolvesBeforePsr(kind: PendingUnitCheckKind): boolean { + return unitCheckDefinition(kind).resolvesBeforePsr === true; +} + +export function unitCheckIsCascadeParticipant(kind: PendingUnitCheckKind): boolean { + return unitCheckDefinition(kind).cascadeParticipant === true; +} + +export function unitCheckRequiresAmmoSelection(kind: PendingUnitCheckKind): boolean { + return unitCheckDefinition(kind).requiresAmmoSelection === true; +} diff --git a/src/app/services/automation-review.service.spec.ts b/src/app/services/automation-review.service.spec.ts new file mode 100644 index 000000000..c554cc4d6 --- /dev/null +++ b/src/app/services/automation-review.service.spec.ts @@ -0,0 +1,60 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { AutomationReviewService } from './automation-review.service'; +import { DialogsService } from './dialogs.service'; + +describe('AutomationReviewService', () => { + let createDialog: jasmine.Spy; + let service: AutomationReviewService; + const events = [ + { id: 'known', subject: 'Atlas', event: 'Heat', description: 'Heat 4 → 8' }, + ]; + + beforeEach(() => { + createDialog = jasmine.createSpy('createDialog'); + TestBed.configureTestingModule({ + providers: [ + AutomationReviewService, + { provide: DialogsService, useValue: { createDialog } }, + ], + }); + service = TestBed.inject(AutomationReviewService); + }); + + afterEach(() => TestBed.resetTestingModule()); + + it('returns an empty decision set without opening a dialog when there are no events', async () => { + expect(Array.from((await service.review([])) ?? [])).toEqual([]); + expect(createDialog).not.toHaveBeenCalled(); + }); + + it('returns null when the review dialog is cancelled', async () => { + createDialog.and.returnValue({ closed: of(undefined) }); + + expect(await service.review(events)).toBeNull(); + }); + + it('returns only accepted IDs that belong to the request', async () => { + createDialog.and.returnValue({ + closed: of({ acceptedEventIds: ['known', 'not-in-request'] }), + }); + + const accepted = await service.review(events); + + expect(Array.from(accepted ?? [])).toEqual(['known']); + }); + + it('only exposes cancellation when explicitly allowed', async () => { + createDialog.and.returnValue({ closed: of({ acceptedEventIds: [] }) }); + + await service.review(events); + expect(createDialog.calls.mostRecent().args[1].data.allowCancel).toBeFalse(); + + await service.review(events, { allowCancel: true }); + expect(createDialog.calls.mostRecent().args[1].data.allowCancel).toBeTrue(); + }); +}); diff --git a/src/app/services/automation-review.service.ts b/src/app/services/automation-review.service.ts new file mode 100644 index 000000000..268251f9e --- /dev/null +++ b/src/app/services/automation-review.service.ts @@ -0,0 +1,47 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { inject, Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { AutomationReviewDialogComponent } from '../components/automation-review-dialog/automation-review-dialog.component'; +import type { AutomationReviewDialogData, AutomationReviewEvent, AutomationReviewResult } from '../models/automation-review.model'; +import { DialogsService } from './dialogs.service'; + +export interface AutomationReviewOptions { + title?: string; + message?: string; + allowCancel?: boolean; +} + +@Injectable({ providedIn: 'root' }) +export class AutomationReviewService { + private readonly dialogsService = inject(DialogsService); + + /** Returns accepted event IDs, or null when the whole triggering action was cancelled. */ + async review( + events: readonly AutomationReviewEvent[], + options: AutomationReviewOptions = {}, + ): Promise | null> { + if (events.length === 0) return new Set(); + + const ref = this.dialogsService.createDialog( + AutomationReviewDialogComponent, + { + disableClose: true, + data: { + title: options.title ?? 'Review Automations', + message: options.message + ?? 'Accept or skip each event.', + events, + allowCancel: options.allowCancel ?? false, + }, + }, + ); + const result = await firstValueFrom(ref.closed); + if (!result) return null; + + const knownEventIds = new Set(events.map(event => event.id)); + return new Set(result.acceptedEventIds.filter(id => knownEventIds.has(id))); + } +} diff --git a/src/app/services/cbt-automation-toast.service.spec.ts b/src/app/services/cbt-automation-toast.service.spec.ts new file mode 100644 index 000000000..dea00a69a --- /dev/null +++ b/src/app/services/cbt-automation-toast.service.spec.ts @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { TestBed } from '@angular/core/testing'; +import { CBTAutomationToastService } from './cbt-automation-toast.service'; +import { ToastService } from './toast.service'; + +describe('CBTAutomationToastService', () => { + let service: CBTAutomationToastService; + let showToast: jasmine.Spy; + const unit = { + id: 'unit:1', + getNotificationDisplayName: () => 'Atlas AS7-D', + }; + + beforeEach(() => { + showToast = jasmine.createSpy('showToast'); + TestBed.configureTestingModule({ + providers: [ + CBTAutomationToastService, + { provide: ToastService, useValue: { showToast } }, + ], + }); + service = TestBed.inject(CBTAutomationToastService); + }); + + it('omits the unit name when the unit is currently visible', () => { + const owner = {}; + service.setVisibleUnitIds(owner, [unit.id]); + + service.show(unit, 'Piloting Skill Check: PASSED', 'success'); + + expect(showToast).toHaveBeenCalledOnceWith('Piloting Skill Check: PASSED', 'success'); + }); + + it('includes the unit name when the unit is not currently visible', () => { + const owner = {}; + service.setVisibleUnitIds(owner, ['unit:2']); + + service.show(unit, 'Piloting Skill Check: FAILED', 'error'); + + expect(showToast).toHaveBeenCalledOnceWith( + 'Atlas AS7-D — Piloting Skill Check: FAILED', + 'error', + ); + }); + + it('stops treating a unit as visible when its viewer is removed', () => { + const owner = {}; + service.setVisibleUnitIds(owner, [unit.id]); + service.clearVisibleUnitIds(owner); + + service.show(unit, 'Fall resolved', 'error'); + + expect(showToast).toHaveBeenCalledOnceWith('Atlas AS7-D — Fall resolved', 'error'); + }); +}); diff --git a/src/app/services/cbt-automation-toast.service.ts b/src/app/services/cbt-automation-toast.service.ts new file mode 100644 index 000000000..823972f63 --- /dev/null +++ b/src/app/services/cbt-automation-toast.service.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { inject, Injectable } from '@angular/core'; +import type { Toast } from './toast.service'; +import { ToastService } from './toast.service'; + +export interface CBTAutomationToastUnit { + readonly id: string; + getNotificationDisplayName(): string; +} + +/** + * Presents results produced by CBT automation. The unit name is only needed + * when the affected unit is not one of the record sheets currently visible. + */ +@Injectable({ providedIn: 'root' }) +export class CBTAutomationToastService { + private readonly toasts = inject(ToastService); + private readonly visibleUnitIdsByOwner = new Map>(); + + setVisibleUnitIds(owner: object, unitIds: Iterable): void { + this.visibleUnitIdsByOwner.set(owner, new Set(unitIds)); + } + + clearVisibleUnitIds(owner: object): void { + this.visibleUnitIdsByOwner.delete(owner); + } + + show(unit: CBTAutomationToastUnit, message: string, type: Toast['type']): void { + const unitVisible = Array.from(this.visibleUnitIdsByOwner.values()) + .some(unitIds => unitIds.has(unit.id)); + this.toasts.showToast( + unitVisible ? message : `${unit.getNotificationDisplayName()} — ${message}`, + type, + ); + } +} diff --git a/src/app/services/cbt-automation.service.spec.ts b/src/app/services/cbt-automation.service.spec.ts new file mode 100644 index 000000000..d1de16da9 --- /dev/null +++ b/src/app/services/cbt-automation.service.spec.ts @@ -0,0 +1,57 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { provideZonelessChangeDetection } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type { AutomationReviewEvent } from '../models/automation-review.model'; +import type { AutomationMode } from '../models/options.model'; +import { AutomationReviewService } from './automation-review.service'; +import { CBTAutomationService } from './cbt-automation.service'; +import { OptionsService } from './options.service'; + +describe('CBTAutomationService', () => { + const events: AutomationReviewEvent[] = [ + { id: 'one', subject: 'Archer', event: 'Test', description: 'First event' }, + { id: 'two', subject: 'Atlas', event: 'Test', description: 'Second event' }, + ]; + let mode: AutomationMode; + let review: jasmine.Spy; + let service: CBTAutomationService; + + beforeEach(() => { + mode = 'yes'; + review = jasmine.createSpy('review'); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + CBTAutomationService, + { provide: OptionsService, useValue: { cbtAutomationMode: () => mode } }, + { provide: AutomationReviewService, useValue: { review } }, + ], + }); + service = TestBed.inject(CBTAutomationService); + }); + + it('accepts every event without a dialog in yes mode', async () => { + expect(Array.from((await service.resolve('breachAndFloodCheck', events))!)).toEqual(['one', 'two']); + expect(review).not.toHaveBeenCalled(); + }); + + it('rejects every event without a dialog in no mode', async () => { + mode = 'no'; + + expect(Array.from((await service.resolve('breachAndFloodCheck', events))!)).toEqual([]); + expect(review).not.toHaveBeenCalled(); + }); + + it('returns the review decision, including cancellation, in ask mode', async () => { + mode = 'ask'; + review.and.resolveTo(new Set(['two'])); + + expect(Array.from((await service.resolve('breachAndFloodCheck', events))!)).toEqual(['two']); + + review.and.resolveTo(null); + expect(await service.resolve('breachAndFloodCheck', events)).toBeNull(); + }); +}); diff --git a/src/app/services/cbt-automation.service.ts b/src/app/services/cbt-automation.service.ts new file mode 100644 index 000000000..661016ce7 --- /dev/null +++ b/src/app/services/cbt-automation.service.ts @@ -0,0 +1,37 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { inject, Injectable } from '@angular/core'; +import type { AutomationReviewEvent } from '../models/automation-review.model'; +import type { CBTAutomationKey } from '../models/options.model'; +import { AutomationReviewService, type AutomationReviewOptions } from './automation-review.service'; +import { OptionsService } from './options.service'; + +@Injectable({ providedIn: 'root' }) +export class CBTAutomationService { + private readonly optionsService = inject(OptionsService); + private readonly automationReview = inject(AutomationReviewService); + + /** + * Resolves one automation's configured policy. `yes` accepts every event, + * `no` accepts none, and `ask` delegates the choice to the shared review UI. + * A null result means the user cancelled the triggering action. + */ + async resolve( + key: CBTAutomationKey, + events: readonly AutomationReviewEvent[], + options: AutomationReviewOptions = {}, + ): Promise | null> { + if (events.length === 0) return new Set(); + + switch (this.optionsService.cbtAutomationMode(key)) { + case 'yes': + return new Set(events.map(event => event.id)); + case 'no': + return new Set(); + case 'ask': + return this.automationReview.review(events, options); + } + } +} diff --git a/src/app/services/cbt-end-turn.service.spec.ts b/src/app/services/cbt-end-turn.service.spec.ts new file mode 100644 index 000000000..e0bbf481e --- /dev/null +++ b/src/app/services/cbt-end-turn.service.spec.ts @@ -0,0 +1,515 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { provideZonelessChangeDetection } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type { AutomationReviewEvent } from '../models/automation-review.model'; +import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import type { PendingEventInput, SerializedPendingUnitCheck } from '../models/force-serialization'; +import { CBTAutomationService } from './cbt-automation.service'; +import { CBTEndTurnService } from './cbt-end-turn.service'; +import { CBTPhaseResolutionService } from './cbt-phase-resolution.service'; +import { OptionsService } from './options.service'; +import { ToastService } from './toast.service'; + +describe('CBTEndTurnService', () => { + let resolveAutomation: jasmine.Spy; + let resolvePhase: jasmine.Spy; + let resolvePendingChain: jasmine.Spy; + let showToast: jasmine.Spy; + let service: CBTEndTurnService; + let automationModes: Record< + 'heatAndDissipationResolution' | 'heatEffectsCheck' | 'pilotHitsAndConsciousnessCheck', + 'yes' | 'ask' | 'no' + >; + + function createUnit( + id: string, + current: number, + projected: number, + pendingHeat = true, + effects: { + lifeSupportHits?: number; + drowningHits?: number; + shutdown?: boolean; + activePilotCrewId?: number | null; + } = {}, + automaticFall = false, + ) { + const queued: SerializedPendingUnitCheck[] = []; + let activePilotCrewId = effects.activePilotCrewId === undefined ? 0 : effects.activePilotCrewId; + let pendingFalls = 0; + let checkpoint: 'phase-ended' | 'heat-staged' | undefined; + const endTurn = jasmine.createSpy('endTurn'); + const resolveEndTurnHeat = jasmine.createSpy('resolveEndTurnHeat'); + const endPhase = jasmine.createSpy('endPhase').and.callFake(() => { + if (!automaticFall) return; + queued.push({ + type: 'unit-check', + id: `seatbelt:${id}`, + kind: 'seatbelt', + crewId: 0, + target: 5, + }); + }); + const advanceDeferredUnitChecks = jasmine.createSpy('advanceDeferredUnitChecks'); + const sourceHeat = Math.max(0, projected - current); + const consumedDissipation = Math.max(0, current + sourceHeat - projected); + const heatSources = sourceHeat > 0 + ? [{ id: 'weapons', label: 'Weapons', value: sourceHeat }] + : []; + const turnState = { + heatProjection: () => ({ projected, consumedDissipation }), + heatSources: () => heatSources, + heatDissipationBalance: () => consumedDissipation, + getEndTurnCheckpoint: () => checkpoint, + markEndTurnPhaseEnded: () => { checkpoint ??= 'phase-ended'; }, + markEndTurnHeatStaged: () => { checkpoint = 'heat-staged'; }, + advanceDeferredUnitChecks, + queuePendingUnitCheck: (check: PendingEventInput) => { + queued.push({ type: 'unit-check', ...check } as SerializedPendingUnitCheck); + return true; + }, + pendingUnitCheckCount: () => queued.length, + getPendingUnitChecks: () => queued, + pendingCriticalChanceCount: () => 0, + pendingCriticalHitCount: () => 0, + PSRRollsCount: () => automaticFall ? 1 : 0, + actionablePSRRollsCount: () => 0, + autoFall: () => automaticFall, + }; + const unit = { + id, + getHeat: () => ({ current, previous: current }), + turnState: () => turnState, + rules: { + heatScale: [ + { heat: 5, move: -1 }, + { heat: 8, fire: 1 }, + { heat: 14, shutdown: 4 }, + { heat: 18, shutdown: 6 }, + { heat: 30, shutdown: 100 }, + ], + hasDamagedLifeSupport: () => (effects.lifeSupportHits ?? 0) > 0, + heatLifeSupportPilotHits: () => effects.lifeSupportHits ?? 0, + submergedLifeSupportPilotHits: () => effects.drowningHits ?? 0, + getActivePilotCrewId: () => activePilotCrewId, + }, + getNotificationDisplayName: () => `Unit ${id}`, + pendingFallCount: () => pendingFalls, + hasPendingEndTurnHeat: () => pendingHeat, + getCondition: (condition: string) => condition === 'shutdown' && effects.shutdown === true, + getCrewMember: () => ({ getState: () => 'healthy' }), + getCritSlots: () => [], + getUnit: () => ({ type: 'Mek' }), + resolveEndTurnHeat, + endPhase, + endTurn, + } as unknown as CBTForceUnit; + return { + unit, + endPhase, + endTurn, + queued, + resolveEndTurnHeat, + advanceDeferredUnitChecks, + getCheckpoint: () => checkpoint, + setCheckpoint: (value: typeof checkpoint) => { checkpoint = value; }, + setPendingFallCount: (count: number) => { pendingFalls = count; }, + setActivePilotCrewId: (crewId: number | null) => { activePilotCrewId = crewId; }, + }; + } + + beforeEach(() => { + automationModes = { + heatAndDissipationResolution: 'yes', + heatEffectsCheck: 'yes', + pilotHitsAndConsciousnessCheck: 'yes', + }; + resolveAutomation = jasmine.createSpy('resolve'); + resolvePhase = jasmine.createSpy('resolve').and.resolveTo(true); + resolvePendingChain = jasmine.createSpy('resolvePendingChain').and.resolveTo(true); + showToast = jasmine.createSpy('showToast'); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + CBTEndTurnService, + { provide: CBTAutomationService, useValue: { resolve: resolveAutomation } }, + { + provide: CBTPhaseResolutionService, + useValue: { endPhase: resolvePhase, resolvePendingChain }, + }, + { provide: OptionsService, useValue: { + cbtAutomationMode: (key: keyof typeof automationModes) => automationModes[key], + } }, + { provide: ToastService, useValue: { showToast } }, + ], + }); + service = TestBed.inject(CBTEndTurnService); + }); + + afterEach(() => TestBed.resetTestingModule()); + + it('does not stage heat or commit a turn before both automation reviews complete', async () => { + const first = createUnit('first', 4, 8); + const second = createUnit('second', 2, 6); + let finishHeatReview!: (result: ReadonlySet | null) => void; + resolveAutomation.and.callFake((key: string) => key === 'heatAndDissipationResolution' + ? new Promise | null>(resolve => finishHeatReview = resolve) + : Promise.resolve(new Set())); + + const completion = service.endTurn([first.unit, second.unit]); + const duplicateCompletion = service.endTurn([first.unit, second.unit]); + await Promise.resolve(); + + expect(first.endTurn).not.toHaveBeenCalled(); + expect(second.endTurn).not.toHaveBeenCalled(); + expect(first.resolveEndTurnHeat).not.toHaveBeenCalled(); + expect(second.resolveEndTurnHeat).not.toHaveBeenCalled(); + expect(resolveAutomation).toHaveBeenCalledTimes(1); + + finishHeatReview(new Set(['heat-and-dissipation:first'])); + expect(await completion).toBeTrue(); + expect(await duplicateCompletion).toBeTrue(); + expect(first.endTurn).toHaveBeenCalledOnceWith({ heatAndDissipationResolution: false, phaseAlreadyEnded: true }); + expect(second.endTurn).toHaveBeenCalledOnceWith({ heatAndDissipationResolution: false, phaseAlreadyEnded: true }); + expect(first.resolveEndTurnHeat).toHaveBeenCalledTimes(1); + expect(second.resolveEndTurnHeat).not.toHaveBeenCalled(); + expect(showToast).toHaveBeenCalledWith( + 'Unit first — Heat and dissipation: Heat 4 → 8', + 'info', + ); + }); + + it('leaves the ended phase resumable when the heat review is cancelled', async () => { + const first = createUnit('first', 4, 8); + const second = createUnit('second', 2, 6); + resolveAutomation.and.resolveTo(null); + + expect(await service.endTurn([first.unit, second.unit])).toBeFalse(); + + expect(first.endTurn).not.toHaveBeenCalled(); + expect(second.endTurn).not.toHaveBeenCalled(); + expect(first.resolveEndTurnHeat).not.toHaveBeenCalled(); + expect(second.resolveEndTurnHeat).not.toHaveBeenCalled(); + expect(first.getCheckpoint()).toBe('phase-ended'); + expect(second.getCheckpoint()).toBe('phase-ended'); + }); + + it('does not stage heat when the heat-effects review is cancelled', async () => { + const harness = createUnit('atlas', 4, 18); + resolveAutomation.and.callFake((key: string, events: readonly AutomationReviewEvent[]) => + Promise.resolve(key === 'heatAndDissipationResolution' + ? new Set(events.map(event => event.id)) + : null)); + + expect(await service.endTurn([harness.unit])).toBeFalse(); + + expect(harness.endTurn).not.toHaveBeenCalled(); + expect(harness.queued).toEqual([]); + expect(harness.getCheckpoint()).toBe('phase-ended'); + }); + + it('stages automatic heat-30 shutdown and opens the persistent checks panel', async () => { + const harness = createUnit('atlas', 20, 30); + resolveAutomation.and.callFake((_key: string, events: readonly AutomationReviewEvent[]) => + Promise.resolve(new Set(events.map(event => event.id)))); + + expect(await service.endTurn([harness.unit])).toBeTrue(); + + expect(harness.queued.length).toBe(1); + expect(harness.queued[0]).toEqual(jasmine.objectContaining({ + kind: 'heat-shutdown', + pilotDamageGroup: jasmine.stringMatching(/^heat:end-turn:/), + result: { kind: 'automatic', outcome: 'failed' }, + })); + expect(harness.queued[0].target).toBeUndefined(); + expect(resolvePendingChain).toHaveBeenCalledOnceWith([harness.unit]); + }); + + it('describes pending heat and stages effects from the accepted final heat', async () => { + const first = createUnit('first', 4, 18); + const second = createUnit('second', 2, 6); + resolveAutomation.and.callFake((_key: string, events: readonly AutomationReviewEvent[]) => + Promise.resolve(new Set(events.map(event => event.id)))); + + await service.endTurn([first.unit, second.unit]); + + expect(resolveAutomation.calls.argsFor(0)).toEqual([ + 'heatAndDissipationResolution', + [ + { + id: 'heat-and-dissipation:first', + subject: 'Unit first', + event: 'Heat and dissipation', + description: 'Heat 4 → 18', + delta: 14, + breakdown: [{ id: 'weapons', label: 'Weapons', value: 14 }], + effects: ['Shutdown check 6+'], + }, + { + id: 'heat-and-dissipation:second', + subject: 'Unit second', + event: 'Heat and dissipation', + description: 'Heat 2 → 6', + delta: 4, + breakdown: [{ id: 'weapons', label: 'Weapons', value: 4 }], + }, + ], + { + title: 'Review Heat and Dissipation', + message: 'Choose which heat and dissipation results to apply.', + allowCancel: true, + }, + ]); + expect(first.queued[0]).toEqual(jasmine.objectContaining({ + kind: 'heat-shutdown', + target: 6, + })); + expect(second.queued).toEqual([]); + }); + + it('groups every heat effect for one unit into one review entry and includes Life Support hits', async () => { + const harness = createUnit('atlas', 4, 20, true, { lifeSupportHits: 2 }); + resolveAutomation.and.callFake((_key: string, events: readonly AutomationReviewEvent[]) => + Promise.resolve(new Set(events.map(event => event.id)))); + + await service.endTurn([harness.unit]); + + expect(resolveAutomation.calls.argsFor(0)[1][0].effects).toEqual([ + 'Shutdown check 6+', + 'Damaged life support (2 pilot hits)', + ]); + expect(resolveAutomation.calls.argsFor(1)).toEqual([ + 'heatEffectsCheck', + [{ + id: 'heat-effects:atlas', + subject: 'Unit atlas', + event: 'Heat effects', + description: 'Heat 4 → 20', + delta: 16, + breakdown: [{ id: 'weapons', label: 'Weapons', value: 16 }], + effects: [ + 'Shutdown check 6+', + 'Damaged life support (2 pilot hits)', + ], + }], + { + title: 'Review End-Turn Heat Effects', + message: 'Choose which units\' heat effects to resolve.', + allowCancel: true, + }, + ]); + expect(harness.queued.map(check => check.kind)).toEqual(['heat-shutdown', 'heat-life-support']); + }); + + it('uses one grouped review when heat, heat effects, and pilot hits all ask', async () => { + automationModes = { + heatAndDissipationResolution: 'ask', + heatEffectsCheck: 'ask', + pilotHitsAndConsciousnessCheck: 'ask', + }; + const harness = createUnit('atlas', 4, 18, true, { lifeSupportHits: 1 }); + resolveAutomation.and.callFake((_key: string, events: readonly AutomationReviewEvent[]) => + Promise.resolve(new Set(events.map(event => event.id)))); + + expect(await service.endTurn([harness.unit])).toBeTrue(); + + expect(resolveAutomation).toHaveBeenCalledTimes(1); + expect(resolveAutomation).toHaveBeenCalledOnceWith( + 'heatAndDissipationResolution', + [{ + id: 'end-turn-heat:atlas', + subject: 'Unit atlas', + event: 'Heat, dissipation, effects, and pilot hits', + description: 'Heat 4 → 18', + delta: 14, + breakdown: [{ id: 'weapons', label: 'Weapons', value: 14 }], + effects: [ + 'Shutdown check 6+', + 'Damaged life support (1 pilot hit)', + ], + }], + { + title: 'Review End-Turn Heat', + message: 'Choose which units\' heat, dissipation, heat effects, and pilot hits to apply.', + allowCancel: true, + }, + ); + expect(harness.endTurn).toHaveBeenCalledOnceWith({ heatAndDissipationResolution: false, phaseAlreadyEnded: true }); + expect(harness.queued.map(check => check.kind)).toEqual(['heat-shutdown', 'heat-life-support']); + }); + + it('groups heat effects and pilot hits for every unit when both ask after automatic heat', async () => { + automationModes.heatEffectsCheck = 'ask'; + automationModes.pilotHitsAndConsciousnessCheck = 'ask'; + const first = createUnit('first', 4, 18, true, { lifeSupportHits: 1 }); + const second = createUnit('second', 4, 20, true, { lifeSupportHits: 2 }); + resolveAutomation.and.callFake((key: string, events: readonly AutomationReviewEvent[]) => + Promise.resolve(key === 'heatEffectsCheck' + ? new Set(['heat-effects:first']) + : new Set(events.map(event => event.id)))); + + expect(await service.endTurn([first.unit, second.unit])).toBeTrue(); + + expect(resolveAutomation).toHaveBeenCalledTimes(2); + expect(resolveAutomation.calls.argsFor(1)).toEqual([ + 'heatEffectsCheck', + [ + jasmine.objectContaining({ + id: 'heat-effects:first', + event: 'Heat effects and pilot hits', + effects: ['Shutdown check 6+', 'Damaged life support (1 pilot hit)'], + }), + jasmine.objectContaining({ + id: 'heat-effects:second', + event: 'Heat effects and pilot hits', + effects: ['Shutdown check 6+', 'Damaged life support (2 pilot hits)'], + }), + ], + { + title: 'Review End-Turn Heat Effects', + message: 'Choose which units\' heat effects and pilot hits to resolve.', + allowCancel: true, + }, + ]); + expect(first.queued.map(check => check.kind)).toEqual(['heat-shutdown', 'heat-life-support']); + expect(second.queued).toEqual([]); + }); + + it('asks once for Life Support pilot hits after automatic heat effects and skips rejected hits', async () => { + automationModes.pilotHitsAndConsciousnessCheck = 'ask'; + const harness = createUnit('atlas', 4, 20, true, { lifeSupportHits: 2 }); + resolveAutomation.and.callFake((key: string, events: readonly AutomationReviewEvent[]) => + Promise.resolve(key === 'pilotHitsAndConsciousnessCheck' + ? new Set() + : new Set(events.map(event => event.id)))); + + expect(await service.endTurn([harness.unit])).toBeTrue(); + + expect(resolveAutomation.calls.argsFor(2)).toEqual([ + 'pilotHitsAndConsciousnessCheck', + [{ + id: 'pilot-hits:atlas', + subject: 'Unit atlas', + event: 'Pilot hits and consciousness', + description: 'Heat 20', + effects: ['Damaged life support (2 pilot hits)'], + }], + { + title: 'Review Pilot Hits', + message: 'Choose which units\' pilot-hit effects to apply. Accepted hits continue directly into any required Consciousness Rolls.', + allowCancel: true, + }, + ]); + expect(harness.queued.map(check => check.kind)).toEqual(['heat-shutdown']); + }); + + it('omits Life Support pilot-hit automation entirely in no mode', async () => { + automationModes.pilotHitsAndConsciousnessCheck = 'no'; + const harness = createUnit('atlas', 4, 20, true, { lifeSupportHits: 2 }); + resolveAutomation.and.callFake((_key: string, events: readonly AutomationReviewEvent[]) => + Promise.resolve(new Set(events.map(event => event.id)))); + + expect(await service.endTurn([harness.unit])).toBeTrue(); + + expect(resolveAutomation.calls.argsFor(0)[1][0].effects).toEqual(['Shutdown check 6+']); + expect(resolveAutomation.calls.argsFor(1)[1][0].effects).toEqual(['Shutdown check 6+']); + expect(harness.queued.map(check => check.kind)).toEqual(['heat-shutdown']); + }); + + it('runs the shared phase resolver before starting end-turn heat work', async () => { + const harness = createUnit('atlas', 0, 0, false); + resolveAutomation.and.resolveTo(new Set()); + + expect(await service.endTurn([harness.unit])).toBeTrue(); + + expect(resolvePhase).toHaveBeenCalledOnceWith([harness.unit]); + expect(harness.endTurn).toHaveBeenCalledTimes(1); + }); + + it('offers shutdown recovery after End Phase consciousness recovery succeeds', async () => { + const harness = createUnit('atlas', 4, 18, true, { + shutdown: true, + activePilotCrewId: null, + }); + resolvePhase.and.callFake(async () => { + harness.setActivePilotCrewId(0); + return true; + }); + resolveAutomation.and.callFake((_key: string, events: readonly AutomationReviewEvent[]) => + Promise.resolve(new Set(events.map(event => event.id)))); + + expect(await service.endTurn([harness.unit])).toBeTrue(); + + expect(harness.queued).toEqual([ + jasmine.objectContaining({ kind: 'shutdown-recovery', target: 6 }), + ]); + }); + + it('aborts END TURN when CLOSE interrupts the shared phase resolver', async () => { + const harness = createUnit('atlas', 20, 10, true); + resolvePhase.and.resolveTo(false); + + expect(await service.endTurn([harness.unit])).toBeFalse(); + + expect(resolveAutomation).not.toHaveBeenCalled(); + expect(harness.endTurn).not.toHaveBeenCalled(); + expect(harness.getCheckpoint()).toBeUndefined(); + }); + + it('does not reset the turn when CLOSE interrupts a staged heat consequence', async () => { + const harness = createUnit('atlas', 4, 18); + resolveAutomation.and.callFake((_key: string, events: readonly AutomationReviewEvent[]) => + Promise.resolve(new Set(events.map(event => event.id)))); + resolvePendingChain.and.resolveTo(false); + + expect(await service.endTurn([harness.unit])).toBeFalse(); + + expect(harness.resolveEndTurnHeat).toHaveBeenCalledTimes(1); + expect(harness.getCheckpoint()).toBe('heat-staged'); + expect(harness.endTurn).not.toHaveBeenCalled(); + }); + + it('resumes after CLOSE without ending the phase or staging heat twice', async () => { + const harness = createUnit('atlas', 4, 18); + resolveAutomation.and.callFake((_key: string, events: readonly AutomationReviewEvent[]) => + Promise.resolve(new Set(events.map(event => event.id)))); + resolvePendingChain.and.returnValues(Promise.resolve(false), Promise.resolve(true)); + + expect(await service.endTurn([harness.unit])).toBeFalse(); + expect(await service.endTurn([harness.unit])).toBeTrue(); + + expect(resolvePhase).toHaveBeenCalledTimes(1); + expect(resolveAutomation).toHaveBeenCalledTimes(2); + expect(resolvePendingChain).toHaveBeenCalledTimes(2); + expect(harness.resolveEndTurnHeat).toHaveBeenCalledTimes(1); + expect(harness.queued.length).toBe(1); + expect(harness.endTurn).toHaveBeenCalledOnceWith({ + heatAndDissipationResolution: false, + phaseAlreadyEnded: true, + }); + }); + + it('awaits the complete staged consequence chain before resetting the turn', async () => { + const harness = createUnit('atlas', 4, 18); + resolveAutomation.and.callFake((_key: string, events: readonly AutomationReviewEvent[]) => + Promise.resolve(new Set(events.map(event => event.id)))); + let announceChainStarted!: () => void; + let finishChain!: (result: boolean) => void; + const chainStarted = new Promise(resolve => announceChainStarted = resolve); + resolvePendingChain.and.callFake(() => { + announceChainStarted(); + return new Promise(resolve => finishChain = resolve); + }); + + const completion = service.endTurn([harness.unit]); + await chainStarted; + + expect(harness.endTurn).not.toHaveBeenCalled(); + finishChain(true); + expect(await completion).toBeTrue(); + expect(harness.endTurn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/services/cbt-end-turn.service.ts b/src/app/services/cbt-end-turn.service.ts new file mode 100644 index 000000000..dfb1f4a08 --- /dev/null +++ b/src/app/services/cbt-end-turn.service.ts @@ -0,0 +1,387 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { inject, Injectable } from '@angular/core'; +import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import type { AutomationReviewBreakdownItem, AutomationReviewEvent } from '../models/automation-review.model'; +import type { AutomationMode } from '../models/options.model'; +import type { PendingEventInput, SerializedPendingUnitCheck } from '../models/force-serialization'; +import { + unitCheckIsPilotHitHeatEffect, + unitCheckPilotDamagePhase, + type HeatEffectDescriptor, +} from '../models/unit-check.model'; +import { getHeatEffectDescriptors } from '../utils/heat-effects.util'; +import { pendingUnitCheckReviewDescription } from '../utils/unit-check.util'; +import { uuidv7 } from '../utils/uuid.util'; +import { createPilotDamageGroup } from '../utils/pilot-damage-group.util'; +import { buildHeatSummaryRows } from '../utils/heat-summary.util'; +import { CBTAutomationService } from './cbt-automation.service'; +import { CBTAutomationToastService } from './cbt-automation-toast.service'; +import { CBTPhaseResolutionService } from './cbt-phase-resolution.service'; +import { OptionsService } from './options.service'; + +const HEAT_EVENT_PREFIX = 'heat-and-dissipation'; +const HEAT_EFFECT_EVENT_PREFIX = 'heat-effects'; +const COMBINED_HEAT_EVENT_PREFIX = 'end-turn-heat'; +const PILOT_HIT_EVENT_PREFIX = 'pilot-hits'; + +function heatEventId(unit: CBTForceUnit): string { + return `${HEAT_EVENT_PREFIX}:${unit.id}`; +} + +function heatEffectEventId(unit: CBTForceUnit): string { + return `${HEAT_EFFECT_EVENT_PREFIX}:${unit.id}`; +} + +function combinedHeatEventId(unit: CBTForceUnit): string { + return `${COMBINED_HEAT_EVENT_PREFIX}:${unit.id}`; +} + +function pilotHitEventId(unit: CBTForceUnit): string { + return `${PILOT_HIT_EVENT_PREFIX}:${unit.id}`; +} + +interface StagedHeatEffect { + readonly id: string; + readonly descriptor: HeatEffectDescriptor; +} + +interface StagedUnitHeatEffects { + readonly id: string; + readonly unit: CBTForceUnit; + readonly heat: number; + readonly effects: readonly StagedHeatEffect[]; +} + +@Injectable({ providedIn: 'root' }) +export class CBTEndTurnService { + private readonly automations = inject(CBTAutomationService); + private readonly automationToasts = inject(CBTAutomationToastService); + private readonly phaseResolution = inject(CBTPhaseResolutionService); + private readonly options = inject(OptionsService); + private pendingEndTurn: Promise | null = null; + + /** Commits a turn only after its resumable phase, heat, and consequence sequence is complete. */ + async endTurn(units: readonly CBTForceUnit[]): Promise { + if (this.pendingEndTurn) return this.pendingEndTurn; + + const operation = this.performEndTurn(units); + this.pendingEndTurn = operation; + try { + return await operation; + } finally { + if (this.pendingEndTurn === operation) this.pendingEndTurn = null; + } + } + + private async performEndTurn(units: readonly CBTForceUnit[]): Promise { + const uniqueUnits = Array.from(new Map(units.map(unit => [unit.id, unit])).values()); + if (uniqueUnits.length === 0) return false; + + const phaseUnits = uniqueUnits.filter(unit => + unit.turnState().getEndTurnCheckpoint() === undefined); + if (phaseUnits.length > 0) { + if (!await this.phaseResolution.endPhase(phaseUnits)) return false; + phaseUnits.forEach(unit => unit.turnState().markEndTurnPhaseEnded()); + } + + const heatUnits = uniqueUnits.filter(unit => + unit.turnState().getEndTurnCheckpoint() !== 'heat-staged'); + if (heatUnits.length > 0 && !await this.prepareEndTurnHeat(heatUnits)) return false; + if (!await this.phaseResolution.resolvePendingChain(uniqueUnits)) return false; + + uniqueUnits.forEach(unit => unit.endTurn({ + heatAndDissipationResolution: false, + phaseAlreadyEnded: true, + })); + return true; + } + + private async prepareEndTurnHeat(units: readonly CBTForceUnit[]): Promise { + const heatMode = this.options.cbtAutomationMode('heatAndDissipationResolution'); + const heatEffectsMode = this.options.cbtAutomationMode('heatEffectsCheck'); + const pilotHitsMode = this.options.cbtAutomationMode('pilotHitsAndConsciousnessCheck'); + let acceptedHeat: ReadonlySet; + let stagedEffects: readonly StagedUnitHeatEffects[]; + let acceptedEffects: ReadonlySet; + let acceptedPilotHits: ReadonlySet; + + if (heatMode === 'ask' && heatEffectsMode === 'ask') { + const projectedHeat = new Map(units.map(unit => [ + unit.id, + unit.hasPendingEndTurnHeat() + ? unit.turnState().heatProjection().projected + : unit.getHeat().current, + ])); + const projectedEffects = this.stageHeatEffects(units, projectedHeat); + const combinedEvents = units.flatMap(unit => { + const effects = projectedEffects.find(candidate => candidate.unit === unit); + const reviewEffects = this.reviewableHeatEffects(effects?.effects ?? [], pilotHitsMode); + if (!unit.hasPendingEndTurnHeat() && reviewEffects.length === 0) return []; + return [this.createCombinedHeatEvent( + unit, + projectedHeat.get(unit.id)!, + reviewEffects, + pilotHitsMode === 'ask', + )]; + }); + const acceptedCombined = await this.automations.resolve('heatAndDissipationResolution', combinedEvents, { + title: 'Review End-Turn Heat', + message: pilotHitsMode === 'ask' + ? 'Choose which units\' heat, dissipation, heat effects, and pilot hits to apply.' + : 'Choose which units\' heat, dissipation, and heat effects to apply.', + allowCancel: true, + }); + if (acceptedCombined === null) return false; + + acceptedHeat = new Set(units + .filter(unit => unit.hasPendingEndTurnHeat() && acceptedCombined.has(combinedHeatEventId(unit))) + .map(heatEventId)); + const finalHeat = this.finalHeatByUnit(units, acceptedHeat); + stagedEffects = this.stageHeatEffects(units, finalHeat); + acceptedEffects = new Set(stagedEffects + .filter(group => acceptedCombined.has(combinedHeatEventId(group.unit))) + .map(group => group.id)); + acceptedPilotHits = new Set(pilotHitsMode === 'no' ? [] : stagedEffects + .filter(group => acceptedCombined.has(combinedHeatEventId(group.unit))) + .filter(group => group.effects.some(effect => + unitCheckIsPilotHitHeatEffect(effect.descriptor.kind))) + .map(group => pilotHitEventId(group.unit))); + } else { + const heatEvents = units + .filter(unit => unit.hasPendingEndTurnHeat()) + .map(unit => this.createHeatEvent(unit)); + const heatDecision = await this.automations.resolve('heatAndDissipationResolution', heatEvents, { + title: 'Review Heat and Dissipation', + message: 'Choose which heat and dissipation results to apply.', + allowCancel: true, + }); + if (heatDecision === null) return false; + acceptedHeat = heatDecision; + + stagedEffects = this.stageHeatEffects(units, this.finalHeatByUnit(units, acceptedHeat)); + const reviewGroups = stagedEffects + .map(group => ({ group, effects: this.reviewableHeatEffects(group.effects, pilotHitsMode) })) + .filter(candidate => candidate.effects.length > 0); + const combinesEffectsAndPilotHits = heatEffectsMode === 'ask' && pilotHitsMode === 'ask'; + const heatEffectDecision = await this.automations.resolve('heatEffectsCheck', reviewGroups + .map(({ group, effects }) => this.createHeatEffectEvent( + group, + effects, + combinesEffectsAndPilotHits, + acceptedHeat.has(heatEventId(group.unit)), + )), { + title: 'Review End-Turn Heat Effects', + message: combinesEffectsAndPilotHits + ? 'Choose which units\' heat effects and pilot hits to resolve.' + : 'Choose which units\' heat effects to resolve.', + allowCancel: true, + }); + if (heatEffectDecision === null) return false; + acceptedEffects = heatEffectDecision; + + if (combinesEffectsAndPilotHits) { + acceptedPilotHits = new Set(stagedEffects + .filter(group => acceptedEffects.has(group.id)) + .filter(group => group.effects.some(effect => + unitCheckIsPilotHitHeatEffect(effect.descriptor.kind))) + .map(group => pilotHitEventId(group.unit))); + } else { + const pilotHitDecision = await this.resolvePilotHitEffects( + stagedEffects, + acceptedEffects, + pilotHitsMode, + ); + if (pilotHitDecision === null) return false; + acceptedPilotHits = pilotHitDecision; + } + } + + const effectSequence = uuidv7(); + const heatEffectGroup = createPilotDamageGroup('heat', `end-turn:${effectSequence}`); + const endPhaseEffectGroup = createPilotDamageGroup('immediate', `end-turn:${effectSequence}:end`); + + for (const unit of units) { + if (acceptedHeat.has(heatEventId(unit))) { + const previousHeat = unit.getHeat().current; + const resolvedHeat = unit.turnState().heatProjection().projected; + unit.resolveEndTurnHeat(); + if (heatMode === 'yes') { + this.automationToasts.show( + unit, + `Heat and dissipation: Heat ${previousHeat} → ${resolvedHeat}`, + 'info', + ); + } + } + const staged = stagedEffects.find(candidate => candidate.unit === unit); + if (staged && acceptedEffects.has(staged.id)) { + for (const effect of staged.effects) { + if (unitCheckIsPilotHitHeatEffect(effect.descriptor.kind) + && !acceptedPilotHits.has(pilotHitEventId(unit))) continue; + unit.turnState().queuePendingUnitCheck({ + id: effect.id, + pilotDamageGroup: unitCheckPilotDamagePhase(effect.descriptor.kind) === 'end' + ? endPhaseEffectGroup + : heatEffectGroup, + ...effect.descriptor, + } as PendingEventInput); + } + } + unit.turnState().markEndTurnHeatStaged(); + } + return true; + } + + private createHeatEvent(unit: CBTForceUnit): AutomationReviewEvent { + const projectedHeat = unit.turnState().heatProjection().projected; + const pilotHitsMode = this.options.cbtAutomationMode('pilotHitsAndConsciousnessCheck'); + const effects = getHeatEffectDescriptors(unit, projectedHeat) + .filter(descriptor => this.isReviewableHeatEffect(descriptor, pilotHitsMode)) + .map(descriptor => pendingUnitCheckReviewDescription(unit, descriptor, projectedHeat)); + return { + id: heatEventId(unit), + subject: unit.getNotificationDisplayName(), + event: 'Heat and dissipation', + ...this.heatReviewPresentation(unit, projectedHeat), + ...(effects.length > 0 ? { effects } : {}), + }; + } + + private createCombinedHeatEvent( + unit: CBTForceUnit, + heat: number, + effects: readonly StagedHeatEffect[], + includesPilotHitDecision: boolean, + ): AutomationReviewEvent { + return { + id: combinedHeatEventId(unit), + subject: unit.getNotificationDisplayName(), + event: includesPilotHitDecision + ? 'Heat, dissipation, effects, and pilot hits' + : 'Heat, dissipation, and effects', + ...(unit.hasPendingEndTurnHeat() + ? this.heatReviewPresentation(unit, heat) + : { description: `Heat ${heat}` }), + ...(effects.length > 0 ? { + effects: effects.map(effect => + pendingUnitCheckReviewDescription(unit, effect.descriptor, heat)), + } : {}), + }; + } + + private createHeatEffectEvent( + group: StagedUnitHeatEffects, + effects: readonly StagedHeatEffect[], + includesPilotHitDecision: boolean, + includesHeatResolution: boolean, + ): AutomationReviewEvent { + return { + id: group.id, + subject: group.unit.getNotificationDisplayName(), + event: includesPilotHitDecision ? 'Heat effects and pilot hits' : 'Heat effects', + ...(includesHeatResolution + ? this.heatReviewPresentation(group.unit, group.heat) + : { description: `Heat ${group.heat}` }), + effects: effects.map(effect => + pendingUnitCheckReviewDescription(group.unit, effect.descriptor, group.heat)), + }; + } + + private heatReviewPresentation( + unit: CBTForceUnit, + projectedHeat: number, + ): Pick { + const currentHeat = unit.getHeat().current; + const turnState = unit.turnState(); + const projection = turnState.heatProjection(); + const breakdown: AutomationReviewBreakdownItem[] = buildHeatSummaryRows( + turnState.heatSources(), + turnState.heatDissipationBalance(), + projection.consumedDissipation, + projection.projected, + ).map(row => ({ id: row.id, label: row.label, value: row.value })); + return { + description: `Heat ${currentHeat} → ${projectedHeat}`, + delta: projectedHeat - currentHeat, + ...(breakdown.length > 0 ? { breakdown } : {}), + }; + } + + private createPilotHitEvent(group: StagedUnitHeatEffects): AutomationReviewEvent { + const effects = group.effects.filter(effect => + unitCheckIsPilotHitHeatEffect(effect.descriptor.kind)); + return { + id: pilotHitEventId(group.unit), + subject: group.unit.getNotificationDisplayName(), + event: 'Pilot hits and consciousness', + description: `Heat ${group.heat}`, + effects: effects.map(effect => + pendingUnitCheckReviewDescription(group.unit, effect.descriptor, group.heat)), + }; + } + + private async resolvePilotHitEffects( + stagedEffects: readonly StagedUnitHeatEffects[], + acceptedEffects: ReadonlySet, + mode: AutomationMode, + ): Promise | null> { + const groups = stagedEffects + .filter(group => acceptedEffects.has(group.id)) + .filter(group => group.effects.some(effect => + unitCheckIsPilotHitHeatEffect(effect.descriptor.kind))); + if (mode === 'yes') return new Set(groups.map(group => pilotHitEventId(group.unit))); + if (mode === 'no' || groups.length === 0) return new Set(); + return this.automations.resolve('pilotHitsAndConsciousnessCheck', groups.map(group => this.createPilotHitEvent(group)), { + title: 'Review Pilot Hits', + message: 'Choose which units\' pilot-hit effects to apply. Accepted hits continue directly into any required Consciousness Rolls.', + allowCancel: true, + }); + } + + private reviewableHeatEffects( + effects: readonly StagedHeatEffect[], + pilotHitsMode: AutomationMode, + ): readonly StagedHeatEffect[] { + return effects.filter(effect => this.isReviewableHeatEffect(effect.descriptor, pilotHitsMode)); + } + + private isReviewableHeatEffect( + descriptor: HeatEffectDescriptor, + pilotHitsMode: AutomationMode, + ): boolean { + return pilotHitsMode !== 'no' || !unitCheckIsPilotHitHeatEffect(descriptor.kind); + } + + private stageHeatEffects( + units: readonly CBTForceUnit[], + heatByUnit: ReadonlyMap, + ): StagedUnitHeatEffects[] { + return units.flatMap(unit => { + const heat = heatByUnit.get(unit.id)!; + const effects = getHeatEffectDescriptors(unit, heat) + .map(descriptor => ({ id: uuidv7(), descriptor })); + return effects.length > 0 ? [{ + id: heatEffectEventId(unit), + unit, + heat, + effects, + }] : []; + }); + } + + private finalHeatByUnit( + units: readonly CBTForceUnit[], + acceptedHeat: ReadonlySet, + ): ReadonlyMap { + return new Map(units.map(unit => [ + unit.id, + acceptedHeat.has(heatEventId(unit)) + ? unit.turnState().heatProjection().projected + : unit.getHeat().current, + ])); + } + +} diff --git a/src/app/services/cbt-phase-resolution.service.spec.ts b/src/app/services/cbt-phase-resolution.service.spec.ts new file mode 100644 index 000000000..bd8852040 --- /dev/null +++ b/src/app/services/cbt-phase-resolution.service.spec.ts @@ -0,0 +1,389 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { TestBed } from '@angular/core/testing'; +import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import type { PSRCheck } from '../models/rules/unit-type-rules'; +import type { AutomationMode } from '../models/options.model'; +import { CBTPhaseResolutionService } from './cbt-phase-resolution.service'; +import { FallingResolutionService } from './falling-resolution.service'; +import { MekCriticalResolutionService } from './mek-critical-resolution.service'; +import { ToastService } from './toast.service'; +import { UnitCheckResolutionService } from './unit-check-resolution.service'; + +interface PhaseHarness { + readonly unit: CBTForceUnit; + readonly completePilotDamageTurn: jasmine.Spy; + readonly endPhase: jasmine.Spy; + readonly resetPSRChecks: jasmine.Spy; + readonly outcomes: Map; + mode: AutomationMode; + prone: boolean; + autoFall: boolean; + pendingFallId?: string; + pendingUnitChecks: number; + critical?: { type: 'mek-critical-chance' | 'mek-critical-hit'; id: string }; + checks: PSRCheck[]; +} + +describe('CBTPhaseResolutionService', () => { + let service: CBTPhaseResolutionService; + let resumeFall: jasmine.Spy; + let resumeChance: jasmine.Spy; + let resumeCritical: jasmine.Spy; + let openUnitChecks: jasmine.Spy; + let showToast: jasmine.Spy; + + beforeEach(() => { + resumeFall = jasmine.createSpy('resume').and.resolveTo(); + resumeChance = jasmine.createSpy('resumeChance').and.resolveTo(); + resumeCritical = jasmine.createSpy('resume').and.resolveTo(); + openUnitChecks = jasmine.createSpy('open').and.resolveTo(true); + showToast = jasmine.createSpy('showToast'); + + TestBed.configureTestingModule({ + providers: [ + CBTPhaseResolutionService, + { provide: FallingResolutionService, useValue: { resume: resumeFall } }, + { + provide: MekCriticalResolutionService, + useValue: { resumeChance, resume: resumeCritical }, + }, + { provide: UnitCheckResolutionService, useValue: { open: openUnitChecks } }, + { provide: ToastService, useValue: { showToast } }, + ], + }); + service = TestBed.inject(CBTPhaseResolutionService); + }); + + it('drains the complete chain and commits the phase only after it is empty', async () => { + const order: string[] = []; + const harness = createHarness(); + harness.pendingFallId = 'fall:1'; + harness.critical = { type: 'mek-critical-chance', id: 'critical:1' }; + + resumeFall.and.callFake(async () => { + order.push('fall'); + harness.pendingFallId = undefined; + harness.pendingUnitChecks = 1; + }); + openUnitChecks.and.callFake(async () => { + if (harness.pendingUnitChecks > 0) { + order.push('unit-check'); + harness.pendingUnitChecks = 0; + } else { + order.push('psr'); + harness.outcomes.set('psr:1', 'success'); + } + return true; + }); + resumeChance.and.callFake(async () => { + order.push('critical'); + harness.critical = undefined; + harness.checks = [fallCheck('psr:1')]; + }); + harness.endPhase.and.callFake(() => { + order.push('commit'); + }); + + expect(await service.endPhase(harness.unit)).toBeTrue(); + + expect(order).toEqual(['fall', 'unit-check', 'critical', 'psr', 'commit']); + expect(harness.endPhase).toHaveBeenCalledTimes(1); + }); + + it('aborts immediately when CLOSE leaves the current fall queued', async () => { + const harness = createHarness(); + harness.pendingFallId = 'fall:1'; + + expect(await service.endPhase(harness.unit)).toBeFalse(); + + expect(resumeFall).toHaveBeenCalledTimes(1); + expect(harness.endPhase).not.toHaveBeenCalled(); + }); + + it('aborts without a hanging operation when the PSR dialog is closed', async () => { + const harness = createHarness(); + harness.checks = [fallCheck('psr:1')]; + openUnitChecks.and.resolveTo(false); + + expect(await service.endPhase(harness.unit)).toBeFalse(); + + expect(openUnitChecks).toHaveBeenCalledOnceWith([harness.unit], true, false); + expect(harness.endPhase).not.toHaveBeenCalled(); + expect(service.isResolving(harness.unit)).toBeFalse(); + }); + + it('keeps the phase uncommitted when its boundary consciousness dialog is closed', async () => { + const harness = createHarness(); + harness.pendingUnitChecks = 1; + openUnitChecks.and.resolveTo(false); + + expect(await service.endPhase(harness.unit)).toBeFalse(); + + expect(openUnitChecks).toHaveBeenCalledOnceWith([harness.unit], true, false); + expect(harness.endPhase).not.toHaveBeenCalled(); + }); + + it('automatically rolls each unresolved PSR in yes mode', async () => { + const harness = createHarness(); + harness.mode = 'yes'; + harness.checks = [fallCheck('psr:1'), fallCheck('psr:2')]; + spyOn(Math, 'random').and.returnValues(0.99, 0.99, 0, 0); + resumeFall.and.callFake(async () => { + harness.pendingFallId = undefined; + harness.pendingUnitChecks = 1; + }); + openUnitChecks.and.callFake(async () => { + harness.pendingUnitChecks = 0; + return true; + }); + + expect(await service.endPhase(harness.unit)).toBeTrue(); + + expect(harness.outcomes).toEqual(new Map([ + ['psr:1', 'success'], + ['psr:2', 'failed'], + ])); + expect(resumeFall).toHaveBeenCalledTimes(1); + expect(openUnitChecks).toHaveBeenCalledTimes(1); + expect(harness.endPhase).toHaveBeenCalledTimes(1); + expect(showToast.calls.allArgs()).toEqual([ + ['unit:1 — Piloting Skill Check: PASSED (12 vs 7+) — psr:1', 'success'], + ['unit:1 — Piloting Skill Check: FAILED (2 vs 7+) — psr:2', 'error'], + ]); + }); + + it('opens yes-mode PSRs for manual resolution when the pending badge is used', async () => { + const harness = createHarness(); + harness.mode = 'yes'; + harness.checks = [fallCheck('psr:1')]; + spyOn(Math, 'random'); + openUnitChecks.and.callFake(async () => { + harness.outcomes.set('psr:1', 'success'); + return true; + }); + + expect(await service.resumePendingChain(harness.unit)).toBeTrue(); + + expect(openUnitChecks).toHaveBeenCalledOnceWith([harness.unit], false, true); + expect(Math.random).not.toHaveBeenCalled(); + expect(harness.outcomes.get('psr:1')).toBe('success'); + expect(showToast).not.toHaveBeenCalled(); + }); + + it('rolls the TW shutdown PSR but automatically fails a later PSR while shutdown', async () => { + const harness = createHarness(); + harness.mode = 'yes'; + harness.checks = [ + { + id: 'shutdown', kind: 'shutdown', reason: 'Shutdown', + fallCheck: 3, failureOutcome: 'Fall', + }, + { + id: 'damage', reason: 'Received 20 damage', + fallCheck: 1, failureOutcome: 'Fall', + }, + ]; + spyOn(harness.unit.turnState(), 'isPSRCheckAutomaticFailure') + .and.callFake(check => check.kind !== 'shutdown'); + spyOn(Math, 'random').and.returnValues(0.99, 0.99); + resumeFall.and.callFake(async () => { + harness.pendingFallId = undefined; + }); + + expect(await service.endPhase(harness.unit)).toBeTrue(); + + expect(harness.outcomes).toEqual(new Map([ + ['shutdown', 'success'], + ['damage', 'failed'], + ])); + expect(Math.random).toHaveBeenCalledTimes(2); + expect(resumeFall).toHaveBeenCalledTimes(1); + expect(showToast.calls.allArgs()).toEqual([ + ['unit:1 — Piloting Skill Check: PASSED (12 vs 7+) — Shutdown', 'success'], + ['unit:1 — Piloting Skill Check: FAILED (automatic) — Received 20 damage', 'error'], + ]); + }); + + it('keeps PSRs informational in no mode and clears them at the boundary', async () => { + const harness = createHarness(); + harness.mode = 'no'; + harness.autoFall = true; + harness.checks = [fallCheck('psr:1')]; + + expect(await service.endPhase(harness.unit)).toBeTrue(); + + expect(harness.resetPSRChecks).toHaveBeenCalled(); + expect(resumeFall).not.toHaveBeenCalled(); + expect(openUnitChecks).not.toHaveBeenCalled(); + expect(harness.endPhase).toHaveBeenCalledTimes(1); + }); + + it('does not pull a future consciousness recovery into the current phase', async () => { + const harness = createHarness(); + // pendingUnitCheckCount is the actionable gate. A serialized recovery + // whose readyTurn is in the future deliberately reports zero here. + harness.pendingUnitChecks = 0; + + expect(await service.endPhase(harness.unit)).toBeTrue(); + + expect(openUnitChecks).not.toHaveBeenCalled(); + expect(harness.endPhase).toHaveBeenCalledTimes(1); + }); + + it('drains a post-phase shutdown PSR, fall, and seatbelt chain without committing again', async () => { + const harness = createHarness(); + harness.mode = 'yes'; + harness.pendingUnitChecks = 1; + const order: string[] = []; + spyOn(Math, 'random').and.returnValues(0, 0); + openUnitChecks.and.callFake(async (_units: readonly CBTForceUnit[], atPhaseEnd: boolean) => { + order.push(atPhaseEnd ? 'phase-check' : 'turn-check'); + harness.pendingUnitChecks = 0; + if (order.length === 1) harness.checks = [fallCheck('shutdown')]; + return true; + }); + resumeFall.and.callFake(async () => { + order.push('fall'); + harness.pendingFallId = undefined; + harness.pendingUnitChecks = 1; + }); + + expect(await service.resolvePendingChain(harness.unit)).toBeTrue(); + + expect(order).toEqual(['turn-check', 'fall', 'turn-check']); + expect(openUnitChecks).toHaveBeenCalledTimes(2); + expect(resumeFall).toHaveBeenCalledOnceWith(harness.unit, true, false); + expect(harness.endPhase).not.toHaveBeenCalled(); + }); + + it('resumes an overlay event chain without closing phase groups or consolidating fall damage', async () => { + const harness = createHarness(); + harness.pendingFallId = 'fall:1'; + harness.critical = { type: 'mek-critical-hit', id: 'critical:1' }; + + resumeFall.and.callFake(async () => { + harness.pendingFallId = undefined; + }); + resumeCritical.and.callFake(async () => { + harness.critical = undefined; + }); + + expect(await service.resumePendingChain(harness.unit)).toBeTrue(); + + expect(resumeFall).toHaveBeenCalledOnceWith(harness.unit, false, true); + expect(resumeCritical).toHaveBeenCalledOnceWith(harness.unit, 'critical:1', true); + expect(harness.completePilotDamageTurn).not.toHaveBeenCalled(); + expect(harness.endPhase).not.toHaveBeenCalled(); + }); + + it('opens the next queued critical automatically after the current critical is resolved', async () => { + const harness = createHarness(); + harness.critical = { type: 'mek-critical-chance', id: 'critical:1' }; + resumeChance.and.callFake(async (_unit: CBTForceUnit, pendingId: string) => { + harness.critical = pendingId === 'critical:1' + ? { type: 'mek-critical-chance', id: 'critical:2' } + : undefined; + }); + + expect(await service.resumePendingChain(harness.unit)).toBeTrue(); + + expect(resumeChance.calls.allArgs()).toEqual([ + [harness.unit, 'critical:1', true], + [harness.unit, 'critical:2', true], + ]); + }); + + it('stops an overlay event chain when CLOSE leaves the current event queued', async () => { + const harness = createHarness(); + harness.critical = { type: 'mek-critical-chance', id: 'critical:1' }; + + expect(await service.resumePendingChain(harness.unit)).toBeFalse(); + + expect(resumeChance).toHaveBeenCalledOnceWith(harness.unit, 'critical:1', true); + expect(harness.critical).toEqual({ type: 'mek-critical-chance', id: 'critical:1' }); + }); +}); + +function createHarness(): PhaseHarness { + const harness = { + unit: null as unknown as CBTForceUnit, + mode: 'ask' as AutomationMode, + prone: false, + autoFall: false, + pendingFallId: undefined as string | undefined, + pendingUnitChecks: 0, + critical: undefined as PhaseHarness['critical'], + checks: [] as PSRCheck[], + outcomes: new Map(), + completePilotDamageTurn: jasmine.createSpy('completePilotDamageTurn'), + endPhase: jasmine.createSpy('endPhase'), + resetPSRChecks: jasmine.createSpy('resetPSRChecks'), + } as PhaseHarness; + const turnState = { + completePilotDamageTurn: harness.completePilotDamageTurn, + pendingUnitCheckCount: () => harness.pendingUnitChecks, + pendingUnitCheckCountAtPhaseEnd: () => harness.pendingUnitChecks, + getNextPendingCriticalEvent: () => harness.critical, + getPSRChecks: () => harness.checks, + getPSROutcome: (id: string) => harness.outcomes.get(id), + PSRRollsCount: () => harness.checks.filter(check => + check.id !== undefined && !harness.outcomes.has(check.id)).length, + actionablePSRRollsCount: () => harness.checks.filter(check => + check.id !== undefined && !harness.outcomes.has(check.id)).length, + automaticPSRFailure: () => false, + isPSRCheckAutomaticFailure: () => false, + autoFall: () => harness.autoFall, + failPendingPSRChecks: jasmine.createSpy('failPendingPSRChecks'), + resolvePSRCheck: jasmine.createSpy('resolvePSRCheck').and.callFake( + (id: string, outcome: 'success' | 'failed') => { + if (harness.outcomes.has(id)) return false; + harness.outcomes.set(id, outcome); + const check = harness.checks.find(candidate => candidate.id === id); + if (outcome === 'failed' && check?.failureOutcome === 'Fall' && !harness.prone) { + harness.prone = true; + harness.pendingFallId = 'fall:psr'; + } + return true; + }, + ), + resolveAutomaticFall: jasmine.createSpy('resolveAutomaticFall').and.callFake(() => { + if (!harness.autoFall || harness.prone) return false; + harness.prone = true; + harness.pendingFallId = 'fall:auto'; + return true; + }), + resetPSRChecks: harness.resetPSRChecks.and.callFake(() => { + harness.checks = []; + harness.outcomes.clear(); + }), + }; + const unit = { + id: 'unit:1', + force: null as unknown, + turnState: () => turnState, + automationMode: (key: string) => key === 'pilotSkillCheck' ? harness.mode : 'ask', + pendingFallCount: () => harness.pendingFallId ? 1 : 0, + getPendingFall: (id?: string) => harness.pendingFallId + && (!id || id === harness.pendingFallId) + ? { id: harness.pendingFallId, source: 'psr', levelsFallen: 0 } + : undefined, + tracksPhaseAndTurn: () => true, + getCondition: (condition: string) => condition === 'prone' && harness.prone, + getNotificationDisplayName: () => 'unit:1', + PSRTargetRoll: () => 7, + getRuleCheck: () => undefined, + resolveRuleCheck: jasmine.createSpy('resolveRuleCheck').and.returnValue(true), + resolvePendingCrewDeaths: jasmine.createSpy('resolvePendingCrewDeaths'), + endPhase: harness.endPhase, + } as unknown as CBTForceUnit; + (unit as unknown as { force: { units: () => CBTForceUnit[] } }).force = { units: () => [unit] }; + (harness as { unit: CBTForceUnit }).unit = unit; + return harness; +} + +function fallCheck(id: string): PSRCheck { + return { id, fallCheck: 0, reason: id, failureOutcome: 'Fall' }; +} diff --git a/src/app/services/cbt-phase-resolution.service.ts b/src/app/services/cbt-phase-resolution.service.ts new file mode 100644 index 000000000..1019cb2ce --- /dev/null +++ b/src/app/services/cbt-phase-resolution.service.ts @@ -0,0 +1,246 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { inject, Injectable } from '@angular/core'; +import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import type { PSRCheck } from '../models/rules/unit-type-rules'; +import { CBTAutomationToastService } from './cbt-automation-toast.service'; +import { FallingResolutionService } from './falling-resolution.service'; +import { MekCriticalResolutionService } from './mek-critical-resolution.service'; +import { UnitCheckResolutionService } from './unit-check-resolution.service'; + +type RandomSource = () => number; + +export interface AutomaticPilotSkillCheckResolution { + readonly check: PSRCheck; + readonly outcome: 'success' | 'failed'; + readonly target: number; + readonly dice: readonly [number, number] | null; +} + +/** Rolls and applies every currently required Piloting Skill Check in rules order. */ +export function resolvePilotSkillChecksAutomatically( + unit: CBTForceUnit, + random: RandomSource = Math.random, +): AutomaticPilotSkillCheckResolution[] { + const turnState = unit.turnState(); + if (turnState.automaticPSRFailure()) { + const target = unit.PSRTargetRoll(); + const checks = unresolvedPilotSkillChecks(unit); + turnState.failPendingPSRChecks(); + turnState.resolveAutomaticFall(); + return checks.map(check => ({ check, outcome: 'failed', target, dice: null })); + } + + const resolutions: AutomaticPilotSkillCheckResolution[] = []; + while (true) { + const unresolved = unresolvedPilotSkillChecks(unit); + if (unresolved.length === 0) break; + + // An automatic fall is resolved after any independent checks. This + // prevents becoming prone from making an unrelated check disappear. + const check = turnState.autoFall() + ? unresolved.find(candidate => candidate.failureOutcome !== 'Fall') ?? unresolved[0] + : unresolved[0]; + const target = unit.PSRTargetRoll(); + const automaticFailure = turnState.isPSRCheckAutomaticFailure(check) + || (turnState.autoFall() && check.failureOutcome === 'Fall'); + const dice = automaticFailure + ? null + : [rollD6(random), rollD6(random)] as const; + const outcome = automaticFailure || dice![0] + dice![1] < target + ? 'failed' + : 'success'; + + const applied = check.resolution + ? unit.resolveRuleCheck(check.resolution.key, check.resolution.token, outcome) + : check.id !== undefined && turnState.resolvePSRCheck(check.id, outcome); + if (!applied) break; + resolutions.push({ check, outcome, target, dice }); + } + + turnState.resolveAutomaticFall(); + return resolutions; +} + +function unresolvedPilotSkillChecks(unit: CBTForceUnit): PSRCheck[] { + const turnState = unit.turnState(); + return turnState.getPSRChecks().filter(check => { + if (check.fallCheck === undefined || check.id === undefined) return false; + if (!check.resolution) return turnState.getPSROutcome(check.id) === undefined; + const current = unit.getRuleCheck(check.resolution.key); + return !current + || current.token !== check.resolution.token + || current.status === 'pending'; + }); +} + +function rollD6(random: RandomSource): number { + return Math.floor(random() * 6) + 1; +} + +/** + * Drains interactive work at phase and turn boundaries. A dismissed dialog + * returns false immediately and leaves its event available in the UI. + */ +@Injectable({ providedIn: 'root' }) +export class CBTPhaseResolutionService { + private readonly falling = inject(FallingResolutionService); + private readonly criticals = inject(MekCriticalResolutionService); + private readonly unitChecks = inject(UnitCheckResolutionService); + private readonly automationToasts = inject(CBTAutomationToastService); + private readonly activeUnits = new WeakSet(); + + isResolving(unit: CBTForceUnit): boolean { + return this.activeUnits.has(unit); + } + + /** Resolves the complete boundary sequence, then commits the phase once. */ + async endPhase(units: CBTForceUnit | readonly CBTForceUnit[]): Promise { + const requested = Array.isArray(units) ? units : [units]; + return this.run(requested, async targets => { + if (!await this.drain(targets, 'phase')) return false; + targets.forEach(unit => unit.endPhase()); + return true; + }); + } + + /** Drains currently actionable work without committing a phase or turn. */ + async resolvePendingChain(units: CBTForceUnit | readonly CBTForceUnit[]): Promise { + const requested = Array.isArray(units) ? units : [units]; + return this.run(requested, targets => this.drain(targets, 'turn')); + } + + /** + * Resumes queued UI work without closing the current phase's pilot-damage + * group or consolidating pending phase damage. Badge-driven resolution is + * always interactive: configured `yes` modes behave as `ask` for this run. + */ + async resumePendingChain(units: CBTForceUnit | readonly CBTForceUnit[]): Promise { + const requested = Array.isArray(units) ? units : [units]; + return this.run(requested, targets => this.drain(targets, 'interactive')); + } + + private async run( + units: readonly CBTForceUnit[], + operation: (units: readonly CBTForceUnit[]) => Promise, + ): Promise { + const targets = uniqueUnits(units); + if (targets.length === 0 || targets.some(unit => this.activeUnits.has(unit))) return false; + targets.forEach(unit => this.activeUnits.add(unit)); + try { + return await operation(targets); + } finally { + targets.forEach(unit => this.activeUnits.delete(unit)); + } + } + + private async drain( + targets: readonly CBTForceUnit[], + boundary: 'interactive' | 'phase' | 'turn', + ): Promise { + const skippedPilotChecks = new Set(); + const atPhaseEnd = boundary === 'phase'; + const manualResolution = boundary === 'interactive'; + + while (true) { + if (!manualResolution) { + targets.forEach(unit => unit.resolvePendingCrewDeaths()); + } + if (boundary === 'turn') { + targets.forEach(unit => unit.turnState().completePilotDamageTurn()); + } + const fallUnit = targets.find(unit => (unit.pendingFallCount?.() ?? 0) > 0); + if (fallUnit) { + const pendingId = fallUnit.getPendingFall()?.id; + if (!pendingId) continue; + await this.falling.resume( + fallUnit, + boundary === 'turn' || !fallUnit.tracksPhaseAndTurn(), + manualResolution, + ); + if (fallUnit.getPendingFall(pendingId)) return false; + continue; + } + + const hasPendingUnitChecks = targets.some(unit => atPhaseEnd + ? unit.turnState().pendingUnitCheckCountAtPhaseEnd() > 0 + : unit.turnState().pendingUnitCheckCount() > 0); + if (hasPendingUnitChecks) { + if (!await this.unitChecks.open(targets, atPhaseEnd, manualResolution)) return false; + continue; + } + + const criticalUnit = targets.find(unit => + unit.turnState().getNextPendingCriticalEvent() !== undefined); + if (criticalUnit) { + const pending = criticalUnit.turnState().getNextPendingCriticalEvent()!; + if (pending.type === 'mek-critical-chance') { + await this.criticals.resumeChance(criticalUnit, pending.id, manualResolution); + } else { + await this.criticals.resume(criticalUnit, pending.id, manualResolution); + } + if (criticalUnit.turnState().getNextPendingCriticalEvent()?.id === pending.id) { + return false; + } + continue; + } + + const pilotUnit = targets.find(unit => + !skippedPilotChecks.has(unit) && hasPilotSkillWork(unit)); + if (pilotUnit) { + const mode = pilotUnit.automationMode('pilotSkillCheck'); + if (mode === 'no') { + skippedPilotChecks.add(pilotUnit); + continue; + } + if ((!manualResolution && mode === 'yes') + || pilotUnit.turnState().automaticPSRFailure() + || pilotUnit.turnState().actionablePSRRollsCount() === 0) { + const results = resolvePilotSkillChecksAutomatically(pilotUnit); + if (mode === 'yes') { + results.forEach(result => this.showAutomaticPilotSkillToast(pilotUnit, result)); + } + continue; + } + if (!await this.unitChecks.open(targets, atPhaseEnd, manualResolution)) return false; + continue; + } + + // New disabled checks can be created by an earlier event in the + // chain. They remain informational only until this boundary. + if (boundary !== 'interactive') { + targets + .filter(unit => unit.automationMode('pilotSkillCheck') === 'no') + .forEach(unit => unit.turnState().resetPSRChecks()); + } + return true; + } + } + + private showAutomaticPilotSkillToast( + unit: CBTForceUnit, + result: AutomaticPilotSkillCheckResolution, + ): void { + const detail = result.dice + ? ` (${result.dice[0] + result.dice[1]} vs ${result.target}+)` + : ' (automatic)'; + this.automationToasts.show( + unit, + `Piloting Skill Check: ${result.outcome === 'success' ? 'PASSED' : 'FAILED'}${detail} — ${result.check.reason}`, + result.outcome === 'success' ? 'success' : 'error', + ); + } + +} + +function hasPilotSkillWork(unit: CBTForceUnit): boolean { + const turnState = unit.turnState(); + return turnState.PSRRollsCount() > 0 + || (turnState.autoFall() && !unit.getCondition('prone')); +} + +function uniqueUnits(units: readonly CBTForceUnit[]): CBTForceUnit[] { + return Array.from(new Map(units.map(unit => [unit.id, unit])).values()); +} diff --git a/src/app/services/falling-resolution.service.spec.ts b/src/app/services/falling-resolution.service.spec.ts new file mode 100644 index 000000000..76b249380 --- /dev/null +++ b/src/app/services/falling-resolution.service.spec.ts @@ -0,0 +1,397 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { TestBed } from '@angular/core/testing'; +import { Subject, of } from 'rxjs'; +import { + FallingDamageDialogComponent, + type FallingDamageDialogResult, +} from '../components/falling-damage-dialog/falling-damage-dialog.component'; +import { FallingNoticeDialogComponent } from '../components/falling-notice-dialog/falling-notice-dialog.component'; +import type { CBTForceUnit, CBTMekFallDamageRoll } from '../models/cbt-force-unit.model'; +import { CBTAutomationService } from './cbt-automation.service'; +import { DialogsService } from './dialogs.service'; +import { FallingResolutionService } from './falling-resolution.service'; +import { ToastService } from './toast.service'; + +describe('FallingResolutionService', () => { + let service: FallingResolutionService; + let resolveAutomation: jasmine.Spy; + let createDialog: jasmine.Spy; + let closed: Subject; + let showToast: jasmine.Spy; + + beforeEach(() => { + closed = new Subject(); + resolveAutomation = jasmine.createSpy('resolve').and.callFake( + (_key: string, events: Array<{ id: string }>) => + Promise.resolve(new Set(events.map(event => event.id))), + ); + createDialog = jasmine.createSpy('createDialog').and.callFake((component: unknown) => + component === FallingNoticeDialogComponent ? { closed: of(undefined) } : { closed }); + showToast = jasmine.createSpy('showToast'); + TestBed.configureTestingModule({ + providers: [ + FallingResolutionService, + { provide: CBTAutomationService, useValue: { resolve: resolveAutomation } }, + { provide: DialogsService, useValue: { createDialog } }, + { provide: ToastService, useValue: { showToast } }, + ], + }); + service = TestBed.inject(FallingResolutionService); + }); + + it('auto-resolves yes mode after showing the rolled falling direction', async () => { + const harness = createUnit('yes'); + spyOn(Math, 'random').and.returnValues(0.2, 0.99, 0.99, 0.5, 0.5); + + await service.open(harness.unit, { + kind: 'falling', + id: 'fall:1', + source: 'psr', + levelsFallen: 0, + }, false); + + expect(createDialog).toHaveBeenCalledOnceWith( + FallingNoticeDialogComponent, + { + disableClose: true, + data: { + unitName: 'Test Mek', + orientation: jasmine.objectContaining({ + roll: 2, + facingInstruction: 'Keep the current facing', + }), + }, + }, + ); + expect(harness.addArmorHits).toHaveBeenCalledWith('HD', 5, false, false); + expect(harness.applyHeadHitCrewHits).toHaveBeenCalledTimes(1); + expect(harness.completePendingFall).toHaveBeenCalledOnceWith('fall:1'); + expect(showToast).toHaveBeenCalledWith( + 'Test Mek — Fall resolved: 6 damage applied — 5 to Head; 1 to Left Torso', + 'error', + ); + }); + + it('opens the falling panel instead of auto-resolving yes mode when manually requested', async () => { + const harness = createUnit('yes'); + spyOn(Math, 'random'); + + const operation = service.open(harness.unit, { + kind: 'falling', + id: 'fall:1', + source: 'psr', + levelsFallen: 0, + }, false, true); + await settlePromises(); + + expect(createDialog).toHaveBeenCalledOnceWith( + FallingDamageDialogComponent, + jasmine.objectContaining({ + disableClose: false, + data: jasmine.objectContaining({ unit: harness.unit }), + }), + ); + expect(Math.random).not.toHaveBeenCalled(); + expect(harness.completePendingFall).not.toHaveBeenCalled(); + + closed.next({ action: 'close' }); + closed.complete(); + await operation; + }); + + it('reports the pilot hits actually applied by an automatic fall', async () => { + const harness = createUnit('yes', 'yes'); + harness.applyHeadHitCrewHits.and.returnValue(3); + spyOn(Math, 'random').and.returnValues(0.2, 0.99, 0.99, 0.5, 0.5); + + await service.open(harness.unit, { + kind: 'falling', + id: 'fall:1', + source: 'psr', + levelsFallen: 0, + }, false); + + expect(showToast).toHaveBeenCalledWith( + 'Test Mek — Pilot hits from falling: 3 applied', + 'error', + ); + }); + + it('opens the fall directly, applies the selected damage, and resolves falling head hits', async () => { + const harness = createUnit(); + const operation = service.open(harness.unit, { + kind: 'falling', + id: 'fall:1', + source: 'stand-attempt', + levelsFallen: 0, + }, false); + await settlePromises(); + + expect(createDialog).toHaveBeenCalled(); + + closed.next(headHitFallResult()); + closed.complete(); + await operation; + + expect(harness.addArmorHits).toHaveBeenCalledOnceWith('HD', 5, false, false); + expect(resolveAutomation.calls.allArgs().map(args => args[0])).toEqual([ + 'pilotHitsAndConsciousnessCheck', + ]); + expect(harness.applyHeadHitCrewHits).toHaveBeenCalledTimes(1); + expect(harness.completePendingFall).toHaveBeenCalledOnceWith('fall:1'); + expect(showToast).toHaveBeenCalledWith( + 'Keep the current facing; 5 falling damage applied', + 'error', + ); + }); + + it('leaves the complete fall unapplied when the falling head-hit review is cancelled', async () => { + resolveAutomation.and.resolveTo(null); + const harness = createUnit(); + const operation = service.open(harness.unit, { + kind: 'falling', + id: 'fall:1', + source: 'psr', + levelsFallen: 0, + }, false); + await settlePromises(); + + closed.next(headHitFallResult()); + closed.complete(); + await operation; + + expect(harness.addArmorHits).not.toHaveBeenCalled(); + expect(harness.applyHeadHitCrewHits).not.toHaveBeenCalled(); + expect(harness.completePendingFall).not.toHaveBeenCalled(); + expect(harness.unit.getPendingFall('fall:1')).toBeDefined(); + }); + + it('applies fall damage but not a rejected falling head-hit injury', async () => { + resolveAutomation.and.resolveTo(new Set()); + const harness = createUnit(); + const operation = service.open(harness.unit, { + kind: 'falling', + id: 'fall:1', + source: 'psr', + levelsFallen: 0, + }, false); + await settlePromises(); + + closed.next(headHitFallResult()); + closed.complete(); + await operation; + + expect(harness.addArmorHits).toHaveBeenCalledOnceWith('HD', 5, false, false); + expect(harness.applyHeadHitCrewHits).not.toHaveBeenCalled(); + expect(harness.completePendingFall).toHaveBeenCalledOnceWith('fall:1'); + }); + + it('does not let a later queued fall bypass the first pending fall', async () => { + const harness = createUnit(); + const first = harness.unit.getPendingFall('fall:1')!; + const second = { ...first, id: 'fall:2' }; + (harness.unit as unknown as { getPendingFall: (id?: string) => typeof first | undefined }) + .getPendingFall = id => id ? [first, second].find(fall => fall.id === id) : first; + + await service.open(harness.unit, { + kind: 'falling', + id: 'fall:2', + source: 'psr', + levelsFallen: 0, + }, false); + + expect(createDialog).not.toHaveBeenCalled(); + }); + + it('skips a queued fall without opening a dialog when falling automation is no', async () => { + const harness = createUnit('no'); + + await service.open(harness.unit, { + kind: 'falling', + id: 'fall:1', + source: 'psr', + levelsFallen: 0, + }, false); + + expect(createDialog).not.toHaveBeenCalled(); + expect(harness.addArmorHits).not.toHaveBeenCalled(); + expect(harness.skipPendingFall).toHaveBeenCalledOnceWith('fall:1'); + expect(harness.completePendingFall).not.toHaveBeenCalled(); + }); + + it('discards the fall without damage or seatbelt work when IGNORE is pressed', async () => { + const harness = createUnit(); + const operation = service.open(harness.unit, { + kind: 'falling', + id: 'fall:1', + source: 'psr', + levelsFallen: 0, + }, false); + await settlePromises(); + + closed.next({ action: 'ignore' }); + closed.complete(); + await operation; + + expect(harness.addArmorHits).not.toHaveBeenCalled(); + expect(harness.completePendingFall).not.toHaveBeenCalled(); + expect(harness.skipPendingFall).toHaveBeenCalledOnceWith('fall:1'); + expect(harness.unit.getPendingFall('fall:1')).toBeUndefined(); + }); + + it('leaves the fall pending and does not release seatbelt work when CLOSE is pressed', async () => { + const harness = createUnit(); + const operation = service.open(harness.unit, { + kind: 'falling', + id: 'fall:1', + source: 'psr', + levelsFallen: 0, + }, false); + await settlePromises(); + + closed.next({ action: 'close' }); + closed.complete(); + await operation; + + expect(harness.addArmorHits).not.toHaveBeenCalled(); + expect(harness.completePendingFall).not.toHaveBeenCalled(); + expect(harness.unit.getPendingFall('fall:1')).toBeDefined(); + }); + + it('leaves the fall pending when the dialog is dismissed', async () => { + const harness = createUnit(); + + const operation = service.open(harness.unit, { + kind: 'falling', + id: 'fall:1', + source: 'psr', + levelsFallen: 0, + }, false); + await settlePromises(); + closed.next(undefined); + closed.complete(); + await operation; + + expect(createDialog).toHaveBeenCalled(); + expect(harness.completePendingFall).not.toHaveBeenCalled(); + }); +}); + +function createUnit( + fallingMode: 'yes' | 'ask' | 'no' = 'ask', + pilotHitsMode: 'yes' | 'ask' | 'no' = 'ask', +): { + unit: CBTForceUnit; + addArmorHits: jasmine.Spy; + applyHeadHitCrewHits: jasmine.Spy; + completePendingFall: jasmine.Spy; + skipPendingFall: jasmine.Spy; +} { + const armorHits = new Map(); + const addArmorHits = jasmine.createSpy('addArmorHits').and.callFake((location: string, hits: number) => { + armorHits.set(location, (armorHits.get(location) ?? 0) + hits); + }); + const applyHeadHitCrewHits = jasmine.createSpy('applyHeadHitCrewHits').and.returnValue(1); + const pendingFalls: Array<{ + id: string; + source: 'psr' | 'stand-attempt'; + levelsFallen: number; + orientationRoll: number | null; + orientationDice: readonly [number] | null; + damageRolls: CBTMekFallDamageRoll[]; + }> = [{ + id: 'fall:1', + source: 'psr' as const, + levelsFallen: 0, + orientationRoll: null, + orientationDice: null, + damageRolls: [], + }]; + const completePendingFall = jasmine.createSpy('completePendingFall').and.callFake((id: string) => { + const index = pendingFalls.findIndex(pending => pending.id === id); + if (index < 0) return false; + pendingFalls.splice(index, 1); + return true; + }); + const skipPendingFall = jasmine.createSpy('skipPendingFall').and.callFake((id: string) => { + const index = pendingFalls.findIndex(pending => pending.id === id); + if (index < 0) return false; + pendingFalls.splice(index, 1); + return true; + }); + const unit = { + id: 'unit:test-mek', + gameRules: { id: 'core2026', aggregatedEndPhaseConsciousRolls: true }, + locations: { internal: new Map([['HD', { loc: 'HD' }], ['CT', { loc: 'CT' }]]) }, + getUnit: () => ({ + type: 'Mek', + subtype: 'BattleMek', + tons: 55, + comp: [], + armorType: 'Standard Armor', + structureType: 'Standard', + }), + getNotificationDisplayName: () => 'Test Mek', + automationMode: (key: string) => key === 'fallingCheck' + ? fallingMode + : key === 'pilotHitsAndConsciousnessCheck' ? pilotHitsMode : 'ask', + getPendingFall: (id?: string) => id + ? pendingFalls.find(pending => pending.id === id) + : pendingFalls[0], + setPendingFallRolls: ( + id: string, + orientationRoll: number, + damageRolls: readonly CBTMekFallDamageRoll[], + orientationDice: readonly [number] | null, + ) => { + const pending = pendingFalls.find(candidate => candidate.id === id); + if (!pending) return false; + pending.orientationRoll = orientationRoll; + pending.orientationDice = orientationDice; + pending.damageRolls = [...damageRolls]; + return true; + }, + completePendingFall, + skipPendingFall, + getArmorPoints: (location: string) => location === 'HD' ? 9 : 10, + getArmorHits: (location: string) => armorHits.get(location) ?? 0, + addArmorHits, + getInternalPoints: () => 10, + getInternalHits: () => 0, + addInternalHits: jasmine.createSpy('addInternalHits'), + applyHeadHitCrewHits, + } as unknown as CBTForceUnit; + return { unit, addArmorHits, applyHeadHitCrewHits, completePendingFall, skipPendingFall }; +} + +async function settlePromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +function headHitFallResult(): FallingDamageDialogResult { + return { + action: 'accept', + orientation: { + roll: 1, + facingOffset: 0, + facingInstruction: 'Keep the current facing', + hitArc: 'front', + hitArcLabel: 'Front', + rulesExplanation: 'Test', + }, + groups: [{ + damage: 5, + hitLocationRoll: 12, + rawTableResult: 'HD', + tableLabel: 'HD', + location: 'HD', + locationLabel: 'Head', + rear: false, + critical: false, + }], + }; +} diff --git a/src/app/services/falling-resolution.service.ts b/src/app/services/falling-resolution.service.ts new file mode 100644 index 000000000..30f6d690a --- /dev/null +++ b/src/app/services/falling-resolution.service.ts @@ -0,0 +1,289 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { inject, Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { + FallingDamageDialogComponent, + type AcceptedFallingDamageDialogResult, + type FallingAutomationTrigger, + type FallingDamageDialogData, + type FallingDamageDialogResult, +} from '../components/falling-damage-dialog/falling-damage-dialog.component'; +import { + FallingNoticeDialogComponent, + type FallingNoticeDialogData, +} from '../components/falling-notice-dialog/falling-notice-dialog.component'; +import type { AutomationReviewEvent } from '../models/automation-review.model'; +import type { CBTForceUnit, CBTMekFallDamageRoll } from '../models/cbt-force-unit.model'; +import { getMekLocationLabel } from '../models/entity/types'; +import { + applyMekFallDamage, + isResolvedMekFallHitLocation, + mekFallDamage, + mekFallDamageGroups, + resolveMekFallHitLocation, + resolveMekFallOrientation, + type ResolvedMekFallDamageGroup, +} from '../utils/mek-falling.util'; +import { clusterTableForUnit } from '../utils/record-sheet-reference-table'; +import { uuidv7 } from '../utils/uuid.util'; +import { CBTAutomationService } from './cbt-automation.service'; +import { CBTAutomationToastService } from './cbt-automation-toast.service'; +import { DialogsService } from './dialogs.service'; +import { ToastService } from './toast.service'; + +@Injectable({ providedIn: 'root' }) +export class FallingResolutionService { + private readonly dialogs = inject(DialogsService); + private readonly automations = inject(CBTAutomationService); + private readonly automationToasts = inject(CBTAutomationToastService); + private readonly toasts = inject(ToastService); + private readonly activeUnits = new WeakSet(); + + async resume( + unit: CBTForceUnit, + consolidateImmediately: boolean, + manualResolution = false, + ): Promise { + const pending = unit.getPendingFall(); + if (!pending) return; + await this.open(unit, { + kind: 'falling', + id: pending.id, + source: pending.source, + levelsFallen: pending.levelsFallen, + }, consolidateImmediately, manualResolution); + } + + async open( + unit: CBTForceUnit, + trigger: FallingAutomationTrigger, + consolidateImmediately: boolean, + manualResolution = false, + ): Promise { + if (this.activeUnits.has(unit) || unit.getUnit().type !== 'Mek') return; + const pending = unit.getPendingFall(); + if (!pending || pending.id !== trigger.id) return; + if (unit.automationMode('fallingCheck') === 'no') { + unit.skipPendingFall(pending.id); + return; + } + const currentTrigger: FallingAutomationTrigger = { + kind: 'falling', + id: pending.id, + source: pending.source, + levelsFallen: pending.levelsFallen, + }; + this.activeUnits.add(unit); + try { + if (!manualResolution && unit.automationMode('fallingCheck') === 'yes') { + const result = this.resolveAutomatically(unit, currentTrigger); + const notice = this.dialogs.createDialog< + void, + FallingNoticeDialogComponent, + FallingNoticeDialogData + >( + FallingNoticeDialogComponent, + { + disableClose: true, + data: { + unitName: unit.getNotificationDisplayName(), + orientation: result.orientation, + }, + }, + ); + await firstValueFrom(notice.closed); + await this.applyAcceptedFall( + unit, + currentTrigger, + result, + consolidateImmediately, + false, + ); + return; + } + + const ref = this.dialogs.createDialog( + FallingDamageDialogComponent, + { + disableClose: false, + data: { unit, trigger: currentTrigger }, + }, + ); + const result = await firstValueFrom(ref.closed); + if (!result || result.action === 'close') return; + if (result.action === 'ignore') { + // IGNORE discards the entire automated fall resolution. Only + // ACCEPT advances the sequence to a seatbelt check. + unit.skipPendingFall(currentTrigger.id); + return; + } + if (result.action !== 'accept') return; + await this.applyAcceptedFall( + unit, + currentTrigger, + result, + consolidateImmediately, + manualResolution, + ); + } finally { + this.activeUnits.delete(unit); + } + } + + private resolveAutomatically( + unit: CBTForceUnit, + trigger: FallingAutomationTrigger, + ): AcceptedFallingDamageDialogResult { + const pending = unit.getPendingFall(trigger.id); + const generatedOrientation = !pending + || pending.orientationRoll === null + || pending.orientationRoll < 1 + || pending.orientationRoll > 6; + const orientationRoll = generatedOrientation + ? this.rollD6() + : pending.orientationRoll; + const orientation = resolveMekFallOrientation(unit.gameRules.id, orientationRoll); + const damageGroups = mekFallDamageGroups(mekFallDamage( + unit.getUnit().tons, + trigger.levelsFallen, + )); + const hitLocationTable = clusterTableForUnit(unit.getUnit()).hitLocationTable ?? 'biped'; + const damageRolls: CBTMekFallDamageRoll[] = []; + const groups: ResolvedMekFallDamageGroup[] = []; + + damageGroups.forEach((damage, index) => { + const saved = pending?.damageRolls[index]; + const generatedHitLocation = !saved + || saved.hitLocationRoll === null + || saved.hitLocationRoll < 2 + || saved.hitLocationRoll > 12; + const hitLocationDice = generatedHitLocation + ? [this.rollD6(), this.rollD6()] as const + : saved.hitLocationDice ?? null; + const hitLocationRoll = generatedHitLocation + ? hitLocationDice![0] + hitLocationDice![1] + : saved.hitLocationRoll; + const preliminary = resolveMekFallHitLocation( + hitLocationTable, + orientation.hitArc, + hitLocationRoll, + ); + const needsTripodLeg = preliminary.location === null + && preliminary.tripodLegModifier !== undefined; + const generatedTripodLeg = needsTripodLeg + && (!saved || saved.tripodLegRoll === null + || saved.tripodLegRoll < 1 || saved.tripodLegRoll > 6); + const tripodLegRoll = !needsTripodLeg + ? null + : generatedTripodLeg + ? this.rollD6() + : saved!.tripodLegRoll; + const result = resolveMekFallHitLocation( + hitLocationTable, + orientation.hitArc, + hitLocationRoll, + tripodLegRoll ?? undefined, + ); + if (!isResolvedMekFallHitLocation(result)) { + throw new Error('Automatic falling resolution did not produce a hit location.'); + } + + damageRolls.push({ + hitLocationRoll, + hitLocationDice, + tripodLegRoll, + tripodLegDice: generatedTripodLeg ? [tripodLegRoll!] : saved?.tripodLegDice ?? null, + }); + groups.push({ ...result, damage }); + }); + + unit.setPendingFallRolls( + trigger.id, + orientationRoll, + damageRolls, + generatedOrientation ? [orientationRoll] : pending?.orientationDice ?? null, + ); + return { action: 'accept', orientation, groups }; + } + + private async applyAcceptedFall( + unit: CBTForceUnit, + trigger: FallingAutomationTrigger, + result: AcceptedFallingDamageDialogResult, + consolidateImmediately: boolean, + manualResolution: boolean, + ): Promise { + const acceptedHeadHits = await this.reviewHeadHits( + unit, + result.groups.filter(group => group.location === 'HD').length, + ); + if (acceptedHeadHits === null) return; + + const applied = applyMekFallDamage(unit, result.groups, consolidateImmediately); + const automaticFall = !manualResolution + && unit.automationMode('fallingCheck') === 'yes'; + if (automaticFall) { + const damageByLocation = new Map(); + for (const location of applied.locations) { + damageByLocation.set( + location.location, + (damageByLocation.get(location.location) ?? 0) + + location.armorDamage + + location.internalDamage, + ); + } + const locations = Array.from(damageByLocation, ([location, damage]) => + `${damage} to ${getMekLocationLabel(location) ?? location}`, + ).join('; '); + this.automationToasts.show( + unit, + `Fall resolved: ${applied.appliedDamage} damage applied${locations ? ` — ${locations}` : ''}`, + applied.appliedDamage > 0 ? 'error' : 'info', + ); + } else { + this.toasts.showToast( + `${result.orientation.facingInstruction}; ${applied.appliedDamage} falling damage applied`, + applied.appliedDamage > 0 ? 'error' : 'info', + ); + } + const appliedHeadHits = Math.min(acceptedHeadHits, applied.headHits); + let appliedPilotHits = 0; + for (let index = 0; index < appliedHeadHits; index++) { + appliedPilotHits += unit.applyHeadHitCrewHits(); + } + if (appliedHeadHits > 0 + && unit.automationMode('pilotHitsAndConsciousnessCheck') === 'yes') { + this.automationToasts.show( + unit, + `Pilot hits from falling: ${appliedPilotHits > 0 ? `${appliedPilotHits} applied` : 'none applied'}`, + appliedPilotHits > 0 ? 'error' : 'info', + ); + } + unit.completePendingFall(trigger.id); + } + + private rollD6(): number { + return Math.floor(Math.random() * 6) + 1; + } + + private async reviewHeadHits(unit: CBTForceUnit, count: number): Promise { + if (count <= 0) return 0; + const events: AutomationReviewEvent[] = Array.from({ length: count }, (_unused, index) => ({ + id: uuidv7(), + subject: unit.getNotificationDisplayName(), + event: count === 1 ? 'Head hit from falling' : `Head hit from falling ${index + 1}`, + description: 'Apply the resulting pilot hit', + effects: ['Queue any required Consciousness Roll'], + })); + const accepted = await this.automations.resolve('pilotHitsAndConsciousnessCheck', events, { + title: 'Review Falling Head Hits', + message: 'Choose which pilot hits to apply.', + }); + return accepted === null + ? null + : events.reduce((total, event) => total + (accepted.has(event.id) ? 1 : 0), 0); + } +} diff --git a/src/app/services/mek-critical-hit-automation.service.spec.ts b/src/app/services/mek-critical-hit-automation.service.spec.ts new file mode 100644 index 000000000..d99b62576 --- /dev/null +++ b/src/app/services/mek-critical-hit-automation.service.spec.ts @@ -0,0 +1,165 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { TestBed } from '@angular/core/testing'; +import { AmmoEquipment } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; +import type { CriticalSlot } from '../models/force-serialization'; +import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import { CORE_2026_GAME_RULES } from '../models/rules/game-rules'; +import { CBTAutomationService } from './cbt-automation.service'; +import { MekCriticalHitAutomationService } from './mek-critical-hit-automation.service'; +import { ToastService } from './toast.service'; + +describe('MekCriticalHitAutomationService', () => { + let service: MekCriticalHitAutomationService; + let resolveAutomation: jasmine.Spy; + let showToast: jasmine.Spy; + + beforeEach(() => { + resolveAutomation = jasmine.createSpy('resolve'); + showToast = jasmine.createSpy('showToast'); + TestBed.configureTestingModule({ + providers: [ + MekCriticalHitAutomationService, + { provide: CBTAutomationService, useValue: { resolve: resolveAutomation } }, + { provide: ToastService, useValue: { showToast } }, + ], + }); + service = TestBed.inject(MekCriticalHitAutomationService); + }); + + it('applies an accepted explosion and describes its damage before mutation', async () => { + const fixture = explodingAmmoUnit('yes'); + resolveAutomation.and.callFake((_key: string, events: Array<{ id: string }>) => + Promise.resolve(new Set([events[0].id]))); + + const resolution = await service.applyRoll(fixture.unit, 'LT', [1, 1], true); + + expect(resolveAutomation).toHaveBeenCalledOnceWith( + 'internalExplosionsCheck', + [jasmine.objectContaining({ + subject: 'Archer ARC-2D', + event: 'Internal explosion', + description: 'AC/10 Ammo in Left Torso · 100 damage', + effects: [ + 'Left Torso: 12 internal', + 'Center Torso: 8 internal · 12 rear armor', + 'MechWarrior feedback: 1 hit', + ], + })], + jasmine.any(Object), + ); + expect(resolution.cancelled).toBeFalse(); + expect(resolution.outcome?.explosion?.rawDamage).toBe(100); + expect(fixture.slot.hits).toBe(1); + expect(fixture.slot.consumed).toBe(0); + expect(fixture.internalHits.get('LT')).toBe(12); + expect(showToast).toHaveBeenCalledWith( + 'Archer ARC-2D — Internal explosion: AC/10 Ammo, 100 damage in Left Torso; 1 pilot hit applied', + 'error', + ); + }); + + it('applies the manually selected critical but not its rejected explosion', async () => { + const fixture = explodingAmmoUnit(); + resolveAutomation.and.resolveTo(new Set()); + + const resolution = await service.applySlot(fixture.unit, fixture.slot, true); + + expect(resolution.cancelled).toBeFalse(); + expect(resolution.outcome?.applied).toBeTrue(); + expect(resolution.outcome?.explosion).toBeUndefined(); + expect(fixture.slot.hits).toBe(1); + expect(fixture.slot.consumed).toBe(0); + expect(fixture.internalHits.size).toBe(0); + }); + + it('applies nothing when explosion review is cancelled', async () => { + const fixture = explodingAmmoUnit(); + resolveAutomation.and.resolveTo(null); + + const resolution = await service.applyRoll(fixture.unit, 'LT', [1, 1], true); + + expect(resolution).toEqual({ cancelled: true, outcome: null }); + expect(fixture.slot.hits).toBe(0); + expect(fixture.slot.consumed).toBe(0); + expect(fixture.internalHits.size).toBe(0); + }); +}); + +function explodingAmmoUnit(automationMode: 'yes' | 'ask' = 'ask'): { + readonly unit: CBTForceUnit; + readonly slot: CriticalSlot; + readonly internalHits: Map; +} { + const ammo = new AmmoEquipment({ + id: 'TestAC10Ammo', + name: 'AC/10 Ammo', + type: 'ammo', + stats: { explosive: true }, + ammo: { type: 'AC', rackSize: 10, shots: 10, damagePerShot: 1 }, + }); + const slot: CriticalSlot = { + id: 'ammo@LT', + name: ammo.name, + loc: 'LT', + slot: 0, + totalAmmo: 10, + consumed: 0, + hits: 0, + eq: ammo, + }; + const internalPoints = new Map([['LT', 12], ['CT', 31]]); + const armorPoints = new Map([['LT', 16], ['CT', 31], ['CT-rear', 12]]); + const internalHits = new Map(); + const armorHits = new Map(); + let pilotHits = 0; + const unit = { + id: 'unit-a', + gameRules: CORE_2026_GAME_RULES, + rules: { mountedCriticalDamageDestructionThreshold: () => 1 }, + locations: { internal: internalPoints }, + getNotificationDisplayName: () => 'Archer ARC-2D', + automationMode: () => automationMode, + getCritSlots: () => [slot], + getCritSlot: (location: string, index: number) => + location === slot.loc && index === slot.slot ? slot : null, + getInventory: () => [], + getEquipmentStatus: () => 'available', + isEquipmentOperational: () => true, + getCriticalDelayedExplosion: () => null, + getInventoryControlSelectedAmmo: () => null, + getEquipmentRegistry: () => EMPTY_EQUIPMENT_REGISTRY, + getInventoryControlRules: () => ({}), + getUnit: () => ({ structureType: '', armorType: 'Standard', features: [], comp: [] }), + getCrewMember: () => ({ + getHits: () => pilotHits, + setHits: (hits: number) => { pilotHits = hits; }, + }), + applyInternalExplosionCrewHits: (hits: number) => { + pilotHits += hits; + return hits; + }, + applyHitToCritSlot: (critical: CriticalSlot) => { + critical.hits = (critical.hits ?? 0) + 1; + critical.destroying = Date.now(); + }, + getInternalPoints: (location: string) => internalPoints.get(location) ?? 0, + getInternalHits: (location: string) => internalHits.get(location) ?? 0, + addInternalHits: (location: string, damage: number) => { + internalHits.set(location, (internalHits.get(location) ?? 0) + damage); + }, + getArmorPoints: (location: string, rear: boolean) => + armorPoints.get(`${location}${rear ? '-rear' : ''}`) ?? 0, + getArmorHits: (location: string, rear: boolean) => + armorHits.get(`${location}${rear ? '-rear' : ''}`) ?? 0, + addArmorHits: (location: string, damage: number, rear: boolean) => { + const key = `${location}${rear ? '-rear' : ''}`; + armorHits.set(key, (armorHits.get(key) ?? 0) + damage); + }, + } as unknown as CBTForceUnit; + + return { unit, slot, internalHits }; +} diff --git a/src/app/services/mek-critical-hit-automation.service.ts b/src/app/services/mek-critical-hit-automation.service.ts new file mode 100644 index 000000000..fccf63560 --- /dev/null +++ b/src/app/services/mek-critical-hit-automation.service.ts @@ -0,0 +1,169 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { inject, Injectable } from '@angular/core'; +import type { AutomationReviewEvent } from '../models/automation-review.model'; +import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import type { CriticalSlot } from '../models/force-serialization'; +import { getMekLocationLabel } from '../models/entity/types'; +import { + applyMekCriticalRoll, + applyMekCriticalSlotHit, + previewMekCriticalRoll, + previewMekCriticalSlotHit, + type MekCriticalExplosionPreview, + type MekCriticalHitPreview, + type MekCriticalHitOptions, + type MekCriticalRollOutcome, +} from '../utils/mek-critical-hit.util'; +import { uuidv7 } from '../utils/uuid.util'; +import { CBTAutomationService } from './cbt-automation.service'; +import { CBTAutomationToastService } from './cbt-automation-toast.service'; + +export interface MekCriticalHitAutomationResolution { + readonly cancelled: boolean; + readonly outcome: MekCriticalRollOutcome | null; +} + +@Injectable({ providedIn: 'root' }) +export class MekCriticalHitAutomationService { + private readonly automations = inject(CBTAutomationService); + private readonly automationToasts = inject(CBTAutomationToastService); + + previewRoll( + unit: CBTForceUnit, + location: string, + results: readonly number[], + options: MekCriticalHitOptions = {}, + ): MekCriticalHitPreview | null { + return previewMekCriticalRoll(unit, location, results, options); + } + + previewSlot( + unit: CBTForceUnit, + slot: CriticalSlot, + options: Pick = {}, + ): MekCriticalHitPreview | null { + return previewMekCriticalSlotHit(unit, slot, options); + } + + async applyRoll( + unit: CBTForceUnit, + location: string, + results: readonly number[], + consolidateImmediately: boolean, + options: MekCriticalHitOptions = {}, + ): Promise { + const preview = this.previewRoll(unit, location, results, options); + return this.resolve(unit, location, preview, applyExplosion => applyMekCriticalRoll( + unit, + location, + results, + consolidateImmediately, + { ...options, applyExplosion }, + )); + } + + async applySlot( + unit: CBTForceUnit, + slot: CriticalSlot, + consolidateImmediately: boolean, + ): Promise { + const location = slot.loc ?? ''; + const preview = this.previewSlot(unit, slot); + return this.resolve(unit, location, preview, applyExplosion => applyMekCriticalSlotHit( + unit, + slot, + consolidateImmediately, + { applyExplosion }, + )); + } + + private async resolve( + unit: CBTForceUnit, + location: string, + preview: MekCriticalHitPreview | null, + apply: (applyExplosion: boolean) => MekCriticalRollOutcome | null, + ): Promise { + if (!preview?.explosion) { + return { cancelled: false, outcome: apply(false) }; + } + + const event = this.createExplosionEvent(unit, location, preview.explosion); + const accepted = await this.automations.resolve('internalExplosionsCheck', [event], { + title: 'Review Internal Explosion', + message: 'Choose whether to resolve this explosion automatically. SKIP applies only the critical hit.', + }); + if (accepted === null) return { cancelled: true, outcome: null }; + + const applyExplosion = accepted.has(event.id); + const outcome = apply(applyExplosion); + if (applyExplosion + && outcome + && unit.automationMode('internalExplosionsCheck') === 'yes') { + const explosion = outcome.explosion ?? outcome.pendingExplosion; + if (explosion) { + const pilotHits = outcome.explosion?.pilotHits ?? 0; + this.automationToasts.show( + unit, + `Internal explosion: ${explosion.equipment}, ${explosion.rawDamage} damage in ${getMekLocationLabel(location) ?? location}${outcome.pendingExplosion ? ' queued for phase end' : ''}${pilotHits > 0 ? `; ${pilotHits} pilot hit${pilotHits === 1 ? '' : 's'} applied` : ''}`, + 'error', + ); + } + const automaticCritical = outcome.explosion?.automaticCritical; + if (automaticCritical) { + this.automationToasts.show( + unit, + `Critical hit in ${getMekLocationLabel(automaticCritical.location) ?? automaticCritical.location}: ${automaticCritical.equipment} (slot ${automaticCritical.slotNumber})`, + 'error', + ); + } + } + + return { + cancelled: false, + outcome, + }; + } + + private createExplosionEvent( + unit: CBTForceUnit, + location: string, + explosion: MekCriticalExplosionPreview, + ): AutomationReviewEvent { + const locationLabel = getMekLocationLabel(location) ?? location; + const effects = explosion.locations + .filter(damage => damage.internalDamage > 0 || damage.armorDamage > 0) + .map(damage => { + const parts: string[] = []; + if (damage.internalDamage > 0) { + parts.push(`${damage.internalDamage} internal`); + } + if (damage.armorDamage > 0) { + parts.push(`${damage.armorDamage} ${damage.armorRear ? 'rear ' : ''}armor`); + } + if (damage.protection !== 'none') { + parts.push(damage.protection === 'case-ii' ? 'CASE II' : 'CASE'); + } + return `${getMekLocationLabel(damage.location) ?? damage.location}: ${parts.join(' · ')}`; + }); + if (explosion.pilotHits > 0) { + effects.push(`MechWarrior feedback: ${explosion.pilotHits} hit${explosion.pilotHits === 1 ? '' : 's'}`); + } + if (explosion.automaticCriticalEquipment) { + effects.push(`Automatic critical: ${explosion.automaticCriticalEquipment}`); + } + if (explosion.timing === 'phase-end') { + effects.push('Resolves at phase end unless firing or discharging prevents it'); + } + + return { + id: uuidv7(), + subject: unit.getNotificationDisplayName(), + event: 'Internal explosion', + description: `${explosion.equipment} in ${locationLabel} · ${explosion.rawDamage} damage`, + ...(effects.length > 0 ? { effects } : {}), + }; + } +} diff --git a/src/app/services/mek-critical-resolution.service.spec.ts b/src/app/services/mek-critical-resolution.service.spec.ts new file mode 100644 index 000000000..f9f6f5ef0 --- /dev/null +++ b/src/app/services/mek-critical-resolution.service.spec.ts @@ -0,0 +1,1057 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { TestBed } from '@angular/core/testing'; +import { Subject } from 'rxjs'; +import { MekCriticalChanceDialogComponent } from '../components/page-viewer/mek-critical-chance-dialog.component'; +import { MekCriticalHitDialogComponent } from '../components/page-viewer/mek-critical-hit-dialog.component'; +import { MekFloatingCriticalDialogComponent } from '../components/page-viewer/mek-floating-critical-dialog.component'; +import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import type { + PendingEventInput, + SerializedPendingMekCritical, + SerializedPendingMekCriticalChance, + SerializedPendingUnitCheck, +} from '../models/force-serialization'; +import { CORE_2026_GAME_RULES, TW_GAME_RULES } from '../models/rules/game-rules'; +import { DialogsService } from './dialogs.service'; +import { MekCriticalHitAutomationService } from './mek-critical-hit-automation.service'; +import { MekCriticalResolutionService } from './mek-critical-resolution.service'; +import { ToastService } from './toast.service'; +import { UnitCheckResolutionService } from './unit-check-resolution.service'; + +describe('MekCriticalResolutionService', () => { + let service: MekCriticalResolutionService; + let createDialog: jasmine.Spy; + let dialogClosures: Subject[]; + let pendingChances: SerializedPendingMekCriticalChance[]; + let pendingHits: SerializedPendingMekCritical[]; + let pendingCriticalOrder: string[]; + let pendingFallCount: number; + let pendingUnitChecks: SerializedPendingUnitCheck[]; + let queuePendingCriticalChance: jasmine.Spy; + let queuePendingCriticalHits: jasmine.Spy; + let replacePendingCriticalChanceWithHits: jasmine.Spy; + let replacePendingCriticalHitWithChance: jasmine.Spy; + let applyCriticalRoll: jasmine.Spy; + let criticalAutomationMode: 'yes' | 'ask' | 'no'; + let showToast: jasmine.Spy; + let openUnitChecks: jasmine.Spy; + let unit: CBTForceUnit; + + beforeEach(() => { + pendingChances = []; + pendingHits = []; + pendingCriticalOrder = []; + pendingFallCount = 0; + pendingUnitChecks = []; + criticalAutomationMode = 'ask'; + applyCriticalRoll = jasmine.createSpy('applyRoll').and.resolveTo({ + cancelled: false, + outcome: { + applied: true, + slotNumber: 1, + equipment: 'Engine', + armoredAbsorption: false, + }, + }); + dialogClosures = []; + createDialog = jasmine.createSpy('createDialog').and.callFake(() => { + const closed = new Subject(); + dialogClosures.push(closed); + return { closed }; + }); + queuePendingCriticalChance = jasmine.createSpy('queuePendingCriticalChance').and.callFake( + (entry: PendingEventInput) => { + if (pendingChances.some(candidate => candidate.id === entry.id)) return false; + pendingChances.push({ type: 'mek-critical-chance', ...entry }); + pendingCriticalOrder.push(entry.id); + return true; + }, + ); + queuePendingCriticalHits = jasmine.createSpy('queuePendingCriticalHits').and.callFake( + (entry: PendingEventInput) => { + if (pendingHits.some(candidate => candidate.id === entry.id)) return false; + pendingHits.push({ type: 'mek-critical-hit', ...entry }); + pendingCriticalOrder.push(entry.id); + return true; + }, + ); + replacePendingCriticalChanceWithHits = jasmine.createSpy('replacePendingCriticalChanceWithHits').and.callFake( + (entry: Pick) => { + const chanceIndex = pendingChances.findIndex(candidate => candidate.id === entry.id); + if (chanceIndex === -1 || pendingHits.some(candidate => candidate.id === entry.id)) return false; + const chance = pendingChances[chanceIndex]; + const { + type: _type, + result: _result, + roll: _chanceRoll, + explosionProtection, + hardenedArmorApplies, + throughArmorHitArc, + ...base + } = chance; + pendingChances.splice(chanceIndex, 1); + pendingHits.push({ + ...base, + type: 'mek-critical-hit', + targetLocation: entry.targetLocation, + remainingHits: entry.remainingHits, + chanceOrigin: { + ...(explosionProtection !== undefined ? { explosionProtection } : {}), + ...(hardenedArmorApplies !== undefined ? { hardenedArmorApplies } : {}), + ...(throughArmorHitArc !== undefined ? { throughArmorHitArc } : {}), + }, + ...(entry.floatingLocation ? { floatingLocation: entry.floatingLocation } : {}), + ...(entry.caseII ? { caseII: entry.caseII } : {}), + }); + return true; + }, + ); + replacePendingCriticalHitWithChance = jasmine.createSpy('replacePendingCriticalHitWithChance').and.callFake( + (id: string) => { + const hitIndex = pendingHits.findIndex(candidate => candidate.id === id); + if (hitIndex === -1 || pendingHits[hitIndex].chanceOrigin === undefined) return false; + const { + type: _type, + targetLocation: _targetLocation, + remainingHits: _remainingHits, + chanceOrigin, + floatingLocation: _floatingLocation, + caseII: _caseII, + roll: _roll, + ...base + } = pendingHits[hitIndex]; + pendingHits.splice(hitIndex, 1); + pendingChances.push({ type: 'mek-critical-chance', ...base, ...chanceOrigin }); + return true; + }, + ); + const turnState = { + currentPilotDamageGroup: () => 'combat:test', + queuePendingCriticalChance, + getPendingCriticalChance: (id: string) => pendingChances.find(entry => entry.id === id), + getPendingCriticalChances: () => pendingChances, + getNextPendingCriticalEvent: () => pendingCriticalOrder.flatMap(id => [ + pendingChances.find(entry => entry.id === id) + ?? pendingHits.find(entry => entry.id === id), + ]).find(entry => entry !== undefined), + pendingFallCount: () => pendingFallCount, + setPendingCriticalChanceResult: (id: string, result: SerializedPendingMekCriticalChance['result']) => + updateChance(id, pending => { + if (result !== undefined) return { ...pending, result }; + const { result: _result, ...withoutResult } = pending; + return withoutResult; + }), + setPendingCriticalChanceRoll: (id: string, roll: readonly [number, number] | undefined) => + updateChance(id, pending => { + if (roll !== undefined) return { ...pending, roll }; + const { roll: _roll, ...withoutRoll } = pending; + return withoutRoll; + }), + discardPendingCriticalChance: (id: string) => { + const originalLength = pendingChances.length; + pendingChances = pendingChances.filter(entry => entry.id !== id); + return pendingChances.length !== originalLength; + }, + replacePendingCriticalChanceWithHits, + replacePendingCriticalHitWithChance, + queuePendingCriticalHits, + getPendingCriticalHit: (id: string) => pendingHits.find(entry => entry.id === id), + getPendingCriticalHits: () => pendingHits, + setPendingCriticalRoll: (id: string, roll: readonly number[]) => + updateHit(id, pending => ({ ...pending, roll: [...roll] })), + clearPendingCriticalRoll: (id: string) => updateHit(id, pending => { + const { roll: _roll, ...withoutRoll } = pending; + return withoutRoll; + }), + setPendingCriticalCaseIICheckResult: ( + id: string, + result: 'resolve' | 'discard', + roll: readonly [number, number], + ) => updateHit(id, pending => ({ + ...pending, + caseII: { status: 'pending', result, roll }, + })), + passPendingCriticalCaseIICheck: (id: string) => updateHit(id, pending => ({ + ...pending, + caseII: { status: 'passed' }, + })), + resolvePendingCriticalHit: (id: string) => { + const index = pendingHits.findIndex(entry => entry.id === id); + if (index === -1) return false; + const pending = pendingHits[index]; + if (pending.remainingHits <= 1) { + pendingHits.splice(index, 1); + return true; + } + const { roll: _roll, ...withoutRoll } = pending; + pendingHits[index] = { + ...withoutRoll, + remainingHits: pending.remainingHits - 1, + }; + return true; + }, + setPendingFloatingCriticalLocation: ( + id: string, + locationRoll: number | null, + dice: readonly [number, number] | null, + tripodLegRoll: number | null, + ) => updateHit(id, pending => ({ + ...pending, + floatingLocation: { + hitArc: pending.floatingLocation!.hitArc, + ...(locationRoll !== null ? { locationRoll } : {}), + ...(dice !== null ? { dice } : {}), + ...(tripodLegRoll !== null ? { tripodLegRoll } : {}), + }, + })), + resolvePendingFloatingCriticalLocation: (id: string, targetLocation: string) => + updateHit(id, pending => { + if (!pending.floatingLocation) return pending; + const { floatingLocation: _floatingLocation, ...resolved } = pending; + return { ...resolved, targetLocation }; + }), + discardPendingCriticalHits: (id: string) => { + const originalLength = pendingHits.length; + pendingHits = pendingHits.filter(entry => entry.id !== id); + return pendingHits.length !== originalLength; + }, + actionablePendingUnitChecks: () => pendingUnitChecks, + }; + unit = { + id: 'unit-a', + gameRules: CORE_2026_GAME_RULES, + rules: { mountedCriticalDamageDestructionThreshold: () => 1 }, + locations: { internal: new Map([['CT', {}]]) }, + automationMode: (key: string) => key === 'criticalHitChanceCheck' + ? criticalAutomationMode + : 'ask', + turnState: () => turnState, + getNotificationDisplayName: () => 'Atlas AS7-D', + getUnit: () => ({ structureType: '', armorType: '', features: [], comp: [] }), + getCritSlots: () => [], + getCritSlot: () => null, + usesFloatingCriticals: () => false, + } as unknown as CBTForceUnit; + showToast = jasmine.createSpy('showToast'); + openUnitChecks = jasmine.createSpy('open').and.resolveTo(); + + TestBed.configureTestingModule({ + providers: [ + MekCriticalResolutionService, + { provide: DialogsService, useValue: { createDialog } }, + { provide: MekCriticalHitAutomationService, useValue: { applyRoll: applyCriticalRoll } }, + { provide: ToastService, useValue: { showToast } }, + { provide: UnitCheckResolutionService, useValue: { open: openUnitChecks } }, + ], + }); + service = TestBed.inject(MekCriticalResolutionService); + }); + + it('persists hits before opening a backdrop-dismissible guided dialog', async () => { + const operation = service.queue(unit, { + id: 'critical:1', + location: 'LT', + hits: 2, + locationDestroyed: true, + consolidateImmediately: false, + }); + + expect(queuePendingCriticalHits).toHaveBeenCalledOnceWith({ + id: 'critical:1', + location: 'LT', + targetLocation: 'LT', + remainingHits: 2, + locationDestroyed: true, + pilotDamageGroup: 'combat:test', + }); + expect(createDialog).toHaveBeenCalledOnceWith( + MekCriticalHitDialogComponent, + jasmine.objectContaining({ + disableClose: false, + data: jasmine.objectContaining({ + unit, + location: 'LT', + requiredHits: 2, + pendingCriticalId: 'critical:1', + }), + }), + ); + + closeDialog(0, undefined); + await operation; + expect(pendingHits[0].remainingHits).toBe(2); + }); + + it('does not reopen an existing critical when a duplicate enqueue is rejected', async () => { + addHit({ + id: 'critical:duplicate', + location: 'CT', + targetLocation: 'CT', + remainingHits: 1, + }); + + await service.queue(unit, { + id: 'critical:duplicate', + location: 'LT', + hits: 2, + consolidateImmediately: false, + }); + + expect(queuePendingCriticalHits).toHaveBeenCalled(); + expect(createDialog).not.toHaveBeenCalled(); + expect(pendingHits).toEqual([jasmine.objectContaining({ + id: 'critical:duplicate', + location: 'CT', + remainingHits: 1, + })]); + }); + + it('opens a serialized pending hit from the overlay path', async () => { + addHit({ + id: 'saved-critical', + location: 'RA', + targetLocation: 'RT', + remainingHits: 1, + consolidateImmediately: true, + roll: [3, 4], + }); + + const operation = service.resume(unit); + + expect(createDialog.calls.mostRecent().args[1].data).toEqual(jasmine.objectContaining({ + location: 'RA', + targetLocation: 'RT', + requiredHits: 1, + consolidateImmediately: true, + pendingCriticalId: 'saved-critical', + })); + + closeDialog(0, { completed: false }); + await operation; + }); + + it('opens a menu critical hit as a transient one-shot without queue state', async () => { + const operation = service.openManual(unit, 'CT', true); + + expect(queuePendingCriticalHits).not.toHaveBeenCalled(); + expect(queuePendingCriticalChance).not.toHaveBeenCalled(); + expect(pendingHits).toEqual([]); + expect(pendingChances).toEqual([]); + expect(createDialog).toHaveBeenCalledOnceWith( + MekCriticalHitDialogComponent, + jasmine.objectContaining({ disableClose: false }), + ); + const data = createDialog.calls.mostRecent().args[1].data; + expect(data).toEqual(jasmine.objectContaining({ + unit, + location: 'CT', + targetLocation: 'CT', + requiredHits: 1, + consolidateImmediately: true, + canUndoToChance: false, + manual: true, + })); + expect(data.pendingCriticalId).toBeUndefined(); + + closeDialog(0, undefined); + await operation; + + expect(pendingHits).toEqual([]); + expect(pendingChances).toEqual([]); + }); + + it('keeps menu chance-to-hit UNDO in memory without serializing either dialog', async () => { + const operation = service.openManualChance(unit, 'CT', false); + + expect(queuePendingCriticalChance).not.toHaveBeenCalled(); + expect(createDialog.calls.argsFor(0)[0]).toBe(MekCriticalChanceDialogComponent); + expect(createDialog.calls.argsFor(0)[1].data).toEqual(jasmine.objectContaining({ + locationLabel: 'Center Torso', + manual: true, + })); + expect(createDialog.calls.argsFor(0)[1].data.onResultChange).toBeUndefined(); + + closeDialog(0, { kind: 'critical-hits', count: 2 }); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(queuePendingCriticalHits).not.toHaveBeenCalled(); + expect(createDialog.calls.argsFor(1)[0]).toBe(MekCriticalHitDialogComponent); + const hitData = createDialog.calls.argsFor(1)[1].data; + expect(hitData).toEqual(jasmine.objectContaining({ + unit, + location: 'CT', + targetLocation: 'CT', + requiredHits: 2, + canUndoToChance: true, + manual: true, + })); + expect(hitData.pendingCriticalId).toBeUndefined(); + + closeDialog(1, { completed: false, undoToChance: true }); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(replacePendingCriticalHitWithChance).not.toHaveBeenCalled(); + expect(createDialog.calls.argsFor(2)[0]).toBe(MekCriticalChanceDialogComponent); + expect(createDialog.calls.argsFor(2)[1].data.manual).toBeTrue(); + closeDialog(2, undefined); + await operation; + + expect(pendingHits).toEqual([]); + expect(pendingChances).toEqual([]); + }); + + it('does not open duplicate dialogs for the same unit', async () => { + addHit({ + id: 'critical:1', + location: 'CT', + targetLocation: 'CT', + remainingHits: 1, + }); + + const first = service.resume(unit); + await service.resume(unit); + + expect(createDialog).toHaveBeenCalledTimes(1); + closeDialog(0, { completed: false }); + await first; + }); + + it('does not let a queued callback bypass an earlier critical chance that was left pending', async () => { + addChance({ id: 'chance:first', location: 'CT' }); + addChance({ id: 'chance:second', location: 'CT' }); + + await service.resumeChance(unit, 'chance:second'); + + expect(createDialog).not.toHaveBeenCalled(); + + const first = service.resumeChance(unit, 'chance:first'); + expect(createDialog).toHaveBeenCalledTimes(1); + closeDialog(0, undefined); + await first; + }); + + it('does not open a later chance while an earlier critical-hit stage remains pending', async () => { + addHit({ + id: 'hit:first', + location: 'CT', + targetLocation: 'CT', + remainingHits: 1, + }); + addChance({ id: 'chance:second', location: 'CT' }); + + await service.resumeChance(unit, 'chance:second'); + + expect(createDialog).not.toHaveBeenCalled(); + }); + + it('does not open critical work while falling damage is pending', async () => { + addChance({ id: 'chance:1', location: 'CT' }); + pendingFallCount = 1; + + await service.resumeChance(unit, 'chance:1'); + + expect(createDialog).not.toHaveBeenCalled(); + }); + + it('resolves pending Total Warfare consciousness before reopening a critical', async () => { + (unit as unknown as { gameRules: typeof TW_GAME_RULES }).gameRules = TW_GAME_RULES; + addHit({ + id: 'critical:1', + location: 'CT', + targetLocation: 'CT', + remainingHits: 1, + }); + addUnitCheck({ + id: 'consciousness:1', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: 'combat:test', + target: 5, + }); + + await service.resume(unit); + + expect(openUnitChecks).toHaveBeenCalledOnceWith([unit]); + expect(createDialog).not.toHaveBeenCalled(); + expect(pendingHits).toHaveSize(1); + }); + + it('pauses a Total Warfare critical, resolves its new consciousness roll, then resumes it', async () => { + (unit as unknown as { gameRules: typeof TW_GAME_RULES }).gameRules = TW_GAME_RULES; + addHit({ + id: 'critical:1', + location: 'CT', + targetLocation: 'CT', + remainingHits: 2, + }); + openUnitChecks.and.callFake(async () => { + pendingUnitChecks = []; + }); + + const operation = service.resume(unit); + addUnitCheck({ + id: 'consciousness:1', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: 'combat:test', + target: 3, + }); + closeDialog(0, { completed: false, interruptedForConsciousness: true }); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(openUnitChecks).toHaveBeenCalledOnceWith([unit]); + expect(createDialog).toHaveBeenCalledTimes(2); + expect(createDialog.calls.argsFor(1)[0]).toBe(MekCriticalHitDialogComponent); + + closeDialog(1, { completed: false }); + await operation; + expect(pendingHits).toHaveSize(1); + }); + + it('opens a queued chance directly and keeps it pending when the dialog closes', async () => { + const operation = service.queueChance(unit, { + id: 'chance:1', + location: 'CT', + consolidateImmediately: false, + }); + + expect(pendingChances).toEqual([{ + type: 'mek-critical-chance', + id: 'chance:1', + location: 'CT', + pilotDamageGroup: 'combat:test', + }]); + expect(createDialog).toHaveBeenCalledOnceWith( + MekCriticalChanceDialogComponent, + jasmine.objectContaining({ disableClose: false }), + ); + + closeDialog(0, undefined); + await operation; + expect(pendingChances).toHaveSize(1); + }); + + it('removes a NO CRITICAL result instead of creating critical-hit work', async () => { + addChance({ id: 'chance:1', location: 'CT' }); + + const operation = service.resumeChance(unit); + closeDialog(0, { kind: 'none' }); + await operation; + + expect(pendingChances).toEqual([]); + expect(pendingHits).toEqual([]); + }); + + it('persists and restores exact chance dice when its dialog is dismissed', async () => { + addChance({ + id: 'chance:1', + location: 'CT', + }); + + const operation = service.resumeChance(unit); + const chanceData = createDialog.calls.mostRecent().args[1].data; + chanceData.onRollChange([5, 5]); + chanceData.onResultChange({ kind: 'critical-hits', count: 2 }); + + expect(pendingChances[0]).toEqual({ + type: 'mek-critical-chance', + id: 'chance:1', + location: 'CT', + roll: [5, 5], + result: 2, + }); + expect(createDialog).toHaveBeenCalledOnceWith( + MekCriticalChanceDialogComponent, + jasmine.objectContaining({ disableClose: false }), + ); + + closeDialog(0, undefined); + await operation; + expect(pendingChances[0].result).toBe(2); + + const reopened = service.resumeChance(unit); + expect(createDialog.calls.mostRecent().args[1].data.initialRoll).toEqual([5, 5]); + closeDialog(1, undefined); + await reopened; + }); + + it('restores the Core CASE II explosion modifier with a pending chance', async () => { + const operation = service.queueChance(unit, { + id: 'chance:case-ii', + location: 'CT', + consolidateImmediately: false, + explosionProtection: 'case-ii', + pilotDamageGroup: 'combat:test', + }); + + expect(queuePendingCriticalChance).toHaveBeenCalledOnceWith({ + id: 'chance:case-ii', + location: 'CT', + explosionProtection: 'case-ii', + pilotDamageGroup: 'combat:test', + }); + expect(createDialog.calls.mostRecent().args[1].data.modifiers).toEqual([ + { label: 'CASE II internal explosion', value: -1 }, + ]); + + closeDialog(0, undefined); + await operation; + }); + + it('persists an exact Hardened Armor facing decision and restores its modifier', async () => { + (unit as unknown as { getUnit: () => object }).getUnit = () => ({ + structureType: '', + armorType: 'Hardened', + features: [], + comp: [], + }); + const operation = service.queueChance(unit, { + id: 'chance:hardened', + location: 'CT', + consolidateImmediately: false, + hardenedArmorApplies: true, + pilotDamageGroup: 'combat:test', + }); + + expect(queuePendingCriticalChance).toHaveBeenCalledOnceWith({ + id: 'chance:hardened', + location: 'CT', + hardenedArmorApplies: true, + pilotDamageGroup: 'combat:test', + }); + expect(createDialog.calls.mostRecent().args[1].data.modifiers).toEqual([ + { label: 'Hardened armor in damaged facing', value: -2 }, + ]); + + closeDialog(0, undefined); + await operation; + }); + + it('marks every Total Warfare CASE II critical for its separate 2D6 check', async () => { + (unit as unknown as { gameRules: typeof TW_GAME_RULES }).gameRules = TW_GAME_RULES; + addChance({ + id: 'chance:case-ii', + location: 'CT', + explosionProtection: 'case-ii', + }); + + const operation = service.resumeChance(unit); + closeDialog(0, { kind: 'critical-hits', count: 2 }); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(pendingHits).toEqual([jasmine.objectContaining({ + location: 'CT', + remainingHits: 2, + caseII: { status: 'pending' }, + })]); + expect(createDialog.calls.argsFor(1)[1].data).toEqual(jasmine.objectContaining({ + caseIICheckRequired: true, + caseIICheckPassed: false, + })); + + closeDialog(1, { completed: false }); + await operation; + }); + + it('keeps the chance pending if its critical-hit work cannot be queued', async () => { + replacePendingCriticalChanceWithHits.and.returnValue(false); + addChance({ id: 'chance:1', location: 'CT' }); + + const operation = service.resumeChance(unit); + closeDialog(0, { kind: 'critical-hits', count: 1 }); + await operation; + + expect(pendingChances).toHaveSize(1); + expect(createDialog).toHaveBeenCalledTimes(1); + }); + + it('turns an accepted chance result into serialized hits before opening their dialog', async () => { + addChance({ + id: 'chance:1', + location: 'CT', + pilotDamageGroup: 'turn-closed:immediate:end-turn:heat', + }); + + const operation = service.resumeChance(unit); + closeDialog(0, { kind: 'critical-hits', count: 2 }); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(pendingChances).toEqual([]); + expect(pendingHits).toEqual([jasmine.objectContaining({ + id: 'chance:1', + location: 'CT', + targetLocation: 'CT', + remainingHits: 2, + pilotDamageGroup: 'turn-closed:immediate:end-turn:heat', + chanceOrigin: {}, + })]); + expect(createDialog.calls.argsFor(1)[0]).toBe(MekCriticalHitDialogComponent); + expect(createDialog.calls.argsFor(1)[1].data.pilotDamageGroup) + .toBe('turn-closed:immediate:end-turn:heat'); + + closeDialog(1, { completed: false }); + await operation; + }); + + it('persists and resolves a floating critical before opening its critical-hit dialog', async () => { + (unit as unknown as { usesFloatingCriticals: () => boolean }).usesFloatingCriticals = () => true; + addChance({ + id: 'chance:floating', + location: 'CT', + throughArmorHitArc: 'front', + }); + + const operation = service.resumeChance(unit); + closeDialog(0, { kind: 'critical-hits', count: 1 }); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(createDialog.calls.argsFor(1)[0]).toBe(MekFloatingCriticalDialogComponent); + expect(pendingHits[0]).toEqual(jasmine.objectContaining({ + id: 'chance:floating', + floatingLocation: { hitArc: 'front' }, + chanceOrigin: { throughArmorHitArc: 'front' }, + })); + + const floatingData = createDialog.calls.argsFor(1)[1].data; + floatingData.onDraftChange(7, [3, 4], null); + expect(pendingHits[0].floatingLocation).toEqual({ + hitArc: 'front', + locationRoll: 7, + dice: [3, 4], + }); + + closeDialog(1, { action: 'apply', location: 'CT' }); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(createDialog.calls.argsFor(2)[0]).toBe(MekCriticalHitDialogComponent); + expect(createDialog.calls.argsFor(2)[1].data).toEqual(jasmine.objectContaining({ + targetLocation: 'CT', + pendingCriticalId: 'chance:floating', + })); + expect(pendingHits[0].floatingLocation).toBeUndefined(); + + closeDialog(2, { completed: false }); + await operation; + }); + + it('restores a floating-critical location roll after CLOSE', async () => { + addHit({ + id: 'critical:floating-paused', + location: 'CT', + targetLocation: 'CT', + remainingHits: 1, + floatingLocation: { + hitArc: 'rear', + locationRoll: 8, + dice: [2, 6], + }, + }); + + const first = service.resume(unit); + expect(createDialog.calls.argsFor(0)[0]).toBe(MekFloatingCriticalDialogComponent); + expect(createDialog.calls.argsFor(0)[1].data).toEqual(jasmine.objectContaining({ + hitArc: 'rear', + initialLocationRoll: 8, + initialRoll: [2, 6], + })); + closeDialog(0, undefined); + await first; + + const reopened = service.resume(unit); + expect(createDialog.calls.argsFor(1)[0]).toBe(MekFloatingCriticalDialogComponent); + expect(createDialog.calls.argsFor(1)[1].data.initialRoll).toEqual([2, 6]); + closeDialog(1, undefined); + await reopened; + }); + + it('consumes the floating critical without opening a hit dialog after SKIP', async () => { + addHit({ + id: 'critical:floating-skipped', + location: 'CT', + targetLocation: 'CT', + remainingHits: 2, + floatingLocation: { hitArc: 'front' }, + }); + + const operation = service.resume(unit); + expect(createDialog.calls.argsFor(0)[0]).toBe(MekFloatingCriticalDialogComponent); + + closeDialog(0, { action: 'skip' }); + await operation; + + expect(pendingHits).toEqual([]); + expect(createDialog).toHaveBeenCalledTimes(1); + }); + + it('restores the exact chance stage when an untouched hit dialog requests undo', async () => { + addChance({ + id: 'chance:undo', + location: 'CT', + explosionProtection: 'case-ii', + hardenedArmorApplies: true, + consolidateImmediately: true, + pilotDamageGroup: 'combat:test', + }); + + const operation = service.resumeChance(unit, 'chance:undo'); + closeDialog(0, { kind: 'critical-hits', count: 2 }); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(pendingHits[0]).toEqual(jasmine.objectContaining({ + id: 'chance:undo', + remainingHits: 2, + chanceOrigin: { + explosionProtection: 'case-ii', + hardenedArmorApplies: true, + }, + })); + expect(createDialog.calls.argsFor(1)[1].data.canUndoToChance).toBeTrue(); + + closeDialog(1, { completed: false, undoToChance: true }); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(replacePendingCriticalHitWithChance).toHaveBeenCalledOnceWith('chance:undo'); + expect(pendingHits).toEqual([]); + expect(pendingChances).toEqual([{ + type: 'mek-critical-chance', + id: 'chance:undo', + location: 'CT', + explosionProtection: 'case-ii', + hardenedArmorApplies: true, + consolidateImmediately: true, + pilotDamageGroup: 'combat:test', + }]); + expect(createDialog.calls.argsFor(2)[0]).toBe(MekCriticalChanceDialogComponent); + expect(createDialog.calls.argsFor(2)[1].data.initialResult).toBeUndefined(); + + closeDialog(2, undefined); + await operation; + }); + + it('applies a blow-off result and consumes its pending chance', async () => { + const setLocationCondition = jasmine.createSpy('setLocationCondition'); + (unit as unknown as { setLocationCondition: jasmine.Spy }).setLocationCondition = setLocationCondition; + addChance({ id: 'chance:1', location: 'HD' }); + + const operation = service.resumeChance(unit); + closeDialog(0, { kind: 'blown-off' }); + await operation; + + expect(setLocationCondition).toHaveBeenCalledOnceWith('HD', 'blown-off', true, false); + expect(pendingChances).toEqual([]); + expect(pendingHits).toEqual([]); + expect(showToast).toHaveBeenCalledWith('Head blown off', 'error'); + }); + + it('automatically rolls and applies a critical chance when automation is yes', async () => { + criticalAutomationMode = 'yes'; + const setLocationCondition = jasmine.createSpy('setLocationCondition'); + (unit as unknown as { setLocationCondition: jasmine.Spy }).setLocationCondition = setLocationCondition; + spyOn(Math, 'random').and.returnValues(0.99, 0.99); + addChance({ id: 'chance:auto', location: 'LA' }); + + await service.resumeChance(unit, 'chance:auto'); + + expect(createDialog).not.toHaveBeenCalled(); + expect(setLocationCondition).toHaveBeenCalledOnceWith('LA', 'blown-off', true, false); + expect(pendingChances).toEqual([]); + expect(pendingHits).toEqual([]); + expect(showToast).toHaveBeenCalledWith( + 'Atlas AS7-D — Critical chance: Left Arm blown off', + 'error', + ); + }); + + it('opens the chance and hit panels in yes mode when manually requested', async () => { + criticalAutomationMode = 'yes'; + spyOn(Math, 'random'); + addChance({ id: 'chance:manual', location: 'CT' }); + + const operation = service.resumeChance(unit, 'chance:manual', true); + expect(createDialog.calls.argsFor(0)[0]).toBe(MekCriticalChanceDialogComponent); + + closeDialog(0, { kind: 'critical-hits', count: 1 }); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(createDialog.calls.argsFor(1)[0]).toBe(MekCriticalHitDialogComponent); + expect(Math.random).not.toHaveBeenCalled(); + expect(applyCriticalRoll).not.toHaveBeenCalled(); + + closeDialog(1, undefined); + await operation; + }); + + it('reports when an automatic critical chance produces no critical hits', async () => { + criticalAutomationMode = 'yes'; + spyOn(Math, 'random').and.returnValues(0, 0); + addChance({ id: 'chance:none', location: 'CT' }); + + await service.resumeChance(unit, 'chance:none'); + + expect(showToast).toHaveBeenCalledOnceWith( + 'Atlas AS7-D — Critical chance in Center Torso: no critical hits (roll 2)', + 'success', + ); + expect(pendingChances).toEqual([]); + }); + + it('automatically rolls and applies queued critical hits when automation is yes', async () => { + criticalAutomationMode = 'yes'; + const slot = { + id: 'engine@CT:0', + loc: 'CT', + slot: 0, + name: 'Engine', + hits: 0, + pendingHits: 0, + destroying: false, + destroyed: false, + }; + (unit as unknown as { + getCritSlots: () => unknown[]; + getCritSlot: (location: string, index: number) => unknown | null; + }).getCritSlots = () => [slot]; + (unit as unknown as { + getCritSlot: (location: string, index: number) => unknown | null; + }).getCritSlot = (location, index) => location === 'CT' && index === 0 ? slot : null; + spyOn(Math, 'random').and.returnValue(0); + + await service.queue(unit, { + id: 'critical:auto', + location: 'CT', + hits: 1, + consolidateImmediately: false, + }); + + expect(createDialog).not.toHaveBeenCalled(); + expect(applyCriticalRoll).toHaveBeenCalledOnceWith( + unit, + 'CT', + [1, 1], + false, + { transfer: false, pilotDamageGroup: 'combat:test' }, + ); + expect(showToast).toHaveBeenCalledOnceWith( + 'Atlas AS7-D — Critical hit in Center Torso: Engine (slot 1)', + 'error', + ); + expect(pendingHits).toEqual([]); + }); + + it('shows a success toast when an automatic CASE II check discards a critical', async () => { + criticalAutomationMode = 'yes'; + spyOn(Math, 'random').and.returnValues(0.99, 0.99); + addHit({ + id: 'critical:case-ii', + location: 'CT', + targetLocation: 'CT', + remainingHits: 1, + caseII: { status: 'pending' }, + }); + + await service.resume(unit, 'critical:case-ii'); + + expect(showToast).toHaveBeenCalledOnceWith( + 'Atlas AS7-D — CASE II critical check: PASSED (12 vs 8+)', + 'success', + ); + expect(applyCriticalRoll).not.toHaveBeenCalled(); + expect(pendingHits).toEqual([]); + }); + + it('lets intact armored limb actuators absorb a blow-off result', async () => { + const shoulder = { + id: 'shoulder@LA', + name: 'Shoulder', + loc: 'LA', + slot: 0, + armored: true, + hits: 0, + }; + const applyHitToCritSlot = jasmine.createSpy('applyHitToCritSlot'); + const setLocationCondition = jasmine.createSpy('setLocationCondition'); + (unit as unknown as { + getCritSlots: () => unknown[]; + applyHitToCritSlot: jasmine.Spy; + setLocationCondition: jasmine.Spy; + }).getCritSlots = () => [shoulder]; + (unit as unknown as { applyHitToCritSlot: jasmine.Spy }).applyHitToCritSlot = applyHitToCritSlot; + (unit as unknown as { setLocationCondition: jasmine.Spy }).setLocationCondition = setLocationCondition; + addChance({ id: 'chance:1', location: 'LA' }); + + const operation = service.resumeChance(unit); + closeDialog(0, { kind: 'blown-off' }); + await operation; + + expect(applyHitToCritSlot).toHaveBeenCalledOnceWith(shoulder, 1, false); + expect(setLocationCondition).not.toHaveBeenCalled(); + expect(pendingChances).toEqual([]); + expect(showToast).toHaveBeenCalledWith('Armored Shoulder absorbs the blow-off result', 'info'); + }); + + it('queues an explicitly requested serialized chance while keeping it resumable', async () => { + const operation = service.queueChance(unit, { + id: 'manual:1', + location: 'CT', + consolidateImmediately: true, + }); + + expect(pendingChances[0]).toEqual(jasmine.objectContaining({ + type: 'mek-critical-chance', + id: 'manual:1', + location: 'CT', + consolidateImmediately: true, + })); + expect(createDialog).toHaveBeenCalledOnceWith( + MekCriticalChanceDialogComponent, + jasmine.objectContaining({ disableClose: false }), + ); + + closeDialog(0, { kind: 'none' }); + await operation; + expect(pendingChances).toEqual([]); + }); + + function addChance(entry: PendingEventInput): void { + pendingChances.push({ type: 'mek-critical-chance', ...entry }); + pendingCriticalOrder.push(entry.id); + } + + function addHit(entry: PendingEventInput): void { + pendingHits.push({ type: 'mek-critical-hit', ...entry }); + pendingCriticalOrder.push(entry.id); + } + + function addUnitCheck(entry: PendingEventInput): void { + pendingUnitChecks.push({ type: 'unit-check', ...entry } as SerializedPendingUnitCheck); + } + + function updateChance( + id: string, + update: (pending: SerializedPendingMekCriticalChance) => SerializedPendingMekCriticalChance, + ): boolean { + const index = pendingChances.findIndex(entry => entry.id === id); + if (index === -1) return false; + pendingChances[index] = update(pendingChances[index]); + return true; + } + + function updateHit( + id: string, + update: (pending: SerializedPendingMekCritical) => SerializedPendingMekCritical, + ): boolean { + const index = pendingHits.findIndex(entry => entry.id === id); + if (index === -1) return false; + pendingHits[index] = update(pendingHits[index]); + return true; + } + + function closeDialog(index: number, result: unknown): void { + dialogClosures[index].next(result); + dialogClosures[index].complete(); + } +}); diff --git a/src/app/services/mek-critical-resolution.service.ts b/src/app/services/mek-critical-resolution.service.ts new file mode 100644 index 000000000..7163c1545 --- /dev/null +++ b/src/app/services/mek-critical-resolution.service.ts @@ -0,0 +1,714 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { inject, Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { + MekCriticalChanceDialogComponent, + type MekCriticalChanceDialogData, +} from '../components/page-viewer/mek-critical-chance-dialog.component'; +import { + MekCriticalHitDialogComponent, + type MekCriticalHitDialogData, + type MekCriticalHitDialogResult, +} from '../components/page-viewer/mek-critical-hit-dialog.component'; +import { + MekFloatingCriticalDialogComponent, + type MekFloatingCriticalDialogData, + type MekFloatingCriticalDialogResult, +} from '../components/page-viewer/mek-floating-critical-dialog.component'; +import { getMekLocationLabel } from '../models/entity/types'; +import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import type { + MekHitArc, + SerializedPendingMekCritical, + SerializedMekCriticalChanceResult, +} from '../models/force-serialization'; +import type { MekExplosionProtection } from '../models/rules/game-rules'; +import { + applyMekBlowOff, + hasRollableMekCriticalSlot, + mekCriticalChanceCanBlowOff, + mekCriticalChanceModifiers, + mekCriticalRollLocation, + randomValidMekCriticalRoll, + resolveMekCriticalChance, + usesIndustrialMekCriticalChanceTable, + type MekCriticalChanceResult, + type MekCriticalHitOptions, +} from '../utils/mek-critical-hit.util'; +import { resolveMekFallHitLocation } from '../utils/mek-falling.util'; +import { clusterTableForUnit } from '../utils/record-sheet-reference-table'; +import { isConsciousnessCheck } from '../utils/unit-check.util'; +import { uuidv7 } from '../utils/uuid.util'; +import { CBTAutomationToastService } from './cbt-automation-toast.service'; +import { DialogsService } from './dialogs.service'; +import { MekCriticalHitAutomationService } from './mek-critical-hit-automation.service'; +import { ToastService } from './toast.service'; +import { UnitCheckResolutionService } from './unit-check-resolution.service'; + +export interface PendingMekCriticalChanceRequest { + readonly id?: string; + readonly location: string; + readonly locationDestroyed?: boolean; + readonly consolidateImmediately: boolean; + readonly explosionProtection?: MekExplosionProtection; + readonly hardenedArmorApplies?: boolean; + readonly throughArmorHitArc?: MekHitArc; + readonly pilotDamageGroup?: string; +} + +export interface PendingMekCriticalRequest { + readonly id?: string; + readonly location: string; + readonly targetLocation?: string; + readonly hits: number; + readonly locationDestroyed?: boolean; + readonly consolidateImmediately: boolean; + readonly pilotDamageGroup?: string; +} + +@Injectable({ providedIn: 'root' }) +export class MekCriticalResolutionService { + private readonly dialogsService = inject(DialogsService); + private readonly toastService = inject(ToastService); + private readonly automationToasts = inject(CBTAutomationToastService); + private readonly criticalHitAutomation = inject(MekCriticalHitAutomationService); + private readonly unitChecks = inject(UnitCheckResolutionService); + private readonly activeUnits = new WeakSet(); + + async queueChance(unit: CBTForceUnit, request: PendingMekCriticalChanceRequest): Promise { + const id = this.enqueueChance(unit, request); + if (id) await this.resumeChance(unit, id); + } + + private enqueueChance(unit: CBTForceUnit, request: PendingMekCriticalChanceRequest): string | null { + const id = request.id ?? uuidv7(); + const queued = unit.turnState().queuePendingCriticalChance({ + id, + location: request.location, + ...(request.locationDestroyed ? { locationDestroyed: true } : {}), + ...(request.consolidateImmediately ? { consolidateImmediately: true } : {}), + ...(request.explosionProtection !== undefined + ? { explosionProtection: request.explosionProtection } + : {}), + ...(request.hardenedArmorApplies !== undefined + ? { hardenedArmorApplies: request.hardenedArmorApplies } + : {}), + ...(request.throughArmorHitArc !== undefined + ? { throughArmorHitArc: request.throughArmorHitArc } + : {}), + pilotDamageGroup: request.pilotDamageGroup + ?? unit.turnState().currentPilotDamageGroup(), + }); + return queued ? id : null; + } + + async resumeChance( + unit: CBTForceUnit, + pendingId?: string, + manualResolution = false, + ): Promise { + if (this.hasImmediateConsciousness(unit) + && !await this.resolveImmediateConsciousness(unit)) return; + if (!manualResolution && unit.automationMode('criticalHitChanceCheck') === 'yes') { + const pendingHitId = await this.runExclusive( + unit, + () => Promise.resolve(this.resolveChanceAutomatically(unit, pendingId)), + ); + if (pendingHitId) await this.resume(unit, pendingHitId); + return; + } + const pendingHitId = await this.runExclusive(unit, async () => { + const turnState = unit.turnState(); + const next = turnState.getNextPendingCriticalEvent(); + if (turnState.pendingFallCount() > 0 + || next?.type !== 'mek-critical-chance' + || (pendingId !== undefined && next.id !== pendingId)) return null; + const pending = next; + + if (pending.locationDestroyed && !hasRollableMekCriticalSlot(unit, pending.location, { + transfer: false, + explosiveSlotsOnly: true, + })) { + turnState.discardPendingCriticalChance(pending.id); + return null; + } + + const activePending = pending; + + const ref = this.dialogsService.createDialog( + MekCriticalChanceDialogComponent, + { + disableClose: false, + data: { + locationLabel: getMekLocationLabel(activePending.location) ?? activePending.location, + canBlowOff: mekCriticalChanceCanBlowOff(activePending.location), + industrialMek: usesIndustrialMekCriticalChanceTable(unit), + modifiers: mekCriticalChanceModifiers(unit, activePending.location, { + explosionProtection: activePending.explosionProtection, + hardenedArmorApplies: activePending.hardenedArmorApplies, + }), + initialResult: deserializeChanceResult(activePending.result), + initialRoll: activePending.roll, + onResultChange: result => turnState.setPendingCriticalChanceResult( + activePending.id, + result ? serializeChanceResult(result) : undefined, + ), + onRollChange: roll => turnState.setPendingCriticalChanceRoll( + activePending.id, + roll, + ), + }, + }, + ); + const result = await firstValueFrom(ref.closed); + // Dismissal leaves the serialized check available from the overlay. + if (!result) return null; + + if (result.kind === 'none') { + turnState.discardPendingCriticalChance(activePending.id); + return null; + } + if (result.kind === 'blown-off') { + this.applyBlowOffResult( + unit, + activePending.location, + activePending.consolidateImmediately ?? false, + ); + turnState.discardPendingCriticalChance(activePending.id); + return null; + } + + const queued = turnState.replacePendingCriticalChanceWithHits({ + id: activePending.id, + targetLocation: activePending.locationDestroyed + ? activePending.location + : mekCriticalRollLocation(unit, activePending.location), + remainingHits: result.count, + ...(activePending.throughArmorHitArc !== undefined && unit.usesFloatingCriticals() + ? { floatingLocation: { hitArc: activePending.throughArmorHitArc } } + : {}), + ...(unit.gameRules.id === 'tw' && activePending.explosionProtection === 'case-ii' + ? { caseII: { status: 'pending' as const } } + : {}), + }); + if (!queued) return null; + return activePending.id; + }); + + if (pendingHitId) await this.resume(unit, pendingHitId, manualResolution); + } + + private resolveChanceAutomatically(unit: CBTForceUnit, pendingId?: string): string | null { + const turnState = unit.turnState(); + const next = turnState.getNextPendingCriticalEvent(); + if (turnState.pendingFallCount() > 0 + || next?.type !== 'mek-critical-chance' + || (pendingId !== undefined && next.id !== pendingId)) return null; + const pending = next; + + if (pending.locationDestroyed && !hasRollableMekCriticalSlot(unit, pending.location, { + transfer: false, + explosiveSlotsOnly: true, + })) { + turnState.discardPendingCriticalChance(pending.id); + this.automationToasts.show( + unit, + `Critical chance in ${getMekLocationLabel(pending.location) ?? pending.location}: no applicable critical slots`, + 'success', + ); + return null; + } + + const dice = pending.roll ?? [this.rollD6(), this.rollD6()] as const; + if (!pending.roll) turnState.setPendingCriticalChanceRoll(pending.id, dice); + const industrialMek = usesIndustrialMekCriticalChanceTable(unit); + const total = Math.min( + industrialMek ? 14 : 12, + dice[0] + dice[1] + mekCriticalChanceModifiers(unit, pending.location, { + explosionProtection: pending.explosionProtection, + hardenedArmorApplies: pending.hardenedArmorApplies, + }).reduce((sum, modifier) => + sum + (!modifier.optional || modifier.enabled !== false ? modifier.value : 0), 0), + ); + const result = deserializeChanceResult(pending.result) ?? resolveMekCriticalChance( + total, + mekCriticalChanceCanBlowOff(pending.location), + industrialMek, + ); + if (pending.result === undefined) { + turnState.setPendingCriticalChanceResult(pending.id, serializeChanceResult(result)); + } + + if (result.kind === 'none') { + turnState.discardPendingCriticalChance(pending.id); + this.automationToasts.show( + unit, + `Critical chance in ${getMekLocationLabel(pending.location) ?? pending.location}: no critical hits (roll ${total})`, + 'success', + ); + return null; + } + if (result.kind === 'blown-off') { + this.applyBlowOffResult( + unit, + pending.location, + pending.consolidateImmediately ?? false, + true, + ); + turnState.discardPendingCriticalChance(pending.id); + return null; + } + + const floatingCritical = pending.throughArmorHitArc !== undefined && unit.usesFloatingCriticals(); + const targetLocation = pending.locationDestroyed + ? pending.location + : mekCriticalRollLocation(unit, pending.location); + const queued = turnState.replacePendingCriticalChanceWithHits({ + id: pending.id, + targetLocation, + remainingHits: result.count, + ...(floatingCritical + ? { floatingLocation: { hitArc: pending.throughArmorHitArc! } } + : {}), + ...(unit.gameRules.id === 'tw' && pending.explosionProtection === 'case-ii' + ? { caseII: { status: 'pending' as const } } + : {}), + }); + if (queued) { + const location = floatingCritical + ? `floating from ${getMekLocationLabel(pending.location) ?? pending.location}` + : `in ${getMekLocationLabel(targetLocation) ?? targetLocation}`; + this.automationToasts.show( + unit, + `Critical chance: ${result.count} critical hit${result.count === 1 ? '' : 's'} ${location} (roll ${total})`, + 'error', + ); + } + return queued ? pending.id : null; + } + + async queue(unit: CBTForceUnit, request: PendingMekCriticalRequest): Promise { + const id = request.id ?? uuidv7(); + const queued = unit.turnState().queuePendingCriticalHits({ + id, + location: request.location, + targetLocation: request.targetLocation ?? request.location, + remainingHits: request.hits, + ...(request.locationDestroyed ? { locationDestroyed: true } : {}), + ...(request.consolidateImmediately ? { consolidateImmediately: true } : {}), + pilotDamageGroup: request.pilotDamageGroup + ?? unit.turnState().currentPilotDamageGroup(), + }); + if (queued) await this.resume(unit, id); + } + + async resume( + unit: CBTForceUnit, + pendingId?: string, + manualResolution = false, + ): Promise { + if (!manualResolution && unit.automationMode('criticalHitChanceCheck') === 'yes') { + await this.resumeAutomatically(unit, pendingId); + return; + } + while (true) { + if (this.hasImmediateConsciousness(unit) + && !await this.resolveImmediateConsciousness(unit)) return; + const opened = await this.runExclusive(unit, async () => { + const turnState = unit.turnState(); + const next = turnState.getNextPendingCriticalEvent(); + if (turnState.pendingFallCount() > 0 + || next?.type !== 'mek-critical-hit' + || (pendingId !== undefined && next.id !== pendingId)) return undefined; + const pending = next; + + if (pending.floatingLocation) { + const floating = pending.floatingLocation; + const ref = this.dialogsService.createDialog( + MekFloatingCriticalDialogComponent, + { + disableClose: false, + data: { + unit, + hitArc: floating.hitArc, + initialLocationRoll: floating.locationRoll, + initialRoll: floating.dice, + initialTripodLegRoll: floating.tripodLegRoll, + onDraftChange: (locationRoll, dice, tripodLegRoll) => + turnState.setPendingFloatingCriticalLocation( + pending.id, + locationRoll, + dice, + tripodLegRoll, + ), + }, + }, + ); + const result = await firstValueFrom(ref.closed); + if (!result) return { kind: 'closed' as const, pendingId: pending.id }; + if (result.action === 'skip') { + return turnState.discardPendingCriticalHits(pending.id) + ? { kind: 'floating-skipped' as const, pendingId: pending.id } + : { kind: 'closed' as const, pendingId: pending.id }; + } + const targetLocation = mekCriticalRollLocation(unit, result.location); + if (!turnState.resolvePendingFloatingCriticalLocation(pending.id, targetLocation)) { + return { kind: 'closed' as const, pendingId: pending.id }; + } + return { kind: 'floating-resolved' as const, pendingId: pending.id }; + } + + const ref = this.dialogsService.createDialog( + MekCriticalHitDialogComponent, + { + disableClose: false, + data: { + unit, + location: pending.location, + targetLocation: pending.targetLocation, + requiredHits: pending.remainingHits, + locationDestroyed: pending.locationDestroyed ?? false, + consolidateImmediately: pending.consolidateImmediately ?? false, + pendingCriticalId: pending.id, + caseIICheckRequired: pending.caseII !== undefined, + caseIICheckPassed: pending.caseII?.status === 'passed', + caseIICheckResult: pending.caseII?.status === 'pending' + ? pending.caseII.result + : undefined, + caseIICheckRoll: pending.caseII?.status === 'pending' + ? pending.caseII.roll + : undefined, + pilotDamageGroup: pending.pilotDamageGroup, + canUndoToChance: pending.chanceOrigin !== undefined, + }, + }, + ); + return { + kind: 'critical-hit' as const, + pendingId: pending.id, + result: await firstValueFrom(ref.closed), + }; + }); + if (!opened) return; + if (opened.kind === 'closed') return; + if (opened.kind === 'floating-resolved') continue; + if (opened.kind === 'floating-skipped') return; + if (opened.result?.undoToChance) { + if (unit.turnState().replacePendingCriticalHitWithChance(opened.pendingId)) { + await this.resumeChance(unit, opened.pendingId, manualResolution); + } + return; + } + if (!opened.result?.interruptedForConsciousness) return; + } + } + + private async resumeAutomatically(unit: CBTForceUnit, pendingId?: string): Promise { + while (true) { + if (this.hasImmediateConsciousness(unit) + && !await this.resolveImmediateConsciousness(unit)) return; + const step = await this.runExclusive( + unit, + () => this.resolveCriticalHitAutomatically(unit, pendingId), + ); + if (step !== 'continue') return; + } + } + + private async resolveCriticalHitAutomatically( + unit: CBTForceUnit, + pendingId?: string, + ): Promise<'continue' | 'stopped'> { + const turnState = unit.turnState(); + const next = turnState.getNextPendingCriticalEvent(); + if (turnState.pendingFallCount() > 0 + || next?.type !== 'mek-critical-hit' + || (pendingId !== undefined && next.id !== pendingId)) return 'stopped'; + const pending = next; + + if (pending.floatingLocation) { + return this.resolveFloatingCriticalAutomatically(unit, pending) + ? 'continue' + : 'stopped'; + } + + if (pending.caseII?.status === 'pending') { + let result = pending.caseII.result; + let dice = pending.caseII.roll; + if (!result) { + dice = dice ?? [this.rollD6(), this.rollD6()] as const; + result = dice[0] + dice[1] >= 8 ? 'discard' : 'resolve'; + turnState.setPendingCriticalCaseIICheckResult(pending.id, result, dice); + } + this.automationToasts.show( + unit, + `CASE II critical check: ${result === 'discard' ? 'PASSED' : 'FAILED'}${dice ? ` (${dice[0] + dice[1]} vs 8+)` : ' (automatic)'}`, + result === 'discard' ? 'success' : 'error', + ); + if (result === 'discard') { + return turnState.resolvePendingCriticalHit(pending.id) ? 'continue' : 'stopped'; + } + return turnState.passPendingCriticalCaseIICheck(pending.id) ? 'continue' : 'stopped'; + } + + const options: MekCriticalHitOptions = { + transfer: false, + ...(pending.locationDestroyed ? { explosiveSlotsOnly: true } : {}), + ...(pending.pilotDamageGroup + ? { pilotDamageGroup: pending.pilotDamageGroup } + : {}), + }; + if (!hasRollableMekCriticalSlot(unit, pending.targetLocation, options)) { + turnState.discardPendingCriticalHits(pending.id); + this.automationToasts.show( + unit, + `Critical hit in ${getMekLocationLabel(pending.targetLocation) ?? pending.targetLocation}: no applicable critical slots`, + 'success', + ); + return 'stopped'; + } + + const results = pending.roll ?? randomValidMekCriticalRoll( + unit, + pending.targetLocation, + Math.random, + options, + ); + if (!results) { + turnState.discardPendingCriticalHits(pending.id); + this.automationToasts.show( + unit, + `Critical hit in ${getMekLocationLabel(pending.targetLocation) ?? pending.targetLocation}: no applicable critical slots`, + 'success', + ); + return 'stopped'; + } + if (!pending.roll && !turnState.setPendingCriticalRoll(pending.id, results)) return 'stopped'; + + const resolution = await this.criticalHitAutomation.applyRoll( + unit, + pending.targetLocation, + results, + pending.consolidateImmediately ?? false, + options, + ); + if (resolution.cancelled) return 'stopped'; + if (resolution.outcome?.applied) { + const equipment = resolution.outcome.equipment ?? `slot ${resolution.outcome.slotNumber}`; + this.automationToasts.show( + unit, + `Critical hit in ${getMekLocationLabel(pending.targetLocation) ?? pending.targetLocation}: ${equipment} (slot ${resolution.outcome.slotNumber})${resolution.outcome.armoredAbsorption ? '; component armor absorbed the hit' : ''}`, + 'error', + ); + } + if (!resolution.outcome?.applied) { + if (pending.locationDestroyed && resolution.outcome?.reason === 'non-explosive') { + return turnState.resolvePendingCriticalHit(pending.id) ? 'continue' : 'stopped'; + } + turnState.clearPendingCriticalRoll(pending.id); + return 'continue'; + } + return turnState.resolvePendingCriticalHit(pending.id) ? 'continue' : 'stopped'; + } + + private resolveFloatingCriticalAutomatically( + unit: CBTForceUnit, + pending: SerializedPendingMekCritical, + ): boolean { + const floating = pending.floatingLocation; + if (!floating) return false; + const generatedDice = floating.locationRoll === undefined + ? [this.rollD6(), this.rollD6()] as const + : null; + const locationRoll = floating.locationRoll + ?? generatedDice![0] + generatedDice![1]; + const table = clusterTableForUnit(unit.getUnit()).hitLocationTable ?? 'biped'; + const preliminary = resolveMekFallHitLocation(table, floating.hitArc, locationRoll); + const needsTripodLeg = preliminary.location === null + && preliminary.tripodLegModifier !== undefined; + const tripodLegRoll = needsTripodLeg + ? floating.tripodLegRoll ?? this.rollD6() + : null; + const result = resolveMekFallHitLocation( + table, + floating.hitArc, + locationRoll, + tripodLegRoll ?? undefined, + ); + if (!result.location) return false; + + unit.turnState().setPendingFloatingCriticalLocation( + pending.id, + locationRoll, + generatedDice ?? floating.dice ?? null, + tripodLegRoll, + ); + const targetLocation = mekCriticalRollLocation(unit, result.location); + const resolved = unit.turnState().resolvePendingFloatingCriticalLocation( + pending.id, + targetLocation, + ); + if (resolved) { + this.automationToasts.show( + unit, + `Floating critical location: ${getMekLocationLabel(targetLocation) ?? targetLocation} (roll ${locationRoll})`, + 'info', + ); + } + return resolved; + } + + async openManual(unit: CBTForceUnit, location: string, consolidateImmediately: boolean): Promise { + await this.runExclusive(unit, async () => { + await this.runManualHits(unit, { + location, + targetLocation: mekCriticalRollLocation(unit, location), + hits: 1, + consolidateImmediately, + }, false); + }); + } + + async openManualChance(unit: CBTForceUnit, location: string, consolidateImmediately: boolean): Promise { + await this.runExclusive(unit, async () => { + while (true) { + const result = await this.openManualChanceStep(unit, location); + if (!result || result.kind === 'none') return; + if (result.kind === 'blown-off') { + this.applyBlowOffResult(unit, location, consolidateImmediately); + return; + } + + const hitResult = await this.runManualHits(unit, { + location, + targetLocation: mekCriticalRollLocation(unit, location), + hits: result.count, + consolidateImmediately, + }, true); + if (hitResult !== 'undo') return; + } + }); + } + + private async openManualChanceStep( + unit: CBTForceUnit, + location: string, + ): Promise { + const ref = this.dialogsService.createDialog( + MekCriticalChanceDialogComponent, + { + disableClose: false, + data: { + locationLabel: getMekLocationLabel(location) ?? location, + canBlowOff: mekCriticalChanceCanBlowOff(location), + industrialMek: usesIndustrialMekCriticalChanceTable(unit), + modifiers: mekCriticalChanceModifiers(unit, location), + manual: true, + }, + }, + ); + return firstValueFrom(ref.closed); + } + + private async runManualHits( + unit: CBTForceUnit, + request: Omit, + canUndoToChance: boolean, + ): Promise<'done' | 'cancelled' | 'undo'> { + let remainingHits = request.hits; + let undoAvailable = canUndoToChance; + + while (remainingHits > 0) { + if (this.hasImmediateConsciousness(unit) + && !await this.resolveImmediateConsciousness(unit)) return 'cancelled'; + + const ref = this.dialogsService.createDialog( + MekCriticalHitDialogComponent, + { + disableClose: false, + data: { + unit, + location: request.location, + targetLocation: request.targetLocation ?? request.location, + requiredHits: remainingHits, + locationDestroyed: request.locationDestroyed ?? false, + consolidateImmediately: request.consolidateImmediately, + pilotDamageGroup: request.pilotDamageGroup + ?? unit.turnState().currentPilotDamageGroup(), + canUndoToChance: undoAvailable, + manual: true, + }, + }, + ); + const result = await firstValueFrom(ref.closed); + if (result?.undoToChance && undoAvailable) return 'undo'; + if (!result?.interruptedForConsciousness) { + return result?.completed ? 'done' : 'cancelled'; + } + + remainingHits = result.remainingHits ?? (result.completed ? 0 : remainingHits); + undoAvailable = false; + if (!await this.resolveImmediateConsciousness(unit)) return 'cancelled'; + if (result.completed || remainingHits === 0) return 'done'; + } + return 'done'; + } + + private applyBlowOffResult( + unit: CBTForceUnit, + location: string, + consolidateImmediately: boolean, + automatic = false, + ): void { + const blowOff = applyMekBlowOff(unit, location, consolidateImmediately); + if (blowOff.kind === 'absorbed') { + const message = `Critical chance in ${getMekLocationLabel(location) ?? location}: armored ${blowOff.equipment} absorbed the blow-off result`; + if (automatic) this.automationToasts.show(unit, message, 'success'); + else this.toastService.showToast(`Armored ${blowOff.equipment} absorbs the blow-off result`, 'info'); + return; + } + const message = `${getMekLocationLabel(location) ?? location} blown off`; + if (automatic) this.automationToasts.show(unit, `Critical chance: ${message}`, 'error'); + else this.toastService.showToast(message, 'error'); + } + + private async runExclusive(unit: CBTForceUnit, action: () => Promise): Promise { + if (this.activeUnits.has(unit)) return undefined; + this.activeUnits.add(unit); + try { + return await action(); + } finally { + this.activeUnits.delete(unit); + } + } + + private async resolveImmediateConsciousness(unit: CBTForceUnit): Promise { + await this.unitChecks.open([unit]); + return !this.hasImmediateConsciousness(unit); + } + + private hasImmediateConsciousness(unit: CBTForceUnit): boolean { + return !unit.gameRules.aggregatedEndPhaseConsciousRolls + && unit.turnState().actionablePendingUnitChecks() + .some(isConsciousnessCheck); + } + + private rollD6(): number { + return Math.floor(Math.random() * 6) + 1; + } + +} + +function serializeChanceResult(result: MekCriticalChanceResult): SerializedMekCriticalChanceResult { + return result.kind === 'critical-hits' ? result.count : result.kind; +} + +function deserializeChanceResult( + result: SerializedMekCriticalChanceResult | undefined, +): MekCriticalChanceResult | undefined { + if (result === undefined) return undefined; + return typeof result === 'number' ? { kind: 'critical-hits', count: result } : { kind: result }; +} diff --git a/src/app/services/options.service.spec.ts b/src/app/services/options.service.spec.ts index 361f1d512..0ba1849d4 100644 --- a/src/app/services/options.service.spec.ts +++ b/src/app/services/options.service.spec.ts @@ -45,40 +45,6 @@ describe('OptionsService', () => { expect(service.options().enableForceSyncConflictDialog).toBeFalse(); }); - it('defaults heat effects to ask while preserving the established automation defaults', async () => { - savedOptions = null; - - const service = await createService(); - - expect(service.options().cbtAutomationOptions).toEqual({ - pilotSkillCheck: 'ask', - heatAndDissipation: 'no', - heatEffects: 'ask', - pilotHitsAndConsciousness: 'ask', - internalExplosions: 'ask', - criticalHitChance: 'ask', - breachAndFlood: 'ask', - falling: 'ask', - }); - }); - - it('restores each heat automation policy independently', async () => { - savedOptions = { - cbtAutomationOptions: { - heatAndDissipation: 'yes', - heatEffects: 'no', - }, - }; - - const service = await createService(); - - expect(service.cbtAutomationMode('heatAndDissipation')).toBe('yes'); - expect(service.cbtAutomationMode('heatEffects')).toBe('no'); - expect(service.cbtAutomationMode('pilotHitsAndConsciousness')).toBe('ask'); - expect(service.cbtAutomationMode('criticalHitChance')).toBe('ask'); - expect(service.cbtAutomationMode('falling')).toBe('ask'); - }); - it('restores the force sync conflict dialog preference', async () => { savedOptions = { enableForceSyncConflictDialog: true }; @@ -222,6 +188,7 @@ describe('OptionsService', () => { expect(service.options().CBTOptionalRules).toEqual({ forcedWithdrawal: true, extremeRange: false, + floatingCriticals: false, }); expect(service.options().lastCanvasState).toBeUndefined(); expect(service.options().sidebarLipPosition).toBeUndefined(); @@ -283,6 +250,7 @@ describe('OptionsService', () => { expect(service.options().CBTOptionalRules).toEqual({ forcedWithdrawal: true, extremeRange: false, + floatingCriticals: false, }); }); @@ -291,6 +259,7 @@ describe('OptionsService', () => { CBTOptionalRules: { forcedWithdrawal: false, extremeRange: true, + floatingCriticals: true, }, }; @@ -299,6 +268,7 @@ describe('OptionsService', () => { expect(service.options().CBTOptionalRules).toEqual({ forcedWithdrawal: false, extremeRange: true, + floatingCriticals: true, }); }); diff --git a/src/app/services/options.service.ts b/src/app/services/options.service.ts index 33809987a..cc2f89910 100644 --- a/src/app/services/options.service.ts +++ b/src/app/services/options.service.ts @@ -43,16 +43,17 @@ const DEFAULT_OPTIONS: Options = { syncZoomBetweenSheets: true, trackPhaseAndTurn: true, cbtAutomationOptions: { - pilotSkillCheck: 'ask', - heatAndDissipation: 'no', - heatEffects: 'ask', - pilotHitsAndConsciousness: 'ask', - internalExplosions: 'ask', - criticalHitChance: 'ask', - breachAndFlood: 'ask', - falling: 'ask', + pilotSkillCheck: 'no', + heatAndDissipationResolution: 'no', + heatEffectsCheck: 'no', + pilotHitsAndConsciousnessCheck: 'no', + internalExplosionsCheck: 'ask', + criticalHitChanceCheck: 'no', + breachAndFloodCheck: 'yes', + fallingCheck: 'no', }, CBTOptionalRules: { + floatingCriticals: false, forcedWithdrawal: true, extremeRange: false, }, @@ -188,6 +189,7 @@ function resolveForceGeneratorOptions(saved: Options | null | undefined): ForceG function resolveCBTOptionalRules(saved: Options | null | undefined): CBTOptionalRules { const defaults = DEFAULT_OPTIONS.CBTOptionalRules; return { + floatingCriticals: resolveSavedValue(saved?.CBTOptionalRules?.floatingCriticals, defaults.floatingCriticals), forcedWithdrawal: resolveSavedValue(saved?.CBTOptionalRules?.forcedWithdrawal, defaults.forcedWithdrawal), extremeRange: resolveSavedValue(saved?.CBTOptionalRules?.extremeRange, defaults.extremeRange), }; @@ -201,39 +203,39 @@ function resolveCBTAutomationOptions(saved: Options | null | undefined): CBTAuto defaults.pilotSkillCheck, OPTION_VALUES.automationMode, ), - heatAndDissipation: resolveSavedValue( - saved?.cbtAutomationOptions?.heatAndDissipation, - defaults.heatAndDissipation, + heatAndDissipationResolution: resolveSavedValue( + saved?.cbtAutomationOptions?.heatAndDissipationResolution, + defaults.heatAndDissipationResolution, OPTION_VALUES.automationMode, ), - heatEffects: resolveSavedValue( - saved?.cbtAutomationOptions?.heatEffects, - defaults.heatEffects, + heatEffectsCheck: resolveSavedValue( + saved?.cbtAutomationOptions?.heatEffectsCheck, + defaults.heatEffectsCheck, OPTION_VALUES.automationMode, ), - pilotHitsAndConsciousness: resolveSavedValue( - saved?.cbtAutomationOptions?.pilotHitsAndConsciousness, - defaults.pilotHitsAndConsciousness, + pilotHitsAndConsciousnessCheck: resolveSavedValue( + saved?.cbtAutomationOptions?.pilotHitsAndConsciousnessCheck, + defaults.pilotHitsAndConsciousnessCheck, OPTION_VALUES.automationMode, ), - internalExplosions: resolveSavedValue( - saved?.cbtAutomationOptions?.internalExplosions, - defaults.internalExplosions, + internalExplosionsCheck: resolveSavedValue( + saved?.cbtAutomationOptions?.internalExplosionsCheck, + defaults.internalExplosionsCheck, OPTION_VALUES.automationMode, ), - criticalHitChance: resolveSavedValue( - saved?.cbtAutomationOptions?.criticalHitChance, - defaults.criticalHitChance, + criticalHitChanceCheck: resolveSavedValue( + saved?.cbtAutomationOptions?.criticalHitChanceCheck, + defaults.criticalHitChanceCheck, OPTION_VALUES.automationMode, ), - breachAndFlood: resolveSavedValue( - saved?.cbtAutomationOptions?.breachAndFlood, - defaults.breachAndFlood, + breachAndFloodCheck: resolveSavedValue( + saved?.cbtAutomationOptions?.breachAndFloodCheck, + defaults.breachAndFloodCheck, OPTION_VALUES.automationMode, ), - falling: resolveSavedValue( - saved?.cbtAutomationOptions?.falling, - defaults.falling, + fallingCheck: resolveSavedValue( + saved?.cbtAutomationOptions?.fallingCheck, + defaults.fallingCheck, OPTION_VALUES.automationMode, ), }; @@ -268,6 +270,9 @@ function resolveUnitServers(saved: unknown): string[] { @Injectable({ providedIn: 'root' }) export class OptionsService { private dbService = inject(DbService); + private readonly cbtAutomationOptionsState = signal({ + ...DEFAULT_OPTIONS.cbtAutomationOptions, + }); readonly initialized = signal(false); public options = signal({ @@ -311,6 +316,8 @@ export class OptionsService { async initOptions() { const saved = await this.dbService.getOptions(); + const cbtAutomationOptions = resolveCBTAutomationOptions(saved); + this.cbtAutomationOptionsState.set(cbtAutomationOptions); this.options.set({ colorScheme: resolveSavedValue(saved?.colorScheme, DEFAULT_OPTIONS.colorScheme, OPTION_VALUES.colorScheme), pickerStyle: resolveSavedValue(saved?.pickerStyle, DEFAULT_OPTIONS.pickerStyle, OPTION_VALUES.pickerStyle), @@ -327,7 +334,7 @@ export class OptionsService { lastCanvasState: resolveLastCanvasState(saved?.lastCanvasState), sidebarLipPosition: typeof saved?.sidebarLipPosition === 'string' ? saved.sidebarLipPosition : undefined, trackPhaseAndTurn: resolveSavedValue(saved?.trackPhaseAndTurn, DEFAULT_OPTIONS.trackPhaseAndTurn), - cbtAutomationOptions: resolveCBTAutomationOptions(saved), + cbtAutomationOptions, CBTOptionalRules: resolveCBTOptionalRules(saved), CBTRules: resolveSavedValue(saved?.CBTRules, DEFAULT_OPTIONS.CBTRules, OPTION_VALUES.CBTRules), ASUseHex: resolveSavedValue(saved?.ASUseHex, DEFAULT_OPTIONS.ASUseHex), @@ -351,14 +358,41 @@ export class OptionsService { } async setOption(key: K, value: Options[K]) { + if (key === 'cbtAutomationOptions') { + await this.setCbtAutomationOptions(value as CBTAutomationOptions); + return; + } + const updated = { ...this.options(), [key]: value }; this.options.set(updated); await this.dbService.saveOptions(updated); } + async setCbtAutomationMode(key: CBTAutomationKey, value: AutomationMode) { + const current = this.cbtAutomationOptionsState(); + if (current[key] === value) { + return; + } + + const cbtAutomationOptions = { ...current, [key]: value }; + await this.setCbtAutomationOptions(cbtAutomationOptions); + } + + private async setCbtAutomationOptions(cbtAutomationOptions: CBTAutomationOptions) { + this.cbtAutomationOptionsState.set(cbtAutomationOptions); + + // Keep the compatibility snapshot current without invalidating every + // consumer of the global options signal for this granular setting. + this.options().cbtAutomationOptions = cbtAutomationOptions; + await this.dbService.saveOptions({ + ...this.options(), + cbtAutomationOptions, + }); + } + /** Returns the configured mode for one CBT automation. */ cbtAutomationMode(key: CBTAutomationKey): AutomationMode { - return this.options().cbtAutomationOptions[key]; + return this.cbtAutomationOptionsState()[key]; } async updateForceGeneratorOptions( diff --git a/src/app/services/toast.service.ts b/src/app/services/toast.service.ts index e520f2aa4..e2783d32e 100644 --- a/src/app/services/toast.service.ts +++ b/src/app/services/toast.service.ts @@ -13,8 +13,8 @@ export interface Toast { data?: Record; } -const TOAST_DURATION_MS = 3000; -const MAX_TOASTS = 3; +const TOAST_DURATION_MS = 4000; +const MAX_TOASTS = 4; @Injectable({ providedIn: 'root' }) export class ToastService { diff --git a/src/app/services/unit-check-resolution.service.spec.ts b/src/app/services/unit-check-resolution.service.spec.ts new file mode 100644 index 000000000..2d5866810 --- /dev/null +++ b/src/app/services/unit-check-resolution.service.spec.ts @@ -0,0 +1,1168 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import type { PendingEventInput, SerializedPendingUnitCheck } from '../models/force-serialization'; +import type { AutomationMode } from '../models/options.model'; +import type { HeatAmmoExplosionCandidate } from '../utils/heat-effects.util'; +import { + pendingCheckReviewGroupList, + pendingUnitCheckGroupList, + pendingUnitCheckGroupStage, + pendingUnitCheckStage, + type PendingCheckReviewEntry, + type PendingUnitCheckEntry, +} from '../utils/unit-check.util'; +import { DialogsService } from './dialogs.service'; +import { OptionsService } from './options.service'; +import { ToastService } from './toast.service'; +import { UnitCheckResolutionService } from './unit-check-resolution.service'; + +describe('UnitCheckResolutionService', () => { + type CheckInput = PendingEventInput; + + let service: UnitCheckResolutionService; + let createDialog: jasmine.Spy; + let showToast: jasmine.Spy; + let automationModes: Record; + + function createHarness( + initialChecks: readonly CheckInput[], + rulesId: 'core2026' | 'tw' = 'core2026', + psrCount = 0, + unitType: 'Mek' | 'Aero' = 'Mek', + airborne = false, + criticalCount = 0, + unitId = 'unit-under-test', + ) { + const checks = new Map(initialChecks.map(check => [ + check.id, + { type: 'unit-check', ...check } as SerializedPendingUnitCheck, + ])); + const psrChecks = Array.from({ length: psrCount }, (_value, index) => ({ + id: `psr:${index + 1}`, + fallCheck: 0, + reason: `PSR ${index + 1}`, + failureOutcome: 'Fall', + })); + const psrOutcomes = new Map(); + const psrOutcomeSelections = signal>>({}); + const psrDiceSelections = signal>>({}); + let crewState = 'healthy'; + const queuePendingUnitCheck = jasmine.createSpy('queuePendingUnitCheck').and.callFake( + (check: CheckInput) => { + checks.set(check.id, { type: 'unit-check', ...check } as SerializedPendingUnitCheck); + return true; + }, + ); + const discardPendingUnitCheck = jasmine.createSpy('discardPendingUnitCheck').and.callFake((id: string) => + checks.delete(id)); + const discardPendingUnitChecks = jasmine.createSpy('discardPendingUnitChecks').and.callFake( + (predicate: (check: SerializedPendingUnitCheck) => boolean) => { + let removed = 0; + for (const [id, check] of checks) { + if (!predicate(check)) continue; + checks.delete(id); + removed++; + } + return removed; + }, + ); + const crew = { + getState: jasmine.createSpy('getState').and.callFake(() => crewState), + setState: jasmine.createSpy('setState').and.callFake((state: string) => { crewState = state; }), + getHits: jasmine.createSpy('getHits').and.returnValue(3), + }; + const refreshPendingUnitCheckTargets = jasmine.createSpy('refreshPendingUnitCheckTargets').and.callFake(() => { + if (crewState === 'healthy') return; + for (const [id, check] of checks) { + if (check.kind !== 'seatbelt') continue; + const { target: _target, result: _result, ...facts } = check; + checks.set(id, { + ...facts, + result: { kind: 'automatic', outcome: 'failed' }, + } as SerializedPendingUnitCheck); + } + }); + const turnState = { + getPendingUnitCheck: (id: string) => checks.get(id), + getPendingUnitChecks: () => Array.from(checks.values()), + pendingUnitCheckCount: () => checks.size, + actionablePendingUnitChecks: () => Array.from(checks.values()).filter(check => + (!('readyTurn' in check) || check.readyTurn <= 0) + && !(rulesId === 'core2026' + && check.kind === 'consciousness' + && check.pilotDamageGroup.startsWith('combat:'))), + phaseEndPendingUnitChecks: () => Array.from(checks.values()).filter(check => + !('readyTurn' in check) || check.readyTurn <= 0), + queuePendingUnitCheck, + discardPendingUnitCheck, + discardPendingUnitChecks, + setPendingUnitCheckOutcome: ( + id: string, + outcome: 'success' | 'failed', + roll?: readonly number[], + ) => { + const check = checks.get(id); + if (!check || check.target === undefined) return false; + checks.set(id, { + ...check, + result: roll + ? { kind: 'roll', dice: [roll[0], roll[1]] as const } + : { kind: 'manual', outcome }, + } as SerializedPendingUnitCheck); + return true; + }, + setPendingUnitCheckSelection: (id: string, selectionId: string) => { + const check = checks.get(id); + if (!check || check.kind !== 'heat-ammo-explosion') return false; + checks.set(id, { ...check, selectionId }); + return true; + }, + refreshPendingUnitCheckTargets, + setPSRCheckState: jasmine.createSpy('setPSRCheckState'), + getPSRCheckState: () => ({}), + PSRRollsCount: () => psrChecks.filter(check => !psrOutcomes.has(check.id)).length, + actionablePSRRollsCount: () => psrChecks.filter(check => !psrOutcomes.has(check.id)).length, + automaticPSRFailure: () => false, + autoFall: () => false, + getPSRChecks: () => psrChecks, + getPSROutcome: (id: string) => psrOutcomes.get(id), + resolvePSRCheck: jasmine.createSpy('resolvePSRCheck').and.callFake( + (id: string, outcome: 'success' | 'failed') => { + if (psrOutcomes.has(id)) return false; + psrOutcomes.set(id, outcome); + return true; + }, + ), + pendingCriticalChanceCount: () => criticalCount, + pendingCriticalHitCount: () => criticalCount, + failPendingPSRChecks: jasmine.createSpy('failPendingPSRChecks'), + getTurnCounter: () => 0, + airborne: () => airborne, + dirty: () => false, + }; + const queueConsciousnessRecovery = jasmine.createSpy('queueConsciousnessRecovery').and.callFake( + (crewId: number, delay: number, replacingCheckId?: string) => { + if (Array.from(checks.values()).some(check => check.id !== replacingCheckId + && check.kind === 'consciousness-recovery' + && (check.crewId ?? 0) === crewId)) return false; + return queuePendingUnitCheck({ + id: `recovery:${crewId}:${checks.size}`, + kind: 'consciousness-recovery', + crewId, + target: 7, + readyTurn: delay, + }); + }, + ); + const unit = { + id: unitId, + automationMode: () => 'ask', + getNotificationDisplayName: () => unitId, + psrOutcomeSelections, + psrDiceSelections, + getRuleCheck: () => undefined, + resolveRuleCheck: jasmine.createSpy('resolveRuleCheck'), + pendingFallCount: () => 0, + turnState: () => turnState, + applyPilotHits: jasmine.createSpy('applyPilotHits'), + applyLifeSupportDrowningCrewHits: jasmine.createSpy('applyLifeSupportDrowningCrewHits'), + applyHeatCrewHits: jasmine.createSpy('applyHeatCrewHits'), + applyInternalExplosionCrewHits: jasmine.createSpy('applyInternalExplosionCrewHits'), + setCondition: jasmine.createSpy('setCondition'), + getCondition: () => false, + getCrewMember: () => crew, + getCrewMembers: () => [crew], + setCrewState: jasmine.createSpy('setCrewState').and.callFake( + (crewId: number, state: string, recoveryDelay = 1) => { + crew.setState(state); + if (state === 'unconscious') queueConsciousnessRecovery(crewId, recoveryDelay); + return true; + }, + ), + queueConsciousnessRecovery, + getUnit: () => ({ type: unitType }), + gameRules: { + id: rulesId, + aggregatedEndPhaseConsciousRolls: rulesId === 'core2026', + }, + rules: { + getBasePilotingSkill: () => 5, + getStandardControlRollTarget: () => 5, + getActivePilotCrewId: () => crewState === 'healthy' ? 0 : null, + isRemoteDrone: () => false, + }, + PSRModifiers: () => ({ modifier: 0 }), + } as unknown as CBTForceUnit; + return { + unit, + checks, + psrOutcomes, + turnState, + crew, + queuePendingUnitCheck, + discardPendingUnitCheck, + }; + } + + function apply(unit: CBTForceUnit, checks: readonly CheckInput[], atPhaseEnd = false): void { + const entries: PendingUnitCheckEntry[] = checks.map(check => ({ + unit, + check: { type: 'unit-check', ...check } as SerializedPendingUnitCheck, + })); + (service as unknown as { + applyResolved(entries: readonly PendingUnitCheckEntry[], atPhaseEnd?: boolean): void; + }).applyResolved(entries, atPhaseEnd); + } + + beforeEach(() => { + automationModes = { + heatEffectsCheck: 'ask', + pilotHitsAndConsciousnessCheck: 'ask', + }; + createDialog = jasmine.createSpy('createDialog').and.returnValue({ closed: of(undefined) }); + showToast = jasmine.createSpy('showToast'); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + UnitCheckResolutionService, + { provide: DialogsService, useValue: { createDialog } }, + { provide: OptionsService, useValue: { + cbtAutomationMode: (key: string) => automationModes[key] ?? 'ask', + } }, + { provide: ToastService, useValue: { showToast } }, + ], + }); + service = TestBed.inject(UnitCheckResolutionService); + }); + + it('automatically rolls and applies consciousness checks in yes mode', async () => { + automationModes['pilotHitsAndConsciousnessCheck'] = 'yes'; + spyOn(Math, 'random').and.returnValues(0, 0); + const harness = createHarness([{ + id: 'consciousness', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: 'immediate:test', + target: 5, + }], 'tw'); + + expect(await service.open([harness.unit])).toBeTrue(); + + expect(Math.random).toHaveBeenCalledTimes(2); + expect(harness.unit.setCrewState).toHaveBeenCalledOnceWith(0, 'unconscious', 1); + expect(Array.from(harness.checks.values()).map(check => check.kind)) + .toEqual(['consciousness-recovery']); + expect(createDialog).not.toHaveBeenCalled(); + expect(showToast).toHaveBeenCalledOnceWith( + 'unit-under-test — Consciousness check: FAILED (2 vs 5+) — crew member rendered unconscious', + 'error', + ); + }); + + it('opens yes-mode checks for manual resolution when the pending badge is used', async () => { + automationModes['pilotHitsAndConsciousnessCheck'] = 'yes'; + spyOn(Math, 'random'); + const harness = createHarness([{ + id: 'consciousness', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: 'immediate:test', + target: 5, + }], 'tw'); + + expect(await service.open([harness.unit], false, true)).toBeFalse(); + + expect(createDialog).toHaveBeenCalledTimes(1); + expect(Math.random).not.toHaveBeenCalled(); + expect(harness.checks.has('consciousness')).toBeTrue(); + expect(harness.unit.setCrewState).not.toHaveBeenCalled(); + expect(showToast).not.toHaveBeenCalled(); + }); + + it('keeps an aerospace unit controlled when another crew member can take over', async () => { + automationModes['pilotHitsAndConsciousnessCheck'] = 'yes'; + const harness = createHarness([{ + id: 'consciousness', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: 'immediate:test', + result: { kind: 'automatic', outcome: 'failed' }, + }], 'tw', 0, 'Aero', true); + spyOn(harness.unit.rules, 'getActivePilotCrewId').and.returnValue(1); + + expect(await service.open([harness.unit])).toBeTrue(); + + expect(harness.unit.setCrewState).toHaveBeenCalledOnceWith(0, 'unconscious', 1); + expect(harness.turnState.failPendingPSRChecks).not.toHaveBeenCalled(); + expect(harness.unit.setCondition).not.toHaveBeenCalledWith('out-of-control', true); + }); + + it('summarizes automatic checks for multiple crew members in one toast', async () => { + automationModes['pilotHitsAndConsciousnessCheck'] = 'yes'; + spyOn(Math, 'random').and.returnValues(0.99, 0.99, 0.99, 0.99); + const harness = createHarness([ + { + id: 'seatbelt:0', + kind: 'seatbelt', + crewId: 0, + pilotDamageGroup: 'immediate:fall', + target: 5, + }, + { + id: 'seatbelt:1', + kind: 'seatbelt', + crewId: 1, + pilotDamageGroup: 'immediate:fall', + result: { kind: 'automatic', outcome: 'failed' }, + }, + { + id: 'seatbelt:2', + kind: 'seatbelt', + crewId: 2, + pilotDamageGroup: 'immediate:fall', + target: 4, + }, + ]); + (harness.unit.applyPilotHits as jasmine.Spy).and.returnValue(1); + + expect(await service.open([harness.unit])).toBeTrue(); + + expect(harness.unit.applyPilotHits).toHaveBeenCalledOnceWith(1, 'immediate:fall', 1); + expect(showToast).toHaveBeenCalledOnceWith( + 'unit-under-test — Seatbelt checks — Crew 1: PASSED (12 vs 5+) — pilot damage avoided; Crew 2: FAILED (automatic) — 1 pilot hit applied; Crew 3: PASSED (12 vs 4+) — pilot damage avoided', + 'error', + ); + }); + + it('automatically rolls and applies heat checks in yes mode', async () => { + automationModes['heatEffectsCheck'] = 'yes'; + spyOn(Math, 'random').and.returnValues(0, 0); + const harness = createHarness([{ + id: 'shutdown', + kind: 'heat-shutdown', + target: 6, + }], 'tw'); + + expect(await service.open([harness.unit])).toBeTrue(); + + expect(harness.unit.setCondition).toHaveBeenCalledOnceWith('shutdown', true); + expect(harness.checks.size).toBe(0); + expect(createDialog).not.toHaveBeenCalled(); + expect(showToast).toHaveBeenCalledOnceWith( + 'unit-under-test — Shutdown: FAILED (2 vs 6+) — unit shut down', + 'error', + ); + }); + + it('shows a success toast when an automatic check passes', async () => { + automationModes['heatEffectsCheck'] = 'yes'; + spyOn(Math, 'random').and.returnValues(0.99, 0.99); + const harness = createHarness([{ + id: 'restart', + kind: 'shutdown-recovery', + target: 6, + }], 'tw'); + + expect(await service.open([harness.unit])).toBeTrue(); + + expect(harness.unit.setCondition).toHaveBeenCalledOnceWith('shutdown', false); + expect(showToast).toHaveBeenCalledOnceWith( + 'unit-under-test — Shutdown recovery: PASSED (12 vs 6+) — unit restarted', + 'success', + ); + }); + + it('reports the pilot hits actually applied by automatic damage', async () => { + automationModes['pilotHitsAndConsciousnessCheck'] = 'yes'; + const harness = createHarness([{ + id: 'life-support', + kind: 'heat-life-support', + result: { kind: 'automatic', outcome: 'failed' }, + hits: 1, + }]); + (harness.unit.applyHeatCrewHits as jasmine.Spy).and.returnValue(3); + + expect(await service.open([harness.unit])).toBeTrue(); + + expect(showToast).toHaveBeenCalledOnceWith( + 'unit-under-test — Life Support damage: FAILED (automatic) — 3 pilot hits applied', + 'error', + ); + }); + + it('applies an aerospace ammo explosion to the whole crew once', () => { + const entry = { + critSlots: [], + setPendingDestroyed: jasmine.createSpy('setPendingDestroyed'), + setCommittedDestroyed: jasmine.createSpy('setCommittedDestroyed'), + }; + const applyCrewHits = jasmine.createSpy('applyInternalExplosionCrewHits').and.returnValue(3); + const unit = { + getInventory: () => [], + isEquipmentOperational: () => true, + findCurrentCriticalSlot: () => null, + setInventoryEntry: jasmine.createSpy('setInventoryEntry'), + addInternalHits: jasmine.createSpy('addInternalHits'), + applyInternalExplosionCrewHits: applyCrewHits, + } as unknown as CBTForceUnit; + const candidate = { + id: 'ammo', + equipment: 'LRM Ammo', + location: 'Fuselage', + damagePerShot: 1, + shots: 20, + rawDamage: 20, + entry, + } as unknown as HeatAmmoExplosionCandidate; + + const effect = (service as unknown as { + applyAeroAmmoExplosion( + target: CBTForceUnit, + ammo: HeatAmmoExplosionCandidate, + group?: string, + ): string | null; + }).applyAeroAmmoExplosion(unit, candidate, 'heat:ammo'); + + expect(applyCrewHits).toHaveBeenCalledOnceWith(1, 'heat:ammo'); + expect(effect).toBe('LRM Ammo exploded for 20 damage in Fuselage; 2 SI damage applied; 3 pilot hits applied'); + }); + + it('applies deterministic Life Support damage once and removes the persisted result', () => { + const check: CheckInput = { + id: 'life-support', + kind: 'heat-life-support', + pilotDamageGroup: 'end-turn:one', + result: { kind: 'automatic', outcome: 'failed' }, + hits: 2, + }; + const harness = createHarness([check]); + + apply(harness.unit, [check]); + + expect(harness.unit.applyHeatCrewHits).toHaveBeenCalledOnceWith(2, 'end-turn:one'); + expect(harness.checks.size).toBe(0); + }); + + it('keeps submerged Life Support damage in the End Phase instead of the Heat Phase group', () => { + const check: CheckInput = { + id: 'drowning', + kind: 'life-support-drowning', + pilotDamageGroup: 'turn-closed:immediate:end-turn:one:end', + result: { kind: 'automatic', outcome: 'failed' }, + hits: 1, + }; + const harness = createHarness([check]); + + apply(harness.unit, [check]); + + expect(harness.unit.applyLifeSupportDrowningCrewHits).toHaveBeenCalledOnceWith(1, check.pilotDamageGroup); + expect(harness.unit.applyHeatCrewHits).not.toHaveBeenCalled(); + expect(harness.checks.size).toBe(0); + }); + + it('applies approved Life Support damage silently and opens only its resulting consciousness roll', async () => { + const lifeSupport: CheckInput = { + id: 'life-support', + kind: 'heat-life-support', + pilotDamageGroup: 'turn-closed:heat:end-turn:one', + result: { kind: 'automatic', outcome: 'failed' }, + hits: 2, + }; + const harness = createHarness([lifeSupport]); + (harness.unit.applyHeatCrewHits as jasmine.Spy).and.callFake((hits: number, group: string) => { + harness.queuePendingUnitCheck({ + id: 'consciousness', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: group, + target: 5, + }); + return hits; + }); + + await service.open([harness.unit]); + + expect(harness.unit.applyHeatCrewHits).toHaveBeenCalledOnceWith(2, lifeSupport.pilotDamageGroup); + expect(Array.from(harness.checks.values()).map(check => check.kind)).toEqual(['consciousness']); + expect(createDialog).toHaveBeenCalledTimes(1); + }); + + it('discards queued pilot-hit and consciousness automation when its mode is no', async () => { + automationModes['pilotHitsAndConsciousnessCheck'] = 'no'; + const harness = createHarness([ + { + id: 'life-support', + kind: 'heat-life-support', + result: { kind: 'automatic', outcome: 'failed' }, + hits: 1, + }, + { + id: 'consciousness', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: 'immediate:test', + target: 3, + }, + { + id: 'recovery', + kind: 'consciousness-recovery', + crewId: 0, + target: 3, + readyTurn: 0, + }, + ]); + + await service.open([harness.unit]); + + expect(harness.checks.size).toBe(0); + expect(harness.unit.applyHeatCrewHits).not.toHaveBeenCalled(); + expect(createDialog).not.toHaveBeenCalled(); + }); + + it('exposes open Core combat consciousness only to an END PHASE resolution', async () => { + const harness = createHarness([{ + id: 'consciousness', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: 'combat:current-phase', + target: 7, + }]); + + expect(await service.open([harness.unit])).toBeTrue(); + expect(createDialog).not.toHaveBeenCalled(); + + expect(await service.open([harness.unit], true)).toBeFalse(); + expect(createDialog).toHaveBeenCalledTimes(1); + expect(createDialog.calls.mostRecent().args[1].data.atPhaseEnd).toBeTrue(); + }); + + it('reviews every unit heat check and consciousness roll in one rules-ordered list', () => { + const first = createHarness([ + { id: 'shutdown:one', kind: 'heat-shutdown', target: 6 }, + { id: 'ammo:one', kind: 'heat-ammo-explosion', target: 4 }, + { + id: 'life-support:one', kind: 'heat-life-support', hits: 1, + result: { kind: 'automatic', outcome: 'failed' }, + }, + { + id: 'consciousness:one', kind: 'consciousness', crewId: 0, + pilotDamageGroup: 'turn-closed:heat:end-turn:test', target: 5, + }, + ], 'core2026', 0, 'Mek', false, 0, 'unit-one'); + const second = createHarness([ + { id: 'shutdown:two', kind: 'heat-shutdown', target: 6 }, + { id: 'ammo:two', kind: 'heat-ammo-explosion', target: 4 }, + { + id: 'life-support:two', kind: 'heat-life-support', hits: 1, + result: { kind: 'automatic', outcome: 'failed' }, + }, + { + id: 'consciousness:two', kind: 'consciousness', crewId: 0, + pilotDamageGroup: 'turn-closed:heat:end-turn:test', target: 5, + }, + ], 'core2026', 0, 'Mek', false, 0, 'unit-two'); + + expect(pendingUnitCheckGroupList([first.unit, second.unit]) + .map(entry => entry.check.id)).toEqual([ + 'shutdown:one', + 'shutdown:two', + 'ammo:one', + 'ammo:two', + 'life-support:one', + 'life-support:two', + 'consciousness:one', + 'consciousness:two', + ]); + }); + + it('applies one submitted full list in internal rules stages', () => { + const checks: CheckInput[] = [ + { + id: 'shutdown', kind: 'heat-shutdown', target: 6, + result: { kind: 'manual', outcome: 'success' }, + }, + { + id: 'ammo', kind: 'heat-ammo-explosion', target: 4, + result: { kind: 'manual', outcome: 'success' }, + }, + { + id: 'consciousness', kind: 'consciousness', crewId: 0, + pilotDamageGroup: 'turn-closed:heat:end-turn:test', target: 5, + result: { kind: 'manual', outcome: 'success' }, + }, + ]; + const harness = createHarness(checks); + + apply(harness.unit, checks); + + expect(harness.checks.size).toBe(0); + expect(harness.unit.setCondition).not.toHaveBeenCalledWith('shutdown', false); + }); + + it('applies consciousness, PSR, and later unit checks from one submitted list', () => { + const checks: CheckInput[] = [ + { + id: 'consciousness', kind: 'consciousness', crewId: 0, + pilotDamageGroup: 'immediate:test', target: 5, + result: { kind: 'manual', outcome: 'success' }, + }, + { + id: 'seatbelt', kind: 'seatbelt', crewId: 0, target: 5, + result: { kind: 'manual', outcome: 'success' }, + }, + ]; + const harness = createHarness(checks, 'tw', 1); + harness.unit.psrOutcomeSelections.set({ 'psr:1': 'success' }); + const entries = pendingCheckReviewGroupList([harness.unit]); + + (service as unknown as { + applyResolved(entries: readonly PendingCheckReviewEntry[]): void; + }).applyResolved(entries); + + expect(entries.map(entry => entry.check.id)).toEqual([ + 'consciousness', + 'psr:1', + 'seatbelt', + ]); + expect(harness.psrOutcomes.get('psr:1')).toBe('success'); + expect(harness.checks.size).toBe(0); + }); + + it('turns a failed TW heat shutdown into a persistent fall PSR', () => { + const check: CheckInput = { + id: 'shutdown', + kind: 'heat-shutdown', + target: 6, + result: { kind: 'manual', outcome: 'failed' }, + }; + const harness = createHarness([check], 'tw'); + + apply(harness.unit, [check]); + + expect(harness.unit.setCondition).toHaveBeenCalledOnceWith('shutdown', true); + expect(harness.turnState.setPSRCheckState).toHaveBeenCalledOnceWith({ shutdown: true }); + expect(harness.checks.size).toBe(0); + }); + + it('does not create a shutdown PSR under Core rules', () => { + const check: CheckInput = { + id: 'shutdown', + kind: 'heat-shutdown', + target: 6, + result: { kind: 'manual', outcome: 'failed' }, + }; + const harness = createHarness([check], 'core2026'); + + apply(harness.unit, [check]); + + expect(harness.unit.setCondition).toHaveBeenCalledOnceWith('shutdown', true); + expect(harness.turnState.setPSRCheckState).not.toHaveBeenCalled(); + }); + + it('restarts the engine only when shutdown recovery succeeds', () => { + const check: CheckInput = { + id: 'shutdown-recovery', + kind: 'shutdown-recovery', + target: 6, + result: { kind: 'manual', outcome: 'success' }, + }; + const harness = createHarness([check]); + + apply(harness.unit, [check]); + + expect(harness.unit.setCondition).toHaveBeenCalledOnceWith('shutdown', false); + expect(harness.checks.size).toBe(0); + }); + + it('queues next-turn recovery when a consciousness check fails', () => { + const check: CheckInput = { + id: 'consciousness', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: 'turn-closed:immediate:end-turn:one', + target: 7, + result: { kind: 'manual', outcome: 'failed' }, + }; + const harness = createHarness([check]); + + apply(harness.unit, [check]); + + expect(harness.crew.setState).toHaveBeenCalledOnceWith('unconscious'); + expect(harness.queuePendingUnitCheck).toHaveBeenCalledWith(jasmine.objectContaining({ + kind: 'consciousness-recovery', + target: 7, + readyTurn: 1, + })); + expect(Array.from(harness.checks.values())).toEqual([ + jasmine.objectContaining({ kind: 'consciousness-recovery' }), + ]); + }); + + it('fails every pending PSR when the active pilot becomes unconscious', () => { + const check: CheckInput = { + id: 'consciousness', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: 'immediate:test', + target: 7, + result: { kind: 'manual', outcome: 'failed' }, + }; + const harness = createHarness([check], 'tw', 2); + + apply(harness.unit, [check]); + + expect(harness.turnState.failPendingPSRChecks).toHaveBeenCalledTimes(1); + }); + + it('queues recovery for the next turn when consciousness is lost before turn end', () => { + const check: CheckInput = { + id: 'consciousness', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: 'combat:one', + target: 7, + result: { kind: 'manual', outcome: 'failed' }, + }; + const harness = createHarness([check]); + + apply(harness.unit, [check], true); + + expect(harness.queuePendingUnitCheck).toHaveBeenCalledWith(jasmine.objectContaining({ + kind: 'consciousness-recovery', + readyTurn: 1, + })); + }); + + it('persists a later Control Roll when an airborne Aero pilot falls unconscious', () => { + const check: CheckInput = { + id: 'consciousness', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: 'phase-closed:combat:one', + target: 7, + result: { kind: 'manual', outcome: 'failed' }, + }; + const harness = createHarness([check], 'tw', 0, 'Aero', true); + + apply(harness.unit, [check]); + + expect(harness.unit.setCondition).toHaveBeenCalledWith('out-of-control', true); + expect(Array.from(harness.checks.values())).toEqual(jasmine.arrayWithExactContents([ + jasmine.objectContaining({ + kind: 'consciousness-recovery', + readyTurn: 1, + }), + jasmine.objectContaining({ + kind: 'aero-control-recovery', + readyTurn: 1, + }), + ])); + }); + + it('keeps consciousness and recovery scoped to the affected crew member', () => { + const check: CheckInput = { + id: 'consciousness', + kind: 'consciousness', + pilotDamageGroup: 'turn-closed:immediate:end-turn:one', + crewId: 2, + target: 7, + result: { kind: 'manual', outcome: 'failed' }, + }; + const harness = createHarness([check]); + + apply(harness.unit, [check]); + + expect(harness.queuePendingUnitCheck).toHaveBeenCalledWith(jasmine.objectContaining({ + kind: 'consciousness-recovery', + crewId: 2, + })); + }); + + it('keeps aerospace random movement active until a later Control Roll succeeds', () => { + const check: CheckInput = { + id: 'random-movement', + kind: 'heat-random-movement', + target: 8, + result: { kind: 'manual', outcome: 'failed' }, + }; + const harness = createHarness([check]); + + apply(harness.unit, [check]); + + expect(harness.unit.setCondition).toHaveBeenCalledWith('random-movement', true); + expect(harness.unit.setCondition).toHaveBeenCalledWith('out-of-control', true); + expect(harness.queuePendingUnitCheck).toHaveBeenCalledWith(jasmine.objectContaining({ + kind: 'aero-control-recovery', + cause: 'heat-random-movement', + target: 5, + readyTurn: 1, + })); + }); + + it('applies a submitted heat Avoid Roll before its submitted Control Roll', () => { + const check: CheckInput = { + id: 'random-movement', + kind: 'heat-random-movement', + target: 8, + result: { kind: 'manual', outcome: 'success' }, + }; + const recovery: CheckInput = { + id: 'control-recovery', + kind: 'aero-control-recovery', + cause: 'heat-random-movement', + target: 5, + result: { kind: 'manual', outcome: 'success' }, + readyTurn: 0, + }; + const harness = createHarness([check, recovery]); + + apply(harness.unit, [check, recovery]); + + expect(harness.unit.setCondition).toHaveBeenCalledWith('random-movement', false); + expect(harness.unit.setCondition).toHaveBeenCalledWith('out-of-control', false); + expect(harness.checks.size).toBe(0); + }); + + it('does not erase unrelated random movement after a fresh heat Avoid Roll succeeds', () => { + const check: CheckInput = { + id: 'random-movement', + kind: 'heat-random-movement', + target: 8, + result: { kind: 'manual', outcome: 'success' }, + }; + const harness = createHarness([check]); + + apply(harness.unit, [check]); + + expect(harness.unit.setCondition).not.toHaveBeenCalledWith('random-movement', false); + expect(harness.unit.setCondition).not.toHaveBeenCalledWith('out-of-control', false); + }); + + it('clears both heat-induced conditions when its later Control Roll succeeds', () => { + const check: CheckInput = { + id: 'control-recovery', + kind: 'aero-control-recovery', + cause: 'heat-random-movement', + target: 5, + result: { kind: 'manual', outcome: 'success' }, + readyTurn: 0, + }; + const harness = createHarness([check]); + + apply(harness.unit, [check]); + + expect(harness.unit.setCondition).toHaveBeenCalledWith('out-of-control', false); + expect(harness.unit.setCondition).toHaveBeenCalledWith('random-movement', false); + }); + + it('preserves unrelated random movement when a generic Control Roll regains control', () => { + const check: CheckInput = { + id: 'control-recovery', + kind: 'aero-control-recovery', + target: 5, + result: { kind: 'manual', outcome: 'success' }, + readyTurn: 0, + }; + const harness = createHarness([check]); + + apply(harness.unit, [check]); + + expect(harness.unit.setCondition).toHaveBeenCalledWith('out-of-control', false); + expect(harness.unit.setCondition).not.toHaveBeenCalledWith('random-movement', false); + }); + + it('retries a failed Control Roll while an unconscious Aero pilot can still recover', () => { + const check: CheckInput = { + id: 'control-recovery', + kind: 'aero-control-recovery', + target: 5, + result: { kind: 'manual', outcome: 'failed' }, + readyTurn: 0, + }; + const harness = createHarness([check], 'tw', 0, 'Aero', true); + harness.crew.setState('unconscious'); + harness.queuePendingUnitCheck.calls.reset(); + + apply(harness.unit, [check]); + + expect(harness.queuePendingUnitCheck).toHaveBeenCalledOnceWith(jasmine.objectContaining({ + kind: 'aero-control-recovery', + readyTurn: 1, + })); + }); + + it('does not create endless Control recovery rolls after an Aero controller is gone', () => { + const check: CheckInput = { + id: 'control-recovery', + kind: 'aero-control-recovery', + target: 5, + result: { kind: 'manual', outcome: 'failed' }, + readyTurn: 0, + }; + const harness = createHarness([check], 'tw', 0, 'Aero', true); + harness.crew.setState('ejected'); + harness.queuePendingUnitCheck.calls.reset(); + + apply(harness.unit, [check]); + + expect(harness.queuePendingUnitCheck).not.toHaveBeenCalled(); + expect(harness.checks.size).toBe(0); + }); + + it('applies Core seatbelt before consciousness within one submitted list', () => { + const seatbelt: CheckInput = { + id: 'seatbelt', + kind: 'seatbelt', + crewId: 0, + pilotDamageGroup: 'combat:test', + target: 5, + result: { kind: 'manual', outcome: 'failed' }, + }; + const consciousness: CheckInput = { + id: 'consciousness', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: 'combat:test', + target: 5, + result: { kind: 'manual', outcome: 'success' }, + }; + const harness = createHarness([seatbelt, consciousness]); + + apply(harness.unit, [seatbelt, consciousness], true); + + expect(harness.unit.applyPilotHits).toHaveBeenCalledOnceWith(1, 'combat:test', 0); + expect(harness.checks.has('seatbelt')).toBeFalse(); + expect(harness.checks.has('consciousness')).toBeFalse(); + }); + + it('auto-fails a later submitted TW seatbelt after consciousness is lost', () => { + const seatbelt: CheckInput = { + id: 'seatbelt', + kind: 'seatbelt', + crewId: 0, + target: 5, + result: { kind: 'manual', outcome: 'success' }, + }; + const consciousness: CheckInput = { + id: 'consciousness', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: 'combat:test', + target: 5, + result: { kind: 'manual', outcome: 'failed' }, + }; + const harness = createHarness([seatbelt, consciousness], 'tw'); + + apply(harness.unit, [seatbelt, consciousness]); + + expect(harness.crew.setState).toHaveBeenCalledOnceWith('unconscious'); + expect(harness.unit.applyPilotHits).toHaveBeenCalledOnceWith(1, undefined, 0); + expect(harness.checks.has('seatbelt')).toBeFalse(); + }); + + it('pauses later submitted heat effects for a newly-created consciousness interrupt', () => { + const seatbelt: CheckInput = { + id: 'seatbelt', + kind: 'seatbelt', + crewId: 0, + result: { kind: 'automatic', outcome: 'failed' }, + }; + const ammo: CheckInput = { + id: 'ammo', + kind: 'heat-ammo-explosion', + target: 4, + result: { kind: 'manual', outcome: 'success' }, + }; + const harness = createHarness([seatbelt, ammo], 'tw'); + (harness.unit.applyPilotHits as jasmine.Spy).and.callFake(() => { + harness.queuePendingUnitCheck({ + id: 'consciousness:interrupt', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: 'immediate:fall', + target: 5, + }); + }); + + apply(harness.unit, [seatbelt, ammo]); + + expect(harness.unit.applyPilotHits).toHaveBeenCalledOnceWith(1, undefined, 0); + expect(harness.checks.has('seatbelt')).toBeFalse(); + expect(harness.checks.has('ammo')).toBeTrue(); + expect(harness.checks.has('consciousness:interrupt')).toBeTrue(); + }); + + it('continues to later TW heat effects after a submitted consciousness success', () => { + const lifeSupport: CheckInput = { + id: 'life-support', + kind: 'heat-life-support', + result: { kind: 'automatic', outcome: 'failed' }, + hits: 1, + }; + const consciousness: CheckInput = { + id: 'consciousness', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: 'immediate:test', + target: 5, + result: { kind: 'manual', outcome: 'success' }, + }; + const harness = createHarness([lifeSupport, consciousness], 'tw'); + + apply(harness.unit, [lifeSupport, consciousness]); + + expect(harness.unit.applyHeatCrewHits).toHaveBeenCalledOnceWith(1, undefined); + expect(harness.checks.has('life-support')).toBeFalse(); + expect(harness.checks.has('consciousness')).toBeFalse(); + }); + + it('applies Core Heat Phase consciousness before submitted submerged Life Support damage', () => { + const consciousness: CheckInput = { + id: 'heat-consciousness', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: 'turn-closed:heat:end-turn:test', + target: 5, + result: { kind: 'manual', outcome: 'success' }, + }; + const drowning: CheckInput = { + id: 'drowning', + kind: 'life-support-drowning', + result: { kind: 'automatic', outcome: 'failed' }, + hits: 1, + }; + const harness = createHarness([consciousness, drowning]); + + apply(harness.unit, [consciousness, drowning]); + + expect(harness.checks.has('heat-consciousness')).toBeFalse(); + expect(harness.checks.has('drowning')).toBeFalse(); + expect(harness.unit.applyLifeSupportDrowningCrewHits).toHaveBeenCalledOnceWith(1, undefined); + expect(harness.unit.applyHeatCrewHits).not.toHaveBeenCalled(); + }); + + it('applies Core End Phase recovery before submitted submerged Life Support damage', () => { + const recovery: CheckInput = { + id: 'recovery', + kind: 'consciousness-recovery', + crewId: 0, + target: 7, + result: { kind: 'manual', outcome: 'success' }, + readyTurn: 0, + }; + const drowning: CheckInput = { + id: 'drowning', + kind: 'life-support-drowning', + result: { kind: 'automatic', outcome: 'failed' }, + hits: 1, + }; + const harness = createHarness([recovery, drowning]); + harness.crew.setState('unconscious'); + + expect(pendingUnitCheckStage(harness.unit).map(check => check.id)).toEqual(['recovery']); + + apply(harness.unit, [recovery, drowning]); + + expect(harness.crew.setState).toHaveBeenCalledWith('healthy'); + expect(harness.checks.has('recovery')).toBeFalse(); + expect(harness.checks.has('drowning')).toBeFalse(); + expect(harness.unit.applyLifeSupportDrowningCrewHits).toHaveBeenCalledOnceWith(1, undefined); + expect(harness.unit.applyHeatCrewHits).not.toHaveBeenCalled(); + }); + + it('offers only the next consciousness roll per crew member', () => { + const checks: CheckInput[] = [ + { + id: 'pilot-1a', kind: 'consciousness', crewId: 1, + pilotDamageGroup: 'immediate:one', target: 3, + }, + { + id: 'pilot-1b', kind: 'consciousness', crewId: 1, + pilotDamageGroup: 'immediate:one', target: 5, + }, + { + id: 'pilot-2a', kind: 'consciousness', crewId: 2, + pilotDamageGroup: 'immediate:one', target: 7, + }, + ]; + const harness = createHarness(checks, 'tw'); + + expect(pendingUnitCheckStage(harness.unit).map(check => check.id)).toEqual([ + 'pilot-1a', + 'pilot-2a', + ]); + }); + + it('holds a Core combat-phase consciousness roll until the phase is committed', () => { + const open = createHarness([ + { + id: 'consciousness', kind: 'consciousness', crewId: 0, + pilotDamageGroup: 'combat:weapon', target: 5, + }, + ]); + const committed = createHarness([ + { + id: 'consciousness', kind: 'consciousness', crewId: 0, + pilotDamageGroup: 'phase-closed:combat:weapon', target: 5, + }, + ]); + + expect(pendingUnitCheckStage(open.unit)).toEqual([]); + expect(pendingUnitCheckStage(committed.unit).map(check => check.id)).toEqual(['consciousness']); + }); + + it('offers TW consciousness before a simultaneously pending PSR', () => { + const checks: CheckInput[] = [ + { id: 'seatbelt', kind: 'seatbelt', crewId: 0, target: 5 }, + { + id: 'consciousness', kind: 'consciousness', crewId: 0, + pilotDamageGroup: 'combat:weapon', target: 5, + }, + ]; + const harness = createHarness(checks, 'tw', 1); + + expect(pendingUnitCheckStage(harness.unit).map(check => check.id)).toEqual(['consciousness']); + }); + + it('offers immediate TW consciousness through an unfinished critical chain', () => { + const harness = createHarness([ + { + id: 'consciousness', kind: 'consciousness', crewId: 0, + pilotDamageGroup: 'immediate:test', target: 5, + }, + { id: 'recovery', kind: 'consciousness-recovery', crewId: 0, target: 7, readyTurn: 0 }, + ], 'tw', 0, 'Mek', false, 1); + + expect(pendingUnitCheckStage(harness.unit).map(check => check.id)).toEqual(['consciousness']); + }); + + it('offers TW consciousness recovery before a simultaneous Control Roll or PSR', () => { + const checks: CheckInput[] = [ + { id: 'control', kind: 'aero-control-recovery', target: 5, readyTurn: 0 }, + { id: 'recovery', kind: 'consciousness-recovery', crewId: 0, target: 7, readyTurn: 0 }, + ]; + const harness = createHarness(checks, 'tw', 1, 'Aero', true); + + expect(pendingUnitCheckStage(harness.unit).map(check => check.id)).toEqual(['recovery']); + }); + + it('groups eligible recovery rolls across units at the global End Phase stage', () => { + const first = createHarness([ + { id: 'recovery:1', kind: 'consciousness-recovery', crewId: 0, target: 7, readyTurn: 0 }, + ]); + const second = createHarness([ + { id: 'recovery:2', kind: 'consciousness-recovery', crewId: 0, target: 5, readyTurn: 0 }, + ]); + + expect(pendingUnitCheckGroupStage([first.unit, second.unit]).map(entry => entry.check.id)).toEqual([ + 'recovery:1', + 'recovery:2', + ]); + }); +}); diff --git a/src/app/services/unit-check-resolution.service.ts b/src/app/services/unit-check-resolution.service.ts new file mode 100644 index 000000000..c901f7dd5 --- /dev/null +++ b/src/app/services/unit-check-resolution.service.ts @@ -0,0 +1,579 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { inject, Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { + PendingUnitCheckDialogComponent, + type PendingUnitCheckDialogData, +} from '../components/pending-unit-check-dialog/pending-unit-check-dialog.component'; +import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import { getMekLocationLabel } from '../models/entity/types'; +import type { SerializedPendingUnitCheck } from '../models/force-serialization'; +import type { PSRCheck } from '../models/rules/unit-type-rules'; +import { + UNIT_CHECK_CAUSE, + UNIT_CHECK_KIND, + type PendingUnitCheckKind, + type UnitCheckCause, + unitCheckIsCrewOwned, + unitCheckNotificationGroupLabel, +} from '../models/unit-check.model'; +import { + getPreferredHeatAmmoExplosionCandidates, + type HeatAmmoExplosionCandidate, +} from '../utils/heat-effects.util'; +import { applyMekHeatAmmoExplosion } from '../utils/mek-critical-hit.util'; +import { + canRetryAeroControlRecovery, + isAeroControlRecoveryCheck, + isAmmoExplosionCheck, + isHeatControlRecoveryCheck, + isPendingUnitCheckEntry, + pendingCheckReviewEntryKey, + pendingCheckReviewGroupList, + pendingPsrCommittedOutcome, + pendingUnitCheckAutomaticEffect, + pendingUnitCheckAutomationKey, + pendingUnitCheckCrewId, + pendingUnitCheckIsApprovedAutomatic, + pendingUnitCheckIsResolved, + pendingUnitCheckLabel, + type PendingCheckReviewEntry, + type PendingUnitCheckEntry, + type PendingUnitCheckOf, + pendingUnitCheckGroupStage, + pendingUnitCheckOutcome, + pendingUnitCheckUsesPilotAutomation, +} from '../utils/unit-check.util'; +import { uuidv7 } from '../utils/uuid.util'; +import { CBTAutomationToastService } from './cbt-automation-toast.service'; +import { DialogsService } from './dialogs.service'; +import { OptionsService } from './options.service'; +import { ToastService } from './toast.service'; + +interface AutomaticCheckNotification { + readonly check: SerializedPendingUnitCheck; + readonly effect: string | null; +} + +@Injectable({ providedIn: 'root' }) +export class UnitCheckResolutionService { + private readonly dialogs = inject(DialogsService); + private readonly options = inject(OptionsService); + private readonly toasts = inject(ToastService); + private readonly automationToasts = inject(CBTAutomationToastService); + private active = false; + + async open( + units: readonly CBTForceUnit[], + atPhaseEnd = false, + manualResolution = false, + ): Promise { + const uniqueUnits = Array.from(new Map(units.map(unit => [unit.id, unit])).values()); + if (this.active) return false; + this.discardDisabledPilotAutomation(uniqueUnits); + uniqueUnits.forEach(unit => unit.turnState().refreshPendingUnitCheckTargets()); + this.applyAutomaticUnitCheckStages(uniqueUnits, atPhaseEnd, manualResolution); + if (pendingCheckReviewGroupList(uniqueUnits, atPhaseEnd).length === 0) return true; + + this.active = true; + try { + const ref = this.dialogs.createDialog(PendingUnitCheckDialogComponent, { + disableClose: false, + data: { + units: uniqueUnits, + atPhaseEnd, + applyResolved: (entries, forcedPsrFailures) => { + this.applyResolved(entries, atPhaseEnd, forcedPsrFailures); + this.applyAutomaticUnitCheckStages( + uniqueUnits, + atPhaseEnd, + manualResolution, + ); + }, + }, + }); + return (await firstValueFrom(ref.closed)) === true; + } finally { + this.active = false; + } + } + + private discardDisabledPilotAutomation(units: readonly CBTForceUnit[]): void { + if (this.options.cbtAutomationMode('pilotHitsAndConsciousnessCheck') !== 'no') return; + units.forEach(unit => unit.turnState().discardPendingUnitChecks(check => + pendingUnitCheckUsesPilotAutomation(check))); + } + + /** Rolls and applies every currently actionable check whose automation is set to YES. */ + private applyAutomaticUnitCheckStages( + units: readonly CBTForceUnit[], + atPhaseEnd: boolean, + manualResolution: boolean, + ): void { + while (true) { + const stage = pendingUnitCheckGroupStage(units, atPhaseEnd); + if (stage.length === 0) return; + + const automatic = stage.filter(({ check }) => + (!manualResolution + && this.options.cbtAutomationMode(pendingUnitCheckAutomationKey(check)) === 'yes') + || pendingUnitCheckIsApprovedAutomatic(check)); + if (automatic.length === 0) return; + + const resolved = automatic.flatMap(({ unit, check }) => { + const notify = !manualResolution + && this.options.cbtAutomationMode(pendingUnitCheckAutomationKey(check)) === 'yes'; + let current = unit.turnState().getPendingUnitCheck(check.id); + if (!current) return []; + + if (pendingUnitCheckOutcome(current) === undefined) { + if (current.target === undefined) return []; + const dice = [this.rollD6(), this.rollD6()] as const; + const outcome = dice[0] + dice[1] >= current.target ? 'success' : 'failed'; + if (!unit.turnState().setPendingUnitCheckOutcome(current.id, outcome, dice)) return []; + current = unit.turnState().getPendingUnitCheck(current.id); + if (!current) return []; + } + + if (isAmmoExplosionCheck(current) && pendingUnitCheckOutcome(current) === 'failed') { + const choices = getPreferredHeatAmmoExplosionCandidates(unit); + if (choices.length > 0 && !current.selectionId) { + const choice = choices[Math.floor(Math.random() * choices.length)]; + unit.turnState().setPendingUnitCheckSelection(current.id, choice.id); + current = unit.turnState().getPendingUnitCheck(current.id); + if (!current) return []; + } + } + + return pendingUnitCheckIsResolved(unit, current) + ? [{ unit, check: current, notify }] + : []; + }); + if (resolved.length === 0) return; + + const touchedUnits = new Set(); + const notifications = new Map(); + resolved.forEach(({ unit, check, notify }) => { + const effect = this.applyOutcome(unit, check); + if (notify) { + const unitNotifications = notifications.get(unit) ?? []; + unitNotifications.push({ check, effect }); + notifications.set(unit, unitNotifications); + } + touchedUnits.add(unit); + }); + notifications.forEach((results, unit) => this.showAutomaticCheckToasts(unit, results)); + touchedUnits.forEach(unit => unit.turnState().refreshPendingUnitCheckTargets()); + } + } + + private rollD6(): number { + return Math.floor(Math.random() * 6) + 1; + } + + private showAutomaticCheckToast( + unit: CBTForceUnit, + check: SerializedPendingUnitCheck, + effect: string | null, + ): void { + const outcome = pendingUnitCheckOutcome(check); + if (!outcome) return; + this.automationToasts.show( + unit, + `${pendingUnitCheckLabel(check)}: ${this.automaticCheckResultText(check, effect)}`, + outcome === 'success' ? 'success' : 'error', + ); + } + + private showAutomaticCheckToasts( + unit: CBTForceUnit, + results: readonly AutomaticCheckNotification[], + ): void { + const crewGroups = new Map(); + for (const result of results) { + if (!unitCheckIsCrewOwned(result.check.kind)) { + this.showAutomaticCheckToast(unit, result.check, result.effect); + continue; + } + const group = crewGroups.get(result.check.kind) ?? []; + group.push(result); + crewGroups.set(result.check.kind, group); + } + + for (const [kind, group] of crewGroups) { + if (group.length === 1) { + const result = group[0]; + this.showAutomaticCheckToast(unit, result.check, result.effect); + continue; + } + const failed = group.some(({ check }) => pendingUnitCheckOutcome(check) === 'failed'); + const summaries = group.map(({ check, effect }) => { + const crewId = pendingUnitCheckCrewId(check); + const crewName = unit.getCrewMember(crewId)?.getName?.() || `Crew ${crewId + 1}`; + return `${crewName}: ${this.automaticCheckResultText(check, effect)}`; + }); + this.automationToasts.show( + unit, + `${unitCheckNotificationGroupLabel(kind)} — ${summaries.join('; ')}`, + failed ? 'error' : 'success', + ); + } + } + + private automaticCheckResultText( + check: SerializedPendingUnitCheck, + effect: string | null, + ): string { + const outcome = pendingUnitCheckOutcome(check); + if (!outcome) return ''; + const result = check.result; + const detail = result?.kind === 'roll' && check.target !== undefined + ? ` (${result.dice[0] + result.dice[1]} vs ${check.target}+)` + : result?.kind === 'automatic' + ? ' (automatic)' + : ''; + return `${outcome === 'success' ? 'PASSED' : 'FAILED'}${detail}${effect ? ` — ${effect}` : ''}`; + } + + private applyResolved( + entries: readonly PendingCheckReviewEntry[], + atPhaseEnd = false, + forcedPsrFailures: ReadonlySet = new Set(), + ): void { + const units = Array.from(new Map(entries.map(entry => [entry.unit.id, entry.unit])).values()); + const submittedUnitChecks = new Map>(); + const submittedPsrs = new Map>(); + for (const entry of entries) { + const submitted = isPendingUnitCheckEntry(entry) ? submittedUnitChecks : submittedPsrs; + const ids = submitted.get(entry.unit) ?? new Set(); + if (!entry.check.id) continue; + ids.add(entry.check.id); + submitted.set(entry.unit, ids); + } + + // The dialog reviews the full list at once, but effects are still + // applied one rules stage at a time. A newly-created interrupt is not + // in the submitted list, so it pauses the loop and becomes the next row. + while (true) { + const stage = pendingUnitCheckGroupStage(units, atPhaseEnd); + if (stage.length > 0) { + const resolved: PendingUnitCheckEntry[] = []; + for (const { unit, check } of stage) { + const current = unit.turnState().getPendingUnitCheck(check.id); + if (!submittedUnitChecks.get(unit)?.has(check.id) + || !current + || !pendingUnitCheckIsResolved(unit, current)) return; + resolved.push({ unit, check: current }); + } + + const touchedUnits = new Set(); + for (const { unit, check } of resolved) { + this.applyOutcome(unit, check); + touchedUnits.add(unit); + } + touchedUnits.forEach(unit => unit.turnState().refreshPendingUnitCheckTargets()); + continue; + } + + const psrEntries = pendingCheckReviewGroupList(units, atPhaseEnd) + .filter(entry => !isPendingUnitCheckEntry(entry)); + if (psrEntries.length === 0) return; + + const resolvedPsrs = psrEntries.flatMap(entry => { + const checkId = entry.check.id; + if (!checkId || !submittedPsrs.get(entry.unit)?.has(checkId)) return []; + const current = entry.unit.turnState().getPSRChecks() + .find(check => check.id === checkId); + if (!current) return []; + const outcome = forcedPsrFailures.has(pendingCheckReviewEntryKey(entry)) + ? 'failed' + : pendingPsrCommittedOutcome(entry.unit, current) + ?? entry.unit.psrOutcomeSelections()[checkId]; + return outcome ? [{ unit: entry.unit, check: current, outcome }] : []; + }); + if (resolvedPsrs.length !== psrEntries.length) return; + + const appliedIds = new Map>(); + for (const { unit, check, outcome } of resolvedPsrs) { + this.applyPsrOutcome(unit, check, outcome); + const ids = appliedIds.get(unit) ?? new Set(); + ids.add(check.id!); + appliedIds.set(unit, ids); + } + for (const [unit, ids] of appliedIds) { + unit.psrOutcomeSelections.update(current => Object.fromEntries( + Object.entries(current).filter(([id]) => !ids.has(id)), + )); + unit.psrDiceSelections.update(current => Object.fromEntries( + Object.entries(current).filter(([id]) => !ids.has(id)), + )); + unit.turnState().refreshPendingUnitCheckTargets(); + } + } + } + + private applyPsrOutcome( + unit: CBTForceUnit, + check: PSRCheck, + outcome: 'success' | 'failed', + ): void { + if (check.resolution) { + unit.resolveRuleCheck(check.resolution.key, check.resolution.token, outcome); + } else if (check.id) { + unit.turnState().resolvePSRCheck(check.id, outcome); + } + } + + private applyOutcome(unit: CBTForceUnit, check: SerializedPendingUnitCheck): string | null { + const outcome = pendingUnitCheckOutcome(check); + if (!outcome) return null; + let appliedEffect: string | null = null; + + switch (check.kind) { + case UNIT_CHECK_KIND.HEAT_SHUTDOWN: + this.applyShutdown(unit, check, outcome); + break; + case UNIT_CHECK_KIND.SHUTDOWN_RECOVERY: + if (outcome === 'success') unit.setCondition('shutdown', false); + break; + case UNIT_CHECK_KIND.HEAT_AMMO_EXPLOSION: + if (outcome === 'failed') appliedEffect = this.applyAmmoExplosion(unit, check); + break; + case UNIT_CHECK_KIND.HEAT_RANDOM_MOVEMENT: + this.applyRandomMovement(unit, check, outcome); + break; + case UNIT_CHECK_KIND.HEAT_PILOT_DAMAGE: + case UNIT_CHECK_KIND.HEAT_LIFE_SUPPORT: + if (outcome === 'failed') { + appliedEffect = this.pilotHitsAppliedEffect( + unit.applyHeatCrewHits(check.hits, check.pilotDamageGroup), + ); + } + break; + case UNIT_CHECK_KIND.LIFE_SUPPORT_DROWNING: + if (outcome === 'failed') { + appliedEffect = this.pilotHitsAppliedEffect( + unit.applyLifeSupportDrowningCrewHits(check.hits, check.pilotDamageGroup), + ); + } + break; + case UNIT_CHECK_KIND.SEATBELT: + if (outcome === 'failed') { + appliedEffect = this.pilotHitsAppliedEffect( + unit.applyPilotHits(1, check.pilotDamageGroup, check.crewId), + ); + } + break; + case UNIT_CHECK_KIND.CONSCIOUSNESS: + this.applyConsciousness(unit, check, outcome); + break; + case UNIT_CHECK_KIND.CONSCIOUSNESS_RECOVERY: + this.applyConsciousnessRecovery(unit, check, outcome); + break; + case UNIT_CHECK_KIND.AERO_CONTROL_RECOVERY: + this.applyAeroControlRecovery(unit, check, outcome); + break; + } + unit.turnState().discardPendingUnitCheck(check.id); + return appliedEffect ?? pendingUnitCheckAutomaticEffect(check, outcome); + } + + private applyShutdown( + unit: CBTForceUnit, + check: PendingUnitCheckOf, + outcome: 'success' | 'failed', + ): void { + if (outcome === 'success') return; + const newlyShutdown = !unit.getCondition('shutdown'); + unit.setCondition('shutdown', true); + if (newlyShutdown && unit.gameRules.id === 'tw' && unit.getUnit().type === 'Mek') { + unit.turnState().setPSRCheckState({ + ...unit.turnState().getPSRCheckState(), + shutdown: true, + }); + } + if (check.target === undefined + && this.options.cbtAutomationMode('heatEffectsCheck') !== 'yes') { + this.toasts.showToast('Automatic shutdown from heat', 'error'); + } + } + + private applyAmmoExplosion( + unit: CBTForceUnit, + check: PendingUnitCheckOf, + ): string { + const choices = getPreferredHeatAmmoExplosionCandidates(unit); + const candidate = choices.find(choice => choice.id === check.selectionId) + ?? (choices.length === 1 ? choices[0] : undefined); + if (!candidate) { + return 'no eligible ammunition remains; no explosion applied'; + } + + if (unit.getUnit().type === 'Aero') { + return this.applyAeroAmmoExplosion(unit, candidate, check.pilotDamageGroup) + ?? 'no ammunition explosion applied'; + } + + const explosion = applyMekHeatAmmoExplosion(unit, candidate.id, check.pilotDamageGroup); + if (!explosion) return 'no ammunition explosion applied'; + const details = [ + `${explosion.equipment} exploded for ${explosion.rawDamage} damage in ${this.locationLabel(candidate.location)}`, + ]; + if (explosion.pilotHits > 0) details.push(this.pilotHitsAppliedEffect(explosion.pilotHits)); + if (explosion.automaticCritical) { + details.push( + `automatic critical: ${explosion.automaticCritical.equipment} in ${this.locationLabel(explosion.automaticCritical.location)} (slot ${explosion.automaticCritical.slotNumber})`, + ); + } + return details.join('; '); + } + + private applyAeroAmmoExplosion( + unit: CBTForceUnit, + candidate: HeatAmmoExplosionCandidate, + pilotHitGroup?: string, + ): string | null { + const entry = candidate.entry; + if (!entry) return null; + const caseProtected = unit.getInventory().some(item => + item.equipment?.hasAnyFlag(['F_CASE', 'F_CASE_P', 'F_CASE_II']) + && unit.isEquipmentOperational(item)); + const siDamage = Math.max(1, Math.floor(candidate.rawDamage / (caseProtected ? 20 : 10))); + + for (const snapshot of entry.critSlots ?? []) { + const slot = unit.findCurrentCriticalSlot(snapshot); + if (!slot || slot.destroyed) continue; + unit.applyHitToCritSlot(slot, Math.max(1, (slot.armored ? 2 : 1) - (slot.hits ?? 0)), true); + } + entry.setPendingDestroyed(undefined); + entry.setCommittedDestroyed(true); + unit.setInventoryEntry(entry); + unit.addInternalHits('SI', siDamage, true); + const pilotHits = unit.applyInternalExplosionCrewHits(1, pilotHitGroup); + return `${candidate.equipment} exploded for ${candidate.rawDamage} damage in ${this.locationLabel(candidate.location)}; ${siDamage} SI damage applied; ${this.pilotHitsAppliedEffect(pilotHits)}`; + } + + private pilotHitsAppliedEffect(hits: number): string { + return hits > 0 + ? `${hits} pilot hit${hits === 1 ? '' : 's'} applied` + : 'no pilot hits applied'; + } + + private locationLabel(location: string | undefined): string { + return location ? getMekLocationLabel(location) ?? location : 'unknown location'; + } + + private applyRandomMovement( + unit: CBTForceUnit, + check: PendingUnitCheckOf, + outcome: 'success' | 'failed', + ): void { + if (outcome === 'success') { + const hasHeatControlEffect = unit.turnState().getPendingUnitChecks() + .some(isHeatControlRecoveryCheck); + if (check.target === undefined) { + const endedHeatControlEffect = unit.turnState() + .discardPendingUnitChecks(isHeatControlRecoveryCheck) > 0; + if (endedHeatControlEffect) { + unit.setCondition('out-of-control', false); + unit.setCondition('random-movement', false); + } + } else if (hasHeatControlEffect) { + // A successful repeat Avoid Roll suppresses heat-generated + // random movement next turn, but the unit remains out of control + // until its separate Control Roll succeeds. + unit.setCondition('random-movement', false); + } + return; + } + + unit.setCondition('random-movement', true); + unit.setCondition('out-of-control', true); + this.queueAeroControlRecovery(unit, 1, UNIT_CHECK_CAUSE.HEAT_RANDOM_MOVEMENT); + } + + private applyConsciousness( + unit: CBTForceUnit, + check: PendingUnitCheckOf, + outcome: 'success' | 'failed', + ): void { + if (outcome === 'success') return; + const crewId = check.crewId; + const crew = unit.getCrewMember(crewId); + if (!crew || crew.getState() === 'dead' || crew.getState() === 'ejected') return; + + const recoveryDelay = 1; + unit.setCrewState(crewId, 'unconscious', recoveryDelay); + if (unit.rules.getActivePilotCrewId() === null) { + unit.turnState().failPendingPSRChecks(); + if (unit.getUnit().type === 'Aero' && unit.turnState().airborne() !== false) { + unit.setCondition('out-of-control', true); + this.queueAeroControlRecovery(unit, recoveryDelay); + } + } + } + + private applyConsciousnessRecovery( + unit: CBTForceUnit, + check: PendingUnitCheckOf, + outcome: 'success' | 'failed', + ): void { + const crewId = check.crewId; + const crew = unit.getCrewMember(crewId); + if (!crew || crew.getState() !== 'unconscious') return; + if (outcome === 'success') { + unit.setCrewState(crewId, 'healthy'); + return; + } + unit.queueConsciousnessRecovery(crewId, 1, check.id); + } + + private applyAeroControlRecovery( + unit: CBTForceUnit, + check: PendingUnitCheckOf, + outcome: 'success' | 'failed', + ): void { + if (outcome === 'success') { + unit.setCondition('out-of-control', false); + if (check.cause === UNIT_CHECK_CAUSE.HEAT_RANDOM_MOVEMENT) { + unit.setCondition('random-movement', false); + } + return; + } + if (!canRetryAeroControlRecovery(unit)) return; + unit.turnState().queuePendingUnitCheck({ + id: uuidv7(), + kind: UNIT_CHECK_KIND.AERO_CONTROL_RECOVERY, + ...(check.cause ? { cause: check.cause } : {}), + ...this.aeroControlResolution(unit), + readyTurn: unit.turnState().getTurnCounter() + 1, + }); + } + + private queueAeroControlRecovery( + unit: CBTForceUnit, + delay: number, + cause?: UnitCheckCause, + ): boolean { + if (unit.turnState().getPendingUnitChecks().some(isAeroControlRecoveryCheck)) return false; + return unit.turnState().queuePendingUnitCheck({ + id: uuidv7(), + kind: UNIT_CHECK_KIND.AERO_CONTROL_RECOVERY, + ...(cause ? { cause } : {}), + ...this.aeroControlResolution(unit), + readyTurn: unit.turnState().getTurnCounter() + Math.max(1, Math.trunc(delay)), + }); + } + + private aeroControlResolution( + unit: CBTForceUnit, + ): { target: number } | { result: { kind: 'automatic'; outcome: 'failed' } } { + const target = unit.rules.getStandardControlRollTarget(); + return target <= 12 && (unit.rules.isRemoteDrone() || unit.rules.getActivePilotCrewId() !== null) + ? { target } + : { result: { kind: 'automatic', outcome: 'failed' } }; + } +} diff --git a/src/app/services/unit-svg.service.ts b/src/app/services/unit-svg.service.ts index bbbe4a24e..0398cdd9e 100644 --- a/src/app/services/unit-svg.service.ts +++ b/src/app/services/unit-svg.service.ts @@ -633,7 +633,7 @@ export class UnitSvgService { const projection = this.unit.turnState().heatProjection(); const manualTarget = heat.next; const hasUserTarget = manualTarget !== undefined; - const heatAutomationMode = this.unit.automationMode('heatAndDissipation'); + const heatAutomationMode = this.unit.automationMode('heatAndDissipationResolution'); const showProjection = heatAutomationMode !== 'no' && !hasUserTarget && this.unit.turnState().hasPendingHeatResolution(); diff --git a/src/app/utils/heat-effects.util.spec.ts b/src/app/utils/heat-effects.util.spec.ts index f9fcf3908..a8ea827e1 100644 --- a/src/app/utils/heat-effects.util.spec.ts +++ b/src/app/utils/heat-effects.util.spec.ts @@ -3,9 +3,11 @@ // Author: Drake import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import { AmmoEquipment } from '../models/equipment.model'; +import type { CriticalSlot } from '../models/force-serialization'; import { AeroRules } from '../models/rules/aero-rules'; import { MekRules } from '../models/rules/mek-rules'; -import { getHeatEffectDescriptors } from './heat-effects.util'; +import { getHeatEffectDescriptors, getPreferredHeatAmmoExplosionCandidates } from './heat-effects.util'; describe('heat effects', () => { function createUnit(options: { @@ -19,6 +21,7 @@ describe('heat effects', () => { lifeSupportDamaged?: boolean; lifeSupportHits?: number; drowningHits?: number; + critSlots?: CriticalSlot[]; } = {}): CBTForceUnit { const type = options.type ?? 'Mek'; const lifeSupportDamaged = options.lifeSupportDamaged ?? (options.lifeSupportHits ?? 0) > 0; @@ -44,7 +47,8 @@ describe('heat effects', () => { : [], }), getUnit: () => ({ type }), - getCritSlots: () => [], + getCritSlots: () => options.critSlots ?? [], + isInternalLocDestroyed: () => false, getInventory: () => [], } as unknown as CBTForceUnit; } @@ -63,7 +67,44 @@ describe('heat effects', () => { expect(getHeatEffectDescriptors(createUnit(), 30)).toEqual([ jasmine.objectContaining({ kind: 'heat-shutdown', - automaticOutcome: 'failed', + result: { kind: 'automatic', outcome: 'failed' }, + }), + ]); + }); + + it('automatically fails every shutdown Avoid check without a conscious pilot', () => { + for (const heat of [14, 18, 22, 26]) { + const [shutdown] = getHeatEffectDescriptors(createUnit({ activePilotCrewId: null }), heat); + expect(shutdown).withContext(`heat ${heat}`).toEqual(jasmine.objectContaining({ + kind: 'heat-shutdown', + result: { kind: 'automatic', outcome: 'failed' }, + })); + expect(shutdown.target).withContext(`heat ${heat}`).toBeUndefined(); + } + }); + + it('uses a separate recovery roll for a conscious shutdown unit', () => { + expect(getHeatEffectDescriptors(createUnit({ shutdown: true }), 29)).toEqual([ + jasmine.objectContaining({ kind: 'shutdown-recovery', target: 10 }), + ]); + expect(getHeatEffectDescriptors(createUnit({ shutdown: true }), 30)).toEqual([]); + }); + + it('does not offer a restart roll above heat 13 without a conscious pilot', () => { + expect(getHeatEffectDescriptors(createUnit({ + shutdown: true, + activePilotCrewId: null, + }), 26)).toEqual([]); + }); + + it('automatically restarts below heat 14 even without a conscious pilot', () => { + expect(getHeatEffectDescriptors(createUnit({ + shutdown: true, + activePilotCrewId: null, + }), 13)).toEqual([ + jasmine.objectContaining({ + kind: 'shutdown-recovery', + result: { kind: 'automatic', outcome: 'success' }, }), ]); }); @@ -85,17 +126,35 @@ describe('heat effects', () => { ]); }); - it('automatically clears heat shutdown and heat-sourced random movement below their lowest thresholds', () => { + it('keeps independent automatic recovery effects together', () => { expect(getHeatEffectDescriptors(createUnit({ type: 'Aero', shutdown: true, heatControlRecovery: true, }), 4)).toEqual([ - jasmine.objectContaining({ kind: 'heat-shutdown', automaticOutcome: 'success' }), - jasmine.objectContaining({ kind: 'heat-random-movement', automaticOutcome: 'success' }), + jasmine.objectContaining({ + kind: 'shutdown-recovery', + result: { kind: 'automatic', outcome: 'success' }, + }), + jasmine.objectContaining({ + kind: 'heat-random-movement', + result: { kind: 'automatic', outcome: 'success' }, + }), ]); }); + it('selects heat-explosion ammo by damage per shot, then remaining shots, preserving exact ties', () => { + const critSlots = [ + ammoSlot('srm-many', 'SRM 6 Ammo', 6, 2, 100), + ammoSlot('lrm-fewer', 'LRM 15 Ammo', 15, 1, 4), + ammoSlot('lrm-tied-a', 'LRM 15 Ammo A', 15, 1, 8), + ammoSlot('lrm-tied-b', 'LRM 15 Ammo B', 15, 1, 8), + ]; + + expect(getPreferredHeatAmmoExplosionCandidates(createUnit({ critSlots })).map(candidate => candidate.id)) + .toEqual(['lrm-tied-a', 'lrm-tied-b']); + }); + it('does not clear random movement from a non-heat source when heat drops below 5', () => { expect(getHeatEffectDescriptors(createUnit({ type: 'Aero', randomMovement: true }), 4)).toEqual([]); }); @@ -106,7 +165,10 @@ describe('heat effects', () => { it('ends a persisted heat-control recovery when heat drops below 5', () => { expect(getHeatEffectDescriptors(createUnit({ type: 'Aero', heatControlRecovery: true }), 4)).toEqual([ - jasmine.objectContaining({ kind: 'heat-random-movement', automaticOutcome: 'success' }), + jasmine.objectContaining({ + kind: 'heat-random-movement', + result: { kind: 'automatic', outcome: 'success' }, + }), ]); }); @@ -114,7 +176,7 @@ describe('heat effects', () => { expect(getHeatEffectDescriptors(createUnit({ lifeSupportHits: 2 }), 20)).toContain( jasmine.objectContaining({ kind: 'heat-life-support', - automaticOutcome: 'failed', + result: { kind: 'automatic', outcome: 'failed' }, hits: 2, }), ); @@ -131,9 +193,34 @@ describe('heat effects', () => { expect(getHeatEffectDescriptors(createUnit({ drowningHits: 1 }), 0)).toEqual([ jasmine.objectContaining({ kind: 'life-support-drowning', - automaticOutcome: 'failed', + result: { kind: 'automatic', outcome: 'failed' }, hits: 1, }), ]); }); + + function ammoSlot( + id: string, + name: string, + rackSize: number, + damagePerShot: number, + shots: number, + ): CriticalSlot { + const ammo = new AmmoEquipment({ + id, + name, + type: 'ammo', + stats: { explosive: true }, + ammo: { type: 'LRM', rackSize, damagePerShot, shots }, + }); + return { + id, + name, + loc: 'LT', + slot: 0, + totalAmmo: shots, + consumed: 0, + eq: ammo, + }; + } }); diff --git a/src/app/utils/heat-effects.util.ts b/src/app/utils/heat-effects.util.ts index 344bd40c7..38cc093e0 100644 --- a/src/app/utils/heat-effects.util.ts +++ b/src/app/utils/heat-effects.util.ts @@ -7,33 +7,18 @@ import { AmmoEquipment } from '../models/equipment.model'; import type { CriticalSlot } from '../models/force-serialization'; import type { MountedEquipment } from '../models/mounted-equipment.model'; import { resolveHeatScaleEffects } from '../models/rules/heat-management'; +import { + UNIT_CHECK_CAUSE, + UNIT_CHECK_KIND, + type HeatEffectDescriptor, +} from '../models/unit-check.model'; import { ammoExplosionDamagePerShot, ammoRackSize, criticalSlotTotalAmmo, } from './mek-critical-hit.util'; -export type HeatEffectKind = - | 'heat-shutdown' - | 'heat-ammo-explosion' - | 'heat-random-movement' - | 'heat-pilot-damage' - | 'heat-life-support' - | 'life-support-drowning'; - -export interface HeatEffectDescriptor { - readonly kind: HeatEffectKind; - readonly description: string; - readonly target?: number; - readonly automaticOutcome?: 'success' | 'failed'; - readonly hits?: number; -} - -export function isPilotHitHeatEffect(descriptor: HeatEffectDescriptor): boolean { - return descriptor.kind === 'heat-pilot-damage' - || descriptor.kind === 'heat-life-support' - || descriptor.kind === 'life-support-drowning'; -} +export type { HeatEffectDescriptor, HeatEffectKind } from '../models/unit-check.model'; export interface HeatAmmoExplosionCandidate { readonly id: string; @@ -49,50 +34,52 @@ export interface HeatAmmoExplosionCandidate { export function getHeatEffectDescriptors(unit: CBTForceUnit, heat: number): HeatEffectDescriptor[] { const effects = resolveHeatScaleEffects(unit.rules.heatScale, heat); const descriptors: HeatEffectDescriptor[] = []; - if (effects.shutdownTarget !== undefined) { - const consciousPilot = unit.rules.getActivePilotCrewId() !== null; + const shutdown = unit.getCondition('shutdown'); + const consciousPilot = unit.rules.getActivePilotCrewId() !== null; + if (shutdown) { + if (heat < 14) { + descriptors.push({ + kind: UNIT_CHECK_KIND.SHUTDOWN_RECOVERY, + result: { kind: 'automatic', outcome: 'success' }, + }); + } else if (consciousPilot + && effects.shutdownTarget !== undefined + && effects.shutdownTarget <= 12) { + descriptors.push({ + kind: UNIT_CHECK_KIND.SHUTDOWN_RECOVERY, + target: effects.shutdownTarget, + }); + } + } else if (effects.shutdownTarget !== undefined) { descriptors.push({ - kind: 'heat-shutdown', - description: effects.shutdownTarget >= 100 - ? `Automatic shutdown!` - : `Avoid shutdown at heat ${heat}.`, + kind: UNIT_CHECK_KIND.HEAT_SHUTDOWN, ...(effects.shutdownTarget >= 100 || !consciousPilot - ? { automaticOutcome: 'failed' as const } + ? { result: { kind: 'automatic' as const, outcome: 'failed' as const } } : { target: effects.shutdownTarget }), }); - } else if (unit.getCondition('shutdown') && heat < 14) { - descriptors.push({ - kind: 'heat-shutdown', - description: `Heat ${heat} permits an automatic restart.`, - automaticOutcome: 'success', - }); } if (effects.ammoExplosionTarget !== undefined && getHeatAmmoExplosionCandidates(unit).length > 0) { descriptors.push({ - kind: 'heat-ammo-explosion', - description: `Avoid an ammunition explosion at heat ${heat}.`, + kind: UNIT_CHECK_KIND.HEAT_AMMO_EXPLOSION, target: effects.ammoExplosionTarget, }); } if (effects.randomMovementTarget !== undefined) { descriptors.push({ - kind: 'heat-random-movement', - description: `Keep the navigation and piloting systems online at heat ${heat}.`, + kind: UNIT_CHECK_KIND.HEAT_RANDOM_MOVEMENT, target: effects.randomMovementTarget, }); } else if (heat < 5 && unit.turnState().getPendingUnitChecks().some(check => - check.kind === 'aero-control-recovery' - && check.cause === 'heat-random-movement')) { + check.kind === UNIT_CHECK_KIND.AERO_CONTROL_RECOVERY + && check.cause === UNIT_CHECK_CAUSE.HEAT_RANDOM_MOVEMENT)) { descriptors.push({ - kind: 'heat-random-movement', - description: `Heat ${heat} ends the heat-induced random-movement effect.`, - automaticOutcome: 'success', + kind: UNIT_CHECK_KIND.HEAT_RANDOM_MOVEMENT, + result: { kind: 'automatic', outcome: 'success' }, }); } if (effects.pilotDamageTarget !== undefined) { descriptors.push({ - kind: 'heat-pilot-damage', - description: `Avoid pilot damage from heat ${heat}.`, + kind: UNIT_CHECK_KIND.HEAT_PILOT_DAMAGE, target: effects.pilotDamageTarget, hits: 1, }); @@ -100,18 +87,16 @@ export function getHeatEffectDescriptors(unit: CBTForceUnit, heat: number): Heat const lifeSupportHits = unit.rules.heatLifeSupportPilotHits(heat); if (lifeSupportHits > 0) { descriptors.push({ - kind: 'heat-life-support', - description: `Damaged life support (${lifeSupportHits} pilot hit${lifeSupportHits === 1 ? '' : 's'})`, - automaticOutcome: 'failed', + kind: UNIT_CHECK_KIND.HEAT_LIFE_SUPPORT, + result: { kind: 'automatic', outcome: 'failed' }, hits: lifeSupportHits, }); } const drowningHits = unit.rules.submergedLifeSupportPilotHits(); if (drowningHits > 0) { descriptors.push({ - kind: 'life-support-drowning', - description: 'Damaged life support (1 pilot hit).', - automaticOutcome: 'failed', + kind: UNIT_CHECK_KIND.LIFE_SUPPORT_DROWNING, + result: { kind: 'automatic', outcome: 'failed' }, hits: drowningHits, }); } diff --git a/src/app/utils/mek-critical-hit.util.spec.ts b/src/app/utils/mek-critical-hit.util.spec.ts index dd9f196d9..46e47423f 100644 --- a/src/app/utils/mek-critical-hit.util.spec.ts +++ b/src/app/utils/mek-critical-hit.util.spec.ts @@ -38,24 +38,42 @@ import { import { applyMekBlowOff, applyMekCriticalRoll, + applyMekCriticalSlotHit, canApplyMekCriticalHitToSlot, getMekExplosionProtection, + getRollableMekCriticalSlots, hasRollableMekCriticalSlot, + mekCriticalChanceCanBlowOff, mekCriticalChanceModifiers, mekCriticalRollDiceCount, + mekCriticalRollForSlot, mekCriticalRollLocation, mekCriticalSlotIndexForRoll, randomValidMekCriticalRoll, + previewMekCriticalRoll, + previewMekCriticalSlotHit, resolveMekCriticalChance, } from './mek-critical-hit.util'; describe('Mek critical-hit workflow', () => { it('resolves the standard critical chance table including location blow-off', () => { - expect(resolveMekCriticalChance(7, true)).toEqual({ kind: 'none' }); - expect(resolveMekCriticalChance(8, true)).toEqual({ kind: 'critical-hits', count: 1 }); - expect(resolveMekCriticalChance(10, true)).toEqual({ kind: 'critical-hits', count: 2 }); - expect(resolveMekCriticalChance(12, true)).toEqual({ kind: 'blown-off' }); - expect(resolveMekCriticalChance(12, false)).toEqual({ kind: 'critical-hits', count: 3 }); + expect(resolveMekCriticalChance(7, true, false)).toEqual({ kind: 'none' }); + expect(resolveMekCriticalChance(8, true, false)).toEqual({ kind: 'critical-hits', count: 1 }); + expect(resolveMekCriticalChance(10, true, false)).toEqual({ kind: 'critical-hits', count: 2 }); + expect(resolveMekCriticalChance(12, true, false)).toEqual({ kind: 'blown-off' }); + expect(resolveMekCriticalChance(12, false, false)).toEqual({ kind: 'critical-hits', count: 3 }); + expect(resolveMekCriticalChance(13, false, true)).toEqual({ kind: 'critical-hits', count: 3 }); + expect(resolveMekCriticalChance(14, false, true)).toEqual({ kind: 'critical-hits', count: 4 }); + expect(resolveMekCriticalChance(14, true, true)).toEqual({ kind: 'blown-off' }); + }); + + it('allows blow-off results only for a head or limb location', () => { + for (const location of ['HD', 'LA', 'RA', 'LL', 'RL', 'CL', 'FLL', 'FRL', 'RLL', 'RRL']) { + expect(mekCriticalChanceCanBlowOff(location)).withContext(location).toBeTrue(); + } + for (const location of ['CT', 'LT', 'RT', 'UNKNOWN']) { + expect(mekCriticalChanceCanBlowOff(location)).withContext(location).toBeFalse(); + } }); it('uses one die for head and legs and the two-die critical-slot chart elsewhere', () => { @@ -63,12 +81,33 @@ describe('Mek critical-hit workflow', () => { expect(mekCriticalRollDiceCount('LL')).toBe(1); expect(mekCriticalRollDiceCount('LA')).toBe(2); expect(mekCriticalSlotIndexForRoll('LL', [6])).toBe(5); + expect(mekCriticalSlotIndexForRoll('LA', [1, 1])).toBe(0); expect(mekCriticalSlotIndexForRoll('LA', [3, 6])).toBe(5); expect(mekCriticalSlotIndexForRoll('LA', [4, 1])).toBe(6); expect(mekCriticalSlotIndexForRoll('LT', [6, 6])).toBe(11); + expect(mekCriticalRollForSlot('LL', 5)).toEqual([6]); + expect(mekCriticalRollForSlot('LT', 0)).toEqual([1, 1]); + expect(mekCriticalRollForSlot('LT', 8)).toEqual([4, 3]); }); - it('selects valid slots uniformly and returns matching section and slot dice', () => { + it('treats torso and arm dice as section and position selectors, never as a sum', () => { + for (const sectionDie of [1, 2, 3]) { + for (let positionDie = 1; positionDie <= 6; positionDie++) { + expect(mekCriticalSlotIndexForRoll('LT', [sectionDie, positionDie])) + .withContext(`upper section: ${sectionDie}/${positionDie}`) + .toBe(positionDie - 1); + } + } + for (const sectionDie of [4, 5, 6]) { + for (let positionDie = 1; positionDie <= 6; positionDie++) { + expect(mekCriticalSlotIndexForRoll('LT', [sectionDie, positionDie])) + .withContext(`lower section: ${sectionDie}/${positionDie}`) + .toBe(positionDie + 5); + } + } + }); + + it('selects the table section before choosing a valid position within it', () => { const slots: CriticalSlot[] = [ { id: 'first-id', name: 'First', loc: 'LT', slot: 1 }, { id: 'second-id', name: 'Second', loc: 'LT', slot: 8 }, @@ -77,8 +116,33 @@ describe('Mek critical-hit workflow', () => { const firstRandom = randomSequence(0, 0.999); const secondRandom = randomSequence(0.999, 0); - expect(randomValidMekCriticalRoll(unit, 'LT', firstRandom)).toEqual([3, 2]); - expect(randomValidMekCriticalRoll(unit, 'LT', secondRandom)).toEqual([4, 3]); + expect(randomValidMekCriticalRoll(unit, 'LT', firstRandom)).toEqual([1, 2]); + expect(randomValidMekCriticalRoll(unit, 'LT', secondRandom)).toEqual([6, 3]); + }); + + it('keeps a 50/50 section chance when six valid slots oppose one valid slot', () => { + const slots: CriticalSlot[] = Array.from({ length: 7 }, (_, slot) => ({ + id: `slot-${slot}`, + name: `Slot ${slot + 1}`, + loc: 'LT', + slot, + })); + const { unit } = criticalUnit(CORE_2026_GAME_RULES, slots); + + expect(randomValidMekCriticalRoll(unit, 'LT', randomSequence(0.499999, 0.999999))) + .toEqual([3, 6]); + expect(randomValidMekCriticalRoll(unit, 'LT', randomSequence(0.5, 0))) + .toEqual([4, 1]); + }); + + it('uses the only section that still contains a valid slot', () => { + const slots: CriticalSlot[] = [ + { id: 'lower-only', name: 'Lower only', loc: 'LT', slot: 6 }, + ]; + const { unit } = criticalUnit(CORE_2026_GAME_RULES, slots); + + expect(randomValidMekCriticalRoll(unit, 'LT', randomSequence(0, 0))).toEqual([4, 1]); + expect(randomValidMekCriticalRoll(unit, 'LT', randomSequence(0.999999, 0))).toEqual([6, 1]); }); it('excludes unavailable slots while keeping component armor that absorbed one hit rollable', () => { @@ -95,6 +159,10 @@ describe('Mek critical-hit workflow', () => { const { unit } = criticalUnit(CORE_2026_GAME_RULES, slots); expect(hasRollableMekCriticalSlot(unit, 'LL')).toBeTrue(); + expect(getRollableMekCriticalSlots(unit, 'LL').map(slot => slot.id)) + .toEqual(['partial', 'valid-id']); + expect(previewMekCriticalRoll(unit, 'LL', [5])).toBeNull(); + expect(applyMekCriticalRoll(unit, 'LL', [5], true)).toBeNull(); expect(randomValidMekCriticalRoll(unit, 'LL', () => 0)).toEqual([1]); expect(randomValidMekCriticalRoll(unit, 'LL', () => 0.999)).toEqual([6]); slots[5].hits = 1; @@ -128,7 +196,7 @@ describe('Mek critical-hit workflow', () => { } }); - it('empties an exploding ammo bin and applies the Core damage cap with transfer', () => { + it('preserves an exploding ammo bin count and applies the Core damage cap with transfer', () => { const fixture = explodingAmmoUnit(CORE_2026_GAME_RULES); const outcome = applyMekCriticalRoll(fixture.unit, 'LT', [1, 1], true); @@ -137,13 +205,175 @@ describe('Mek critical-hit workflow', () => { expect(outcome?.equipment).toBe('AC/10 Ammo'); expect(outcome?.explosion?.rawDamage).toBe(100); expect(outcome?.explosion?.pilotHits).toBe(1); - expect(fixture.slot.consumed).toBe(10); + expect(fixture.slot.consumed).toBe(0); + expect(fixture.internalHits.get('LT')).toBe(12); + expect(fixture.internalHits.get('CT')).toBe(8); + expect(fixture.armorHits.get('CT-rear')).toBe(12); + expect(fixture.pilotHits()).toBe(1); + }); + + it('reports the total crew hits applied by an internal explosion', () => { + const fixture = explodingAmmoUnit(CORE_2026_GAME_RULES); + const applyCrewHits = spyOn(fixture.unit, 'applyInternalExplosionCrewHits').and.returnValue(3); + + const outcome = applyMekCriticalRoll(fixture.unit, 'LT', [1, 1], true); + + expect(applyCrewHits).toHaveBeenCalledOnceWith(1, undefined); + expect(outcome?.explosion?.pilotHits).toBe(3); + }); + + it('previews explosion damage and CASE transfer without mutating the unit', () => { + const fixture = explodingAmmoUnit(CORE_2026_GAME_RULES); + + const preview = previewMekCriticalRoll(fixture.unit, 'LT', [1, 1]); + + expect(preview?.explosion).toEqual(jasmine.objectContaining({ + timing: 'immediate', + equipment: 'AC/10 Ammo', + rawDamage: 100, + pilotHits: 1, + locations: [ + { location: 'LT', internalDamage: 12, armorDamage: 0, armorRear: true, protection: 'none' }, + { location: 'CT', internalDamage: 8, armorDamage: 12, armorRear: true, protection: 'none' }, + ], + })); + expect(fixture.slot.hits).toBe(0); + expect(fixture.slot.consumed).toBe(0); + expect(fixture.internalHits.size).toBe(0); + expect(fixture.armorHits.size).toBe(0); + expect(fixture.pilotHits()).toBe(0); + }); + + it('includes a linked automatic critical in the explosion preview', () => { + const fixture = riscPulseModuleUnit(); + + const preview = previewMekCriticalRoll(fixture.unit, 'LT', [1, 2]); + + expect(preview?.explosion?.automaticCriticalEquipment).toBe('Medium Laser'); + expect(fixture.moduleSlot.hits ?? 0).toBe(0); + expect(fixture.laserSlot.hits ?? 0).toBe(0); + + const outcome = applyMekCriticalRoll(fixture.unit, 'LT', [1, 2], true); + + expect(outcome?.explosion?.automaticCritical).toEqual(jasmine.objectContaining({ + equipment: 'Medium Laser', + location: 'LT', + slotNumber: 1, + })); + expect(fixture.moduleSlot.hits).toBe(1); + expect(fixture.laserSlot.hits).toBe(1); + }); + + it('applies a rejected ammo critical without consuming ammo or resolving its explosion', () => { + const fixture = explodingAmmoUnit(CORE_2026_GAME_RULES); + + const outcome = applyMekCriticalRoll( + fixture.unit, + 'LT', + [1, 1], + true, + { applyExplosion: false }, + ); + + expect(outcome?.applied).toBeTrue(); + expect(outcome?.explosion).toBeUndefined(); + expect(fixture.slot.hits).toBe(1); + expect(fixture.slot.consumed).toBe(0); + expect(fixture.internalHits.size).toBe(0); + expect(fixture.armorHits.size).toBe(0); + expect(fixture.pilotHits()).toBe(0); + }); + + it('uses the same explosion path for a manually selected critical slot', () => { + const fixture = explodingAmmoUnit(CORE_2026_GAME_RULES); + + const outcome = applyMekCriticalSlotHit(fixture.unit, fixture.slot, true); + + expect(outcome?.explosion?.rawDamage).toBe(100); + expect(fixture.slot.consumed).toBe(0); expect(fixture.internalHits.get('LT')).toBe(12); expect(fixture.internalHits.get('CT')).toBe(8); expect(fixture.armorHits.get('CT-rear')).toBe(12); expect(fixture.pilotHits()).toBe(1); }); + it('detects explosive slots but preserves the normal roll for a destroyed location', () => { + const explosive = explodingAmmoUnit(CORE_2026_GAME_RULES); + explosive.slot.destroying = Date.now(); + const inertSlot: CriticalSlot = { + id: 'heat-sink@LT', + name: 'Heat Sink', + loc: 'LT', + slot: 1, + destroying: Date.now(), + eq: new MiscEquipment({ id: 'HeatSink', name: 'Heat Sink', type: 'misc' }), + }; + explosive.slots.push(inertSlot); + + expect(hasRollableMekCriticalSlot(explosive.unit, 'LT', { transfer: false })).toBeFalse(); + expect(hasRollableMekCriticalSlot(explosive.unit, 'LT', { + transfer: false, + explosiveSlotsOnly: true, + })).toBeTrue(); + expect(randomValidMekCriticalRoll( + explosive.unit, + 'LT', + () => 0, + { transfer: false, explosiveSlotsOnly: true }, + )).toEqual([1, 1]); + expect(previewMekCriticalSlotHit( + explosive.unit, + explosive.slot, + { explosiveSlotsOnly: true }, + )?.explosion?.rawDamage).toBe(100); + expect(randomValidMekCriticalRoll( + explosive.unit, + 'LT', + randomSequence(0, 0.2), + { transfer: false, explosiveSlotsOnly: true }, + )).toEqual([1, 2]); + expect(previewMekCriticalRoll( + explosive.unit, + 'LT', + [1, 2], + { transfer: false, explosiveSlotsOnly: true }, + )).toEqual({ + applied: false, + slotNumber: 2, + equipment: 'Heat Sink', + armoredAbsorption: false, + reason: 'non-explosive', + }); + expect(applyMekCriticalRoll( + explosive.unit, + 'LT', + [1, 2], + true, + { transfer: false, explosiveSlotsOnly: true }, + )).toEqual({ + applied: false, + slotNumber: 2, + equipment: 'Heat Sink', + armoredAbsorption: false, + reason: 'non-explosive', + }); + expect(inertSlot.hits ?? 0).toBe(0); + + const onlyInertSlot: CriticalSlot = { + id: 'heat-sink@LT', + name: 'Heat Sink', + loc: 'LT', + slot: 0, + destroying: Date.now(), + eq: new MiscEquipment({ id: 'HeatSink', name: 'Heat Sink', type: 'misc' }), + }; + const inert = criticalUnit(CORE_2026_GAME_RULES, [onlyInertSlot]); + expect(hasRollableMekCriticalSlot(inert.unit, 'LT', { + transfer: false, + explosiveSlotsOnly: true, + })).toBeFalse(); + }); + it('marks two composite structure pips per point of explosion damage after the Core cap', () => { const { unit, internalHits, armorHits } = explodingAmmoUnit(CORE_2026_GAME_RULES, 'Composite'); @@ -307,12 +537,55 @@ describe('Mek critical-hit workflow', () => { 'Reinforced', undefined, {}, - { armorType: 'Standard', features: ['Primitive Cockpit'] }, + { armorType: 'Hardened', subtype: 'Industrial Mek' }, ); expect(mekCriticalChanceModifiers(tw.unit, 'LT')).toEqual([ { label: 'Reinforced structure', value: -1 }, - { label: 'Primitive Mek', value: 2 }, + { label: 'IndustrialMech', value: 2 }, + { + label: 'Hardened armor in damaged facing', + value: -2, + optional: true, + enabled: true, + }, + ]); + expect(mekCriticalChanceModifiers(tw.unit, 'LT', { hardenedArmorApplies: true })).toContain( + { label: 'Hardened armor in damaged facing', value: -2 }, + ); + expect(mekCriticalChanceModifiers(tw.unit, 'LT', { hardenedArmorApplies: false }) + .some(modifier => modifier.label.includes('Hardened'))).toBeFalse(); + + const primitiveIndustrial = criticalUnit( + TW_GAME_RULES, + [], + [], + null, + undefined, + {}, + { + subtype: 'Industrial Mek', + features: ['Primitive Industrial Cockpit'], + }, + ); + expect(mekCriticalChanceModifiers(primitiveIndustrial.unit, 'LT')).toEqual([ + { label: 'IndustrialMech', value: 2 }, + { label: 'Primitive/RetroTech Mek', value: 2 }, + ]); + }); + + it('applies the Core CASE II modifier only to explosion-triggered critical chances', () => { + const core = criticalUnit(CORE_2026_GAME_RULES, []); + const tw = criticalUnit(TW_GAME_RULES, []); + + expect(mekCriticalChanceModifiers(core.unit, 'LT')).toEqual([]); + expect(mekCriticalChanceModifiers(core.unit, 'LT', { + explosionProtection: 'case-ii', + })).toEqual([ + { label: 'CASE II internal explosion', value: -1 }, ]); + expect(mekCriticalChanceModifiers(tw.unit, 'LT', { + explosionProtection: 'case-ii', + })).toEqual([]); }); it('uses ruleset-specific damage for an intrinsically explosive weapon', () => { @@ -389,7 +662,9 @@ describe('Mek critical-hit workflow', () => { it(`delays a charged PPC/capacitor explosion under ${rules.id} rules`, () => { const ppc = chargedPpcUnit(rules); - const outcome = applyMekCriticalRoll(ppc.unit, 'LT', roll, false); + const outcome = applyMekCriticalRoll(ppc.unit, 'LT', roll, false, { + pilotDamageGroup: 'phase-closed:combat:weapon-phase', + }); expect(outcome?.pendingExplosion).toEqual({ equipment: 'Light PPC + PPC Capacitor', @@ -401,11 +676,31 @@ describe('Mek critical-hit workflow', () => { ppc.handler.beforeEquipmentStateCommit(ppc.weapon); expect(ppc.internalHits.get('LT')).toBe(expectedDamage); + expect(ppc.pilotDamageGroups).toEqual(['phase-closed:combat:weapon-phase']); expect(ppc.slots.every(slot => slot.destroying !== undefined)).toBeTrue(); expect(ppc.capacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); }); } + it('does not queue a delayed component explosion when automation rejects it', () => { + const ppc = chargedPpcUnit(CORE_2026_GAME_RULES); + + const outcome = applyMekCriticalRoll( + ppc.unit, + 'LT', + [1, 1], + false, + { applyExplosion: false }, + ); + ppc.handler.beforeEquipmentStateCommit(ppc.weapon); + + expect(outcome?.pendingExplosion).toBeUndefined(); + expect(outcome?.explosion).toBeUndefined(); + expect(ppc.internalHits.size).toBe(0); + expect(ppc.slots[0].destroying).toBeDefined(); + expect(ppc.slots.slice(1).every(slot => slot.destroying === undefined)).toBeTrue(); + }); + it('cancels a pending PPC/capacitor explosion when the PPC fires in that phase', () => { const ppc = chargedPpcUnit(CORE_2026_GAME_RULES); @@ -519,8 +814,37 @@ describe('Mek critical-hit workflow', () => { caseIISlot.destroyed = 1; expect(getMekExplosionProtection(unit, 'LT')).toBe('case'); caseSlot.destroying = 1; + expect(getMekExplosionProtection(unit, 'LT')).toBe('case'); + caseSlot.destroyed = 1; expect(getMekExplosionProtection(unit, 'LT')).toBe('none'); }); + + it('tags internal damage from an explosion with the protection that resolved it', () => { + const caseIIEquipment = new MiscEquipment({ + id: 'ISCASEII', + name: 'CASE II', + type: 'misc', + flags: ['F_CASE_II'], + }); + const fixture = explodingWeaponUnit(CORE_2026_GAME_RULES); + fixture.unit.getCritSlots().push({ + id: 'caseii@LT', + name: 'CASE II', + loc: 'LT', + slot: 5, + eq: caseIIEquipment, + }); + const addInternalHits = spyOn(fixture.unit, 'addInternalHits').and.callThrough(); + + applyMekCriticalRoll(fixture.unit, 'LT', [1, 1], true); + + expect(addInternalHits).toHaveBeenCalledOnceWith( + 'LT', + 1, + true, + { explosionProtection: 'case-ii' }, + ); + }); }); function explodingAmmoUnit(gameRules: CBTGameRules, structureType: string | null = null) { @@ -542,7 +866,57 @@ function explodingAmmoUnit(gameRules: CBTGameRules, structureType: string | null eq: ammo, }; const slots = [slot]; - return { ...criticalUnit(gameRules, slots, [], structureType), slot }; + return { ...criticalUnit(gameRules, slots, [], structureType), slot, slots }; +} + +function riscPulseModuleUnit() { + const laserEquipment = new WeaponEquipment({ + id: 'MediumLaser', + name: 'Medium Laser', + type: 'weapon', + flags: ['F_ENERGY', 'F_LASER'], + weapon: { damage: 5 }, + }); + const moduleEquipment = new MiscEquipment({ + id: 'RISCLaserPulseModule', + name: 'RISC Laser Pulse Module', + type: 'misc', + flags: ['F_WEAPON_ENHANCEMENT', 'F_RISC_LASER_PULSE_MODULE'], + stats: { explosive: true }, + }); + const laserSlot: CriticalSlot = { + id: 'laser@LT', + name: laserEquipment.name, + loc: 'LT', + slot: 0, + eq: laserEquipment, + }; + const moduleSlot: CriticalSlot = { + id: 'module@LT', + name: moduleEquipment.name, + loc: 'LT', + slot: 1, + eq: moduleEquipment, + }; + const laser = new MountedWeapon({ + owner: null as unknown as CBTForceUnit, + id: 'laser', + name: laserEquipment.name, + equipment: laserEquipment, + critSlots: [laserSlot], + }); + const module = new MountedEquipment({ + owner: null as unknown as CBTForceUnit, + id: 'module', + name: moduleEquipment.name, + equipment: moduleEquipment, + critSlots: [moduleSlot], + parent: laser, + }); + const fixture = criticalUnit(TW_GAME_RULES, [laserSlot, moduleSlot], [laser, module]); + laser.owner = fixture.unit; + module.owner = fixture.unit; + return { ...fixture, laserSlot, moduleSlot }; } function explodingWeaponUnit( @@ -803,13 +1177,18 @@ function criticalUnit( structureType: string | null = null, effectiveWeaponTypes?: (entry: MountedWeapon) => ReadonlySet, inventoryControlRules: InventoryControlRules = {}, - unitData: { readonly armorType?: string; readonly features?: readonly string[] } = {}, + unitData: { + readonly armorType?: string; + readonly features?: readonly string[]; + readonly subtype?: 'BattleMek' | 'Industrial Mek' | 'Quad Industrial Mek'; + } = {}, handlers: readonly EquipmentInteractionHandler[] = [], ): { readonly unit: CBTForceUnit; readonly internalHits: Map; readonly armorHits: Map; readonly pilotHits: () => number; + readonly pilotDamageGroups: readonly string[]; } { const internalPoints = new Map([ ['LA', 10], ['LL', 15], ['LT', 12], ['CT', 31], @@ -821,6 +1200,7 @@ function criticalUnit( const internalHits = new Map(); const armorHits = new Map(); let crewHits = 0; + const pilotDamageGroups: string[] = []; const interactionRegistry = new EquipmentInteractionRegistry(); for (const handler of handlers) interactionRegistry.register(handler); const handlerQueryContext = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); @@ -861,11 +1241,17 @@ function criticalUnit( structureType, armorType: unitData.armorType ?? 'Standard', features: unitData.features ?? [], + subtype: unitData.subtype ?? 'BattleMek', }), getCrewMember: () => ({ getHits: () => crewHits, setHits: (hits: number) => { crewHits = hits; }, }), + applyInternalExplosionCrewHits: (hits: number, group?: string) => { + crewHits += hits; + if (group) pilotDamageGroups.push(group); + return hits; + }, setLocationCondition: () => undefined, applyHitToCritSlot: (critical: CriticalSlot) => { critical.hits = (critical.hits ?? 0) + 1; @@ -888,5 +1274,6 @@ function criticalUnit( internalHits, armorHits, pilotHits: () => crewHits, + pilotDamageGroups, }; } diff --git a/src/app/utils/mek-critical-hit.util.ts b/src/app/utils/mek-critical-hit.util.ts index d24fedd15..ea8a66e08 100644 --- a/src/app/utils/mek-critical-hit.util.ts +++ b/src/app/utils/mek-critical-hit.util.ts @@ -15,7 +15,7 @@ import { getInventoryControlModeAmmoSummary } from './inventory-control.util'; export type MekCriticalChanceResult = | { readonly kind: 'none' } - | { readonly kind: 'critical-hits'; readonly count: 1 | 2 | 3 } + | { readonly kind: 'critical-hits'; readonly count: 1 | 2 | 3 | 4 } | { readonly kind: 'blown-off' }; export interface MekCriticalChanceModifier { @@ -28,11 +28,18 @@ export interface MekCriticalChanceModifier { export interface MekCriticalChanceContext { readonly hardenedArmorApplies?: boolean; + readonly explosionProtection?: MekExplosionProtection; } -export interface MekCriticalRollOptions { +export interface MekCriticalHitOptions { /** Disable transfer when a multi-hit sequence has already selected its target location. */ readonly transfer?: boolean; + /** In a destroyed location, resolve explosive-slot results and discard every other result. */ + readonly explosiveSlotsOnly?: boolean; + /** Whether an explosive critical slot should also resolve its internal explosion. */ + readonly applyExplosion?: boolean; + /** Pilot-damage event retained while this critical chain is paused. */ + readonly pilotDamageGroup?: string; } export type MekBlowOffResult = @@ -55,6 +62,24 @@ export interface MekEquipmentExplosionResult { readonly automaticCritical?: MekAutomaticCriticalResult; } +export interface MekCriticalExplosionPreview { + readonly timing: 'immediate' | 'phase-end'; + readonly equipment: string; + readonly rawDamage: number; + readonly pilotHits: number; + readonly locations: readonly MekExplosionLocationDamage[]; + readonly automaticCriticalEquipment?: string; +} + +export interface MekCriticalHitPreview { + readonly applied: boolean; + readonly slotNumber: number; + readonly equipment: string | null; + readonly armoredAbsorption: boolean; + readonly reason?: MekCriticalRollReason; + readonly explosion?: MekCriticalExplosionPreview; +} + export interface MekAutomaticCriticalResult { readonly equipment: string; readonly location: string; @@ -72,15 +97,22 @@ export interface MekCriticalRollOutcome { readonly slotNumber: number; readonly equipment: string | null; readonly armoredAbsorption: boolean; - readonly reason?: 'empty' | 'unhittable' | 'already-damaged'; + readonly reason?: MekCriticalRollReason; readonly explosion?: MekEquipmentExplosionResult; readonly pendingExplosion?: MekPendingEquipmentExplosion; } +export type MekCriticalRollReason = 'empty' | 'already-damaged' | 'non-explosive'; +export type MekCriticalSlotRollability = + | 'rollable' + | 'empty' + | 'unhittable' + | 'already-damaged' + | 'non-explosive'; + const PENDING_MEK_COMPONENT_EXPLOSION_STATE_KEY = 'pending_mek_component_explosion'; interface PendingMekComponentExplosion { - readonly version: 1; readonly equipment: string; readonly rawDamage: number; readonly pilotHits: number; @@ -90,19 +122,42 @@ interface PendingMekComponentExplosion { readonly triggerLocation?: string; readonly triggerSlot?: number; readonly destroyEntryIds?: readonly string[]; + readonly pilotDamageGroup?: string; } const resolvedComponentExplosions = new WeakMap(); -export function resolveMekCriticalChance(total: number, canBlowOff: boolean): MekCriticalChanceResult { +export function resolveMekCriticalChance( + total: number, + canBlowOff: boolean, + industrialMek: boolean, +): MekCriticalChanceResult { if (total <= 7) return { kind: 'none' }; if (total <= 9) return { kind: 'critical-hits', count: 1 }; if (total <= 11) return { kind: 'critical-hits', count: 2 }; - return canBlowOff ? { kind: 'blown-off' } : { kind: 'critical-hits', count: 3 }; + if (!industrialMek || total <= 13) { + return canBlowOff ? { kind: 'blown-off' } : { kind: 'critical-hits', count: 3 }; + } + return canBlowOff ? { kind: 'blown-off' } : { kind: 'critical-hits', count: 4 }; } export function mekCriticalChanceCanBlowOff(location: string): boolean { - return location === 'HD' || !MEK_TORSO_LOCATIONS.has(location); + return location === 'HD' + || location === 'LA' + || location === 'RA' + || LEG_LOCATIONS.has(location); +} + +export function usesIndustrialMekCriticalChanceTable(unit: CBTForceUnit): boolean { + const subtype = unit.getUnit().subtype; + return unit.gameRules.id === 'tw' + && (subtype === 'Industrial Mek' || subtype === 'Quad Industrial Mek'); +} + +function usesPrimitiveMekCriticalChanceModifier(unit: CBTForceUnit): boolean { + return unit.gameRules.id === 'tw' + && unit.getUnit().features.some(feature => + feature === 'Primitive Cockpit' || feature === 'Primitive Industrial Cockpit'); } export function applyMekBlowOff( @@ -115,7 +170,7 @@ export function applyMekBlowOff( : LEG_LOCATIONS.has(location) ? 'Hip' : null; const armoredActuator = equipment === null ? null : unit.getCritSlots().find(slot => slot.loc === location - && criticalSlotDisplayName(slot) === equipment + && (slot.eq?.name?.trim() || slot.name?.trim()) === equipment && slot.armored === true && (slot.hits ?? 0) === 0 && (slot.pendingHits ?? 0) === 0 @@ -141,19 +196,53 @@ export function mekCriticalSlotIndexForRoll(location: string, results: readonly return isD6Result(die) ? die - 1 : null; } - const groupDie = results[0]; - const slotDie = results[1]; - if (!isD6Result(groupDie) || !isD6Result(slotDie)) return null; - return (groupDie <= 3 ? 0 : 6) + slotDie - 1; + const sectionDie = results[0]; + const positionDie = results[1]; + if (!isD6Result(sectionDie) || !isD6Result(positionDie)) return null; + + // These are sequential selectors, not an arithmetic 2D6 roll: the first die + // chooses slots 1–6 or 7–12, then the second die chooses a position in that section. + const sectionOffset = sectionDie <= 3 ? 0 : 6; + return sectionOffset + positionDie - 1; } export function hasRollableMekCriticalSlot( unit: CBTForceUnit, location: string, - options: MekCriticalRollOptions = {}, + options: MekCriticalHitOptions = {}, ): boolean { + return getRollableMekCriticalSlots(unit, location, options).length > 0; +} + +export function getRollableMekCriticalSlots( + unit: CBTForceUnit, + location: string, + options: MekCriticalHitOptions = {}, +): CriticalSlot[] { const targetLocation = options.transfer === false ? location : mekCriticalRollLocation(unit, location); - return rollableMekCriticalSlotIndexes(unit, targetLocation).length > 0; + return rollableMekCriticalSlotIndexes(unit, targetLocation, options) + .flatMap(slotIndex => unit.getCritSlot(targetLocation, slotIndex) ?? []); +} + +export function mekCriticalSlotRollability( + unit: CBTForceUnit, + location: string, + slotIndex: number, + options: Pick = {}, +): MekCriticalSlotRollability { + const candidate = criticalHitCandidate(unit, location, slotIndex, options); + if (candidate === null) return 'unhittable'; + return 'slot' in candidate ? 'rollable' : candidate.reason ?? 'empty'; +} + +/** Returns one canonical set of table dice faces for a directly selected slot. */ +export function mekCriticalRollForSlot(location: string, slotIndex: number): number[] { + const slotCount = mekCriticalRollDiceCount(location) === 1 ? 6 : 12; + if (!Number.isInteger(slotIndex) || slotIndex < 0 || slotIndex >= slotCount) { + throw new RangeError(`Critical slot index must be between 0 and ${slotCount - 1}.`); + } + if (slotCount === 6) return [slotIndex + 1]; + return [slotIndex < 6 ? 1 : 4, slotIndex % 6 + 1]; } export function mekCriticalChanceModifiers( @@ -167,20 +256,26 @@ export function mekCriticalChanceModifiers( if (structureType.includes('reinforced')) { modifiers.push({ label: 'Reinforced structure', value: -1 }); } - if (unit.gameRules.id === 'tw' - && unitData.features.some(feature => feature === 'Primitive Cockpit' - || feature === 'Primitive Industrial Cockpit')) { - modifiers.push({ label: 'Primitive Mek', value: 2 }); + if (usesIndustrialMekCriticalChanceTable(unit)) { + modifiers.push({ label: 'IndustrialMech', value: 2 }); + } + if (usesPrimitiveMekCriticalChanceModifier(unit)) { + modifiers.push({ label: 'Primitive/RetroTech Mek', value: 2 }); + } + if (unit.gameRules.id === 'core2026' && context.explosionProtection === 'case-ii') { + modifiers.push({ label: 'CASE II internal explosion', value: -1 }); } if (context.hardenedArmorApplies !== false && unitData.armorType.trim().toLowerCase().includes('hardened')) { - const enabled = context.hardenedArmorApplies ?? hasRemainingMekArmor(unit, location); - modifiers.push({ - label: 'Hardened armor in damaged facing', - value: -2, - optional: context.hardenedArmorApplies === undefined, - enabled, - }); + const facingUnknown = context.hardenedArmorApplies === undefined; + modifiers.push(facingUnknown + ? { + label: 'Hardened armor in damaged facing', + value: -2, + optional: true, + enabled: hasRemainingMekArmor(unit, location), + } + : { label: 'Hardened armor in damaged facing', value: -2 }); } return modifiers; } @@ -209,51 +304,199 @@ export function mekCriticalRollLocation(unit: CBTForceUnit, location: string): s } /** - * Selects every valid slot with equal probability, then returns the dice faces - * that represent that slot on the critical-hit table. + * Preserves the critical table's roll order while skipping pointless rerolls: + * choose the 1–6/7–12 section first, then choose a valid position in that section. + * Destroyed locations are the exception: roll the complete table once so + * non-explosive results can be discarded instead of rerolled. */ export function randomValidMekCriticalRoll( unit: CBTForceUnit, location: string, random: () => number = Math.random, - options: MekCriticalRollOptions = {}, + options: MekCriticalHitOptions = {}, ): number[] | null { const targetLocation = options.transfer === false ? location : mekCriticalRollLocation(unit, location); - const validSlots = rollableMekCriticalSlotIndexes(unit, targetLocation); + const validSlots = rollableMekCriticalSlotIndexes(unit, targetLocation, options); if (validSlots.length === 0) return null; - const slotIndex = validSlots[Math.floor(random() * validSlots.length)]; - if (mekCriticalRollDiceCount(targetLocation) === 1) return [slotIndex + 1]; + if (options.explosiveSlotsOnly) { + return Array.from( + { length: mekCriticalRollDiceCount(targetLocation) }, + () => Math.floor(random() * 6) + 1, + ); + } + + if (mekCriticalRollDiceCount(targetLocation) === 1) { + const slotIndex = validSlots[Math.floor(random() * validSlots.length)]; + return [slotIndex + 1]; + } - const sectionStart = slotIndex < 6 ? 1 : 4; - const sectionDie = sectionStart + Math.floor(random() * 3); + const firstSection = validSlots.filter(slotIndex => slotIndex < 6); + const secondSection = validSlots.filter(slotIndex => slotIndex >= 6); + const bothSectionsAvailable = firstSection.length > 0 && secondSection.length > 0; + const sectionDie = bothSectionsAvailable + ? Math.floor(random() * 6) + 1 + : firstSection.length > 0 + ? Math.floor(random() * 3) + 1 + : Math.floor(random() * 3) + 4; + const section = sectionDie <= 3 ? firstSection : secondSection; + const slotIndex = section[Math.floor(random() * section.length)]; return [sectionDie, slotIndex % 6 + 1]; } +interface MekCriticalHitCandidate { + readonly targetLocation: string; + readonly slot: CriticalSlot; + readonly slotNumber: number; + readonly entry: MountedEquipment | null; + readonly equipmentName: string; + readonly criticalHitApplied: boolean; + readonly delayedExplosion: CriticalDelayedExplosion | null; + readonly immediateExplosion: MekImmediateCriticalExplosion | null; +} + +type MekCriticalHitEffects = Pick< + MekCriticalHitCandidate, + 'entry' | 'equipmentName' | 'criticalHitApplied' | 'delayedExplosion' | 'immediateExplosion' +>; + +export function previewMekCriticalRoll( + unit: CBTForceUnit, + location: string, + results: readonly number[], + options: MekCriticalHitOptions = {}, +): MekCriticalHitPreview | null { + const candidate = criticalHitCandidateForRoll(unit, location, results, options); + return candidate ? previewMekCriticalHitCandidate(unit, candidate) : null; +} + +export function previewMekCriticalSlotHit( + unit: CBTForceUnit, + slot: CriticalSlot, + options: Pick = {}, +): MekCriticalHitPreview | null { + const candidate = criticalHitCandidateForSlot(unit, slot, options); + return candidate ? previewMekCriticalHitCandidate(unit, candidate) : null; +} + +/** Returns the canonical mounted-equipment label for a critical slot. */ +export function mekCriticalSlotDisplayName(unit: CBTForceUnit, slot: CriticalSlot): string { + const fallback = slot.eq?.name?.trim() || slot.name?.trim() || 'Equipment'; + return inventoryEntryForCriticalSlot(unit, slot)?.getDisplayName(fallback) ?? fallback; +} + export function applyMekCriticalRoll( unit: CBTForceUnit, location: string, results: readonly number[], consolidateImmediately: boolean, - options: MekCriticalRollOptions = {}, + options: MekCriticalHitOptions = {}, ): MekCriticalRollOutcome | null { + const candidate = criticalHitCandidateForRoll(unit, location, results, options); + return candidate + ? applyMekCriticalHitCandidate( + unit, + candidate, + consolidateImmediately, + options.applyExplosion !== false, + options.pilotDamageGroup, + ) + : null; +} + +export function applyMekCriticalSlotHit( + unit: CBTForceUnit, + slot: CriticalSlot, + consolidateImmediately: boolean, + options: Pick = {}, +): MekCriticalRollOutcome | null { + const candidate = criticalHitCandidateForSlot(unit, slot); + return candidate + ? applyMekCriticalHitCandidate( + unit, + candidate, + consolidateImmediately, + options.applyExplosion !== false, + options.pilotDamageGroup, + ) + : null; +} + +function criticalHitCandidateForRoll( + unit: CBTForceUnit, + location: string, + results: readonly number[], + options: MekCriticalHitOptions, +): MekCriticalHitCandidate | MekCriticalRollOutcome | null { const targetLocation = options.transfer === false ? location : mekCriticalRollLocation(unit, location); const slotIndex = mekCriticalSlotIndexForRoll(targetLocation, results); if (slotIndex === null) return null; + return criticalHitCandidate(unit, targetLocation, slotIndex, options); +} +function criticalHitCandidateForSlot( + unit: CBTForceUnit, + slot: CriticalSlot, + options: Pick = {}, +): MekCriticalHitCandidate | MekCriticalRollOutcome | null { + if (!slot.loc || slot.slot === undefined) return null; + return criticalHitCandidate(unit, slot.loc, slot.slot, options); +} + +function criticalHitCandidate( + unit: CBTForceUnit, + targetLocation: string, + slotIndex: number, + options: Pick = {}, +): MekCriticalHitCandidate | MekCriticalRollOutcome | null { const slotNumber = slotIndex + 1; const slot = unit.getCritSlot(targetLocation, slotIndex); - const rollability = criticalSlotRollability(unit, slot); - if (!slot || rollability !== 'rollable') { + if (!slot) { return { applied: false, slotNumber, - equipment: criticalSlotDisplayName(slot), + equipment: null, armoredAbsorption: false, - reason: rollability === 'rollable' ? 'empty' : rollability, + reason: options.explosiveSlotsOnly ? 'non-explosive' : 'empty', }; } + const explosiveSlotsOnly = options.explosiveSlotsOnly === true; + const baseRollability = criticalSlotRollability(unit, slot, explosiveSlotsOnly); + if (baseRollability === 'unhittable') return null; + if (!explosiveSlotsOnly && baseRollability !== 'rollable') { + return { + applied: false, + slotNumber, + equipment: criticalSlotDisplayName(unit, slot), + armoredAbsorption: false, + reason: baseRollability, + }; + } + + const effects = criticalHitEffects(unit, slot); + const explosive = (effects.delayedExplosion?.rawDamage ?? 0) > 0 + || (effects.immediateExplosion?.rawDamage ?? 0) > 0; + const rollability = explosiveSlotsOnly && !explosive ? 'non-explosive' : baseRollability; + if (rollability !== 'rollable') { + return { + applied: false, + slotNumber, + equipment: criticalSlotDisplayName(unit, slot), + armoredAbsorption: false, + reason: rollability, + }; + } + + return { + targetLocation, + slot, + slotNumber, + ...effects, + }; +} + +function criticalHitEffects(unit: CBTForceUnit, slot: CriticalSlot): MekCriticalHitEffects { const entry = inventoryEntryForCriticalSlot(unit, slot); const equipment = slot.eq; const criticalHitApplied = (slot.hits ?? 0) + 1 > (slot.armored ? 1 : 0); @@ -285,29 +528,95 @@ export function applyMekCriticalRoll( }) : null; - if (equipment instanceof AmmoEquipment && criticalHitApplied) { - slot.consumed = criticalSlotTotalAmmo(unit, slot, equipment); + return { + entry, + equipmentName: criticalSlotDisplayName(unit, slot) ?? 'System', + criticalHitApplied, + delayedExplosion, + immediateExplosion, + }; +} + +function previewMekCriticalHitCandidate( + unit: CBTForceUnit, + candidate: MekCriticalHitCandidate | MekCriticalRollOutcome, +): MekCriticalHitPreview { + if (!('slot' in candidate)) { + return { + applied: candidate.applied, + slotNumber: candidate.slotNumber, + equipment: candidate.equipment, + armoredAbsorption: candidate.armoredAbsorption, + ...(candidate.reason && { reason: candidate.reason }), + }; } - if (delayedExplosion) { - queueMekComponentExplosion(unit, slot, targetLocation, delayedExplosion, consolidateImmediately); + + const explosion = candidate.delayedExplosion + ? previewMekEquipmentExplosion(unit, candidate.targetLocation, { + equipment: candidate.delayedExplosion.equipment, + rawDamage: candidate.delayedExplosion.rawDamage, + pilotHits: unit.gameRules.getMekInternalExplosionPilotHits(), + }, 'phase-end') + : candidate.immediateExplosion && candidate.immediateExplosion.rawDamage > 0 + ? previewMekEquipmentExplosion( + unit, + candidate.targetLocation, + candidate.immediateExplosion, + 'immediate', + ) + : undefined; + + return { + applied: true, + slotNumber: candidate.slotNumber, + equipment: candidate.equipmentName, + armoredAbsorption: !candidate.criticalHitApplied, + ...(explosion && { explosion }), + }; +} + +function applyMekCriticalHitCandidate( + unit: CBTForceUnit, + candidate: MekCriticalHitCandidate | MekCriticalRollOutcome, + consolidateImmediately: boolean, + applyExplosion: boolean, + pilotDamageGroup?: string, +): MekCriticalRollOutcome { + if (!('slot' in candidate)) return candidate; + + const { slot, delayedExplosion, immediateExplosion } = candidate; + if (applyExplosion && delayedExplosion) { + queueMekComponentExplosion( + unit, + slot, + candidate.targetLocation, + delayedExplosion, + consolidateImmediately, + pilotDamageGroup, + ); } unit.applyHitToCritSlot(slot, 1, consolidateImmediately); - const equipmentName = criticalSlotDisplayName(slot) ?? 'System'; - const explosion = delayedExplosion && consolidateImmediately + const explosion = applyExplosion && delayedExplosion && consolidateImmediately ? takeResolvedComponentExplosion(delayedExplosion.source) - : immediateExplosion && immediateExplosion.rawDamage > 0 - ? applyMekEquipmentExplosion(unit, targetLocation, immediateExplosion, consolidateImmediately) + : applyExplosion && immediateExplosion && immediateExplosion.rawDamage > 0 + ? applyMekEquipmentExplosion( + unit, + candidate.targetLocation, + immediateExplosion, + consolidateImmediately, + pilotDamageGroup, + ) : undefined; - const pendingExplosion = delayedExplosion && !consolidateImmediately + const pendingExplosion = applyExplosion && delayedExplosion && !consolidateImmediately ? { equipment: delayedExplosion.equipment, rawDamage: delayedExplosion.rawDamage } : undefined; return { applied: true, - slotNumber, - equipment: equipmentName, - armoredAbsorption: !criticalHitApplied, + slotNumber: candidate.slotNumber, + equipment: candidate.equipmentName, + armoredAbsorption: !candidate.criticalHitApplied, ...(explosion && { explosion }), ...(pendingExplosion && { pendingExplosion }), }; @@ -317,10 +626,19 @@ function isD6Result(value: number | undefined): value is number { return Number.isInteger(value) && value! >= 1 && value! <= 6; } -function rollableMekCriticalSlotIndexes(unit: CBTForceUnit, location: string): number[] { +function rollableMekCriticalSlotIndexes( + unit: CBTForceUnit, + location: string, + options: Pick = {}, +): number[] { const slotCount = mekCriticalRollDiceCount(location) === 1 ? 6 : 12; return Array.from({ length: slotCount }, (_, slotIndex) => slotIndex) - .filter(slotIndex => canApplyMekCriticalHitToSlot(unit, unit.getCritSlot(location, slotIndex))); + .filter(slotIndex => { + const slot = unit.getCritSlot(location, slotIndex); + if (!options.explosiveSlotsOnly) return canApplyMekCriticalHitToSlot(unit, slot); + const candidate = criticalHitCandidate(unit, location, slotIndex, options); + return candidate !== null && 'slot' in candidate; + }); } function locationHadApplicableCriticalSlotAtPhaseStart(unit: CBTForceUnit, location: string): boolean { @@ -349,13 +667,15 @@ export function canApplyMekCriticalHitToSlot(unit: CBTForceUnit, slot: CriticalS function criticalSlotRollability( unit: CBTForceUnit, slot: CriticalSlot | null, + allowStructurallyDestroying = false, ): 'rollable' | 'empty' | 'unhittable' | 'already-damaged' { if (!slot || (!slot.name?.trim() && !slot.eq)) return 'empty'; if (slot.el && slot.el.getAttribute('hittable') !== '1') return 'unhittable'; if ((slot.pendingHits ?? 0) !== 0) return 'already-damaged'; const hits = slot.hits ?? 0; - if (slot.armored && hits < 2 && !slot.destroyed && !slot.destroying) return 'rollable'; + if (slot.armored && hits < 2 && !slot.destroyed + && (!slot.destroying || allowStructurallyDestroying)) return 'rollable'; const repeatable = repeatableSingleSlotCritical(unit, slot); if (repeatable) { return componentCriticalHitCount(unit, repeatable.entry) < repeatable.threshold @@ -363,7 +683,7 @@ function criticalSlotRollability( : 'already-damaged'; } if ((slot.hits ?? 0) > 0 - || !!slot.destroying + || (!!slot.destroying && !allowStructurallyDestroying) || !!slot.destroyed) return 'already-damaged'; return 'rollable'; } @@ -380,13 +700,11 @@ function repeatableSingleSlotCritical( return { entry, threshold }; } -function criticalSlotDisplayName(slot: CriticalSlot | null): string | null { - return slot?.eq?.name?.trim() || slot?.name?.trim() || null; -} - -function isDestroyedCriticalSlot(slot: CriticalSlot): boolean { - const hitsToDestroy = slot.armored ? 2 : 1; - return !!slot.destroyed || !!slot.destroying || (slot.hits ?? 0) >= hitsToDestroy; +function criticalSlotDisplayName(unit: CBTForceUnit, slot: CriticalSlot | null): string | null { + if (!slot) return null; + const fallback = slot.eq?.name?.trim() || slot.name?.trim() || ''; + const entry = inventoryEntryForCriticalSlot(unit, slot); + return entry?.getDisplayName(fallback).trim() || fallback || null; } function inventoryEntryForCriticalSlot(unit: CBTForceUnit, slot: CriticalSlot): MountedEquipment | null { @@ -435,10 +753,10 @@ function queueMekComponentExplosion( sourceLocation: string, plan: CriticalDelayedExplosion, consolidateImmediately: boolean, + pilotDamageGroup?: string, ): void { const source = plan.source; const pending: PendingMekComponentExplosion = { - version: 1, equipment: plan.equipment, rawDamage: plan.rawDamage, pilotHits: unit.gameRules.getMekInternalExplosionPilotHits(), @@ -448,6 +766,7 @@ function queueMekComponentExplosion( ...(trigger.loc && { triggerLocation: trigger.loc }), ...(trigger.slot !== undefined && { triggerSlot: trigger.slot }), ...(plan.destroyEntries && { destroyEntryIds: plan.destroyEntries.map(entry => entry.id) }), + ...(pilotDamageGroup && { pilotDamageGroup }), }; resolvedComponentExplosions.delete(source); if (source.setState(PENDING_MEK_COMPONENT_EXPLOSION_STATE_KEY, JSON.stringify(pending))) { @@ -487,6 +806,7 @@ export function resolvePendingMekComponentExplosion( pilotHits: pending.pilotHits, }, pending.consolidateImmediately, + pending.pilotDamageGroup, ); resolvedComponentExplosions.set(source, explosion); return explosion; @@ -554,7 +874,6 @@ function parsePendingMekComponentExplosion(value: string): PendingMekComponentEx try { const parsed: unknown = JSON.parse(value); if (!isRecord(parsed) - || parsed['version'] !== 1 || typeof parsed['equipment'] !== 'string' || typeof parsed['rawDamage'] !== 'number' || !Number.isFinite(parsed['rawDamage']) @@ -570,7 +889,11 @@ function parsePendingMekComponentExplosion(value: string): PendingMekComponentEx && (!Number.isInteger(parsed['triggerSlot']) || (parsed['triggerSlot'] as number) < 0)) || (parsed['destroyEntryIds'] !== undefined && (!Array.isArray(parsed['destroyEntryIds']) - || parsed['destroyEntryIds'].some(id => typeof id !== 'string' || id.length === 0)))) return null; + || parsed['destroyEntryIds'].some(id => typeof id !== 'string' || id.length === 0))) + || (parsed['pilotDamageGroup'] !== undefined + && (typeof parsed['pilotDamageGroup'] !== 'string' + || parsed['pilotDamageGroup'].length === 0 + || parsed['pilotDamageGroup'].length > 80))) return null; return parsed as unknown as PendingMekComponentExplosion; } catch { return null; @@ -587,17 +910,17 @@ function isPendingCriticalHit(slot: CriticalSlot): boolean { && (slot.hits ?? 0) >= (slot.armored ? 2 : 1); } -function criticalSlotTotalAmmo(unit: CBTForceUnit, slot: CriticalSlot, ammo: AmmoEquipment): number { +export function criticalSlotTotalAmmo(unit: CBTForceUnit, slot: CriticalSlot, ammo: AmmoEquipment): number { const elementTotal = Number(slot.el?.getAttribute('totalAmmo') ?? 0); return Math.max(0, slot.totalAmmo || elementTotal || ammo.getShots(unit.gameRules, unit.getEquipmentRegistry())); } -function ammoRackSize(ammo: AmmoEquipment): number { +export function ammoRackSize(ammo: AmmoEquipment): number { if (ammo.hasFlag('F_CAP_MISSILE') || ammo.ammoType === 'SCREEN_LAUNCHER') return 1; return Math.max(0, ammo.rackSize); } -function ammoExplosionDamagePerShot(ammo: AmmoEquipment): number { +export function ammoExplosionDamagePerShot(ammo: AmmoEquipment): number { if (ammo.ammoType === 'SCREEN_LAUNCHER') return 15; if (ammo.ammoType === 'TASER') return 6; if (ammo.ammoType === 'MEK_MORTAR') { @@ -612,12 +935,31 @@ function ammoExplosionDamagePerShot(ammo: AmmoEquipment): number { ); } -function applyMekEquipmentExplosion( +function previewMekEquipmentExplosion( unit: CBTForceUnit, sourceLocation: string, plan: MekImmediateCriticalExplosion, - consolidateImmediately: boolean, -): MekEquipmentExplosionResult { + timing: MekCriticalExplosionPreview['timing'], +): MekCriticalExplosionPreview { + return { + timing, + equipment: plan.equipment, + rawDamage: plan.rawDamage, + pilotHits: plan.pilotHits, + locations: resolveMekExplosionLocationDamage(unit, sourceLocation, plan), + ...(plan.automaticCriticalEntry && { + automaticCriticalEquipment: plan.automaticCriticalEntry.getDisplayName( + plan.automaticCriticalEntry.name, + ), + }), + }; +} + +function resolveMekExplosionLocationDamage( + unit: CBTForceUnit, + sourceLocation: string, + plan: MekImmediateCriticalExplosion, +): MekExplosionLocationDamage[] { const topology = getTopologyFor(unit.locations?.internal.keys() ?? []); // Explosion rules resolve damage points; composite structure marks two pips per point. const internalDamageMultiplier = isCompositeStructure(unit) ? 2 : 1; @@ -652,8 +994,6 @@ function applyMekEquipmentExplosion( resolution.internalDamage * internalDamageMultiplier, ); - if (armorDamage > 0) unit.addArmorHits(location, armorDamage, resolution.armorRear, consolidateImmediately); - if (internalDamage > 0) unit.addInternalHits(location, internalDamage, consolidateImmediately); locations.push({ location, internalDamage, armorDamage, armorRear: resolution.armorRear, protection }); const appliedInternalDamage = internalDamage / internalDamageMultiplier; @@ -663,10 +1003,42 @@ function applyMekEquipmentExplosion( damage = overflow; } - const pilot = unit.getCrewMember?.(0); - if (pilot && plan.pilotHits > 0) { - pilot.setHits(pilot.getHits() + plan.pilotHits); + return locations; +} + +function applyMekEquipmentExplosion( + unit: CBTForceUnit, + sourceLocation: string, + plan: MekImmediateCriticalExplosion, + consolidateImmediately: boolean, + pilotDamageGroup?: string, +): MekEquipmentExplosionResult { + const locations = resolveMekExplosionLocationDamage(unit, sourceLocation, plan); + for (const damage of locations) { + if (damage.armorDamage > 0) { + unit.addArmorHits( + damage.location, + damage.armorDamage, + damage.armorRear, + consolidateImmediately, + ); + } + if (damage.internalDamage > 0) { + unit.addInternalHits( + damage.location, + damage.internalDamage, + consolidateImmediately, + { + explosionProtection: damage.protection, + ...(pilotDamageGroup && { pilotDamageGroup }), + }, + ); + } } + + const pilotHits = plan.pilotHits > 0 + ? unit.applyInternalExplosionCrewHits(plan.pilotHits, pilotDamageGroup) + : 0; const automaticCritical = plan.automaticCriticalEntry ? applyAutomaticMekCritical(unit, plan.automaticCriticalEntry, consolidateImmediately) : undefined; @@ -674,7 +1046,7 @@ function applyMekEquipmentExplosion( return { equipment: plan.equipment, rawDamage: plan.rawDamage, - pilotHits: plan.pilotHits, + pilotHits, locations, ...(automaticCritical && { automaticCritical }), }; @@ -711,6 +1083,35 @@ export function getMekExplosionProtection(unit: CBTForceUnit, location: string): return 'none'; } +/** Resolves a heat-triggered ammo-bin explosion without pretending it was a critical-hit roll. */ +export function applyMekHeatAmmoExplosion( + unit: CBTForceUnit, + slotId: string, + pilotHitGroup?: string, +): MekEquipmentExplosionResult | null { + const slot = unit.getCritSlots().find(candidate => candidate.id === slotId); + const ammo = slot?.eq; + if (!slot || !(ammo instanceof AmmoEquipment) || !slot.loc || slot.destroyed || slot.destroying) return null; + + const ammoState = explosiveAmmo(unit, slot); + const plan = unit.gameRules.getMekImmediateCriticalExplosion({ + hitEntry: inventoryEntryForCriticalSlot(unit, slot), + hitEquipment: ammo, + remainingAmmoDamage: ammoState.damage, + remainingAmmoShots: ammoState.shots, + mountedCriticalSlots: 1, + previousComponentCriticalHits: 0, + explosiveWeapon: false, + parentOperational: false, + hasUsableAmmo: false, + }); + if (!plan || plan.rawDamage <= 0) return null; + + const hitsRequired = Math.max(1, (slot.armored ? 2 : 1) - (slot.hits ?? 0)); + unit.applyHitToCritSlot(slot, hitsRequired, true); + return applyMekEquipmentExplosion(unit, slot.loc, plan, true, pilotHitGroup); +} + function hasOperationalProtection( unit: CBTForceUnit, location: string, @@ -718,7 +1119,8 @@ function hasOperationalProtection( ): boolean { const slots = unit.getCritSlots().filter(slot => slot.loc === location && slot.eq?.hasAnyFlag([...flags])); - if (slots.length > 0) return slots.some(slot => !isDestroyedCriticalSlot(slot)); + // Pending critical/location damage remains operational until the phase commits. + if (slots.length > 0) return slots.some(slot => !slot.destroyed); return unit.getUnit().comp.some(component => component.eq?.hasAnyFlag([...flags]) diff --git a/src/app/utils/mek-falling.util.spec.ts b/src/app/utils/mek-falling.util.spec.ts new file mode 100644 index 000000000..5d435bc9b --- /dev/null +++ b/src/app/utils/mek-falling.util.spec.ts @@ -0,0 +1,285 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import { + applyMekFallDamage, + mekFallDamage, + mekFallDamageGroups, + resolveMekFallHitLocation, + resolveMekFallOrientation, + type ResolvedMekFallDamageGroup, +} from './mek-falling.util'; + +describe('Mek falling rules', () => { + it('keeps Core facing while selecting rear only on an orientation roll of 1', () => { + expect(resolveMekFallOrientation('core2026', 1)).toEqual(jasmine.objectContaining({ + facingOffset: 0, + facingInstruction: 'Keep the current facing', + hitArc: 'rear', + })); + expect(resolveMekFallOrientation('core2026', 6)).toEqual(jasmine.objectContaining({ + facingOffset: 0, + hitArc: 'front', + })); + }); + + it('uses the Total Warfare facing-after-fall table', () => { + expect([1, 2, 3, 4, 5, 6].map(roll => resolveMekFallOrientation('tw', roll))) + .toEqual([ + jasmine.objectContaining({ facingOffset: 0, hitArc: 'front' }), + jasmine.objectContaining({ facingOffset: 1, hitArc: 'right' }), + jasmine.objectContaining({ facingOffset: 2, hitArc: 'right' }), + jasmine.objectContaining({ facingOffset: 3, hitArc: 'rear' }), + jasmine.objectContaining({ facingOffset: -2, hitArc: 'left' }), + jasmine.objectContaining({ facingOffset: -1, hitArc: 'left' }), + ]); + }); + + it('calculates tonnage and level damage in separate five-point groups', () => { + expect(mekFallDamage(55, 0)).toBe(6); + expect(mekFallDamageGroups(mekFallDamage(55, 0))).toEqual([5, 1]); + expect(mekFallDamage(55, 2)).toBe(18); + expect(mekFallDamageGroups(18)).toEqual([5, 5, 5, 3]); + }); + + it('uses the selected arc and identifies rear torso armor and table criticals', () => { + expect(resolveMekFallHitLocation('biped', 'rear', 2)).toEqual(jasmine.objectContaining({ + rawTableResult: 'CT(C)', + tableLabel: 'CT', + location: 'CT', + locationLabel: 'Center Torso', + rear: true, + critical: true, + })); + expect(resolveMekFallHitLocation('biped', 'left', 3)).toEqual(jasmine.objectContaining({ + location: 'LL', + locationLabel: 'Left Leg', + rear: false, + })); + }); + + it('resolves every quad hit-table abbreviation to a canonical entity location', () => { + expect([3, 4, 9, 10, 11].map(roll => + resolveMekFallHitLocation('quad', 'rear', roll).location, + )).toEqual(['FRL', 'FRL', 'RLL', 'FLL', 'FLL']); + expect([3, 4, 5, 6, 10].map(roll => + resolveMekFallHitLocation('quad', 'left', roll).location, + )).toEqual(['RLL', 'FLL', 'FLL', 'RLL', 'FRL']); + + const unresolved = (['front', 'rear', 'left', 'right'] as const).flatMap(arc => + Array.from({ length: 11 }, (_unused, index) => index + 2) + .map(roll => ({ arc, roll, result: resolveMekFallHitLocation('quad', arc, roll) }))) + .filter(entry => entry.result.location === null || entry.result.locationLabel === null) + .map(entry => `${entry.arc}:${entry.roll}`); + + expect(unresolved).toEqual([]); + }); + + it('resolves the tripod leg subtable with side modifiers', () => { + const pending = resolveMekFallHitLocation('tripod', 'left', 3); + expect(pending.location).toBeNull(); + expect(pending.rawTableResult).toBe('Leg (+1)†'); + expect(pending.tableLabel).toBe('Leg (+1)'); + expect(pending.tripodLegModifier).toBe(1); + + expect(resolveMekFallHitLocation('tripod', 'left', 3, 4)).toEqual(jasmine.objectContaining({ + adjustedTripodLegRoll: 5, + location: 'LL', + })); + expect(resolveMekFallHitLocation('tripod', 'right', 3, 3)).toEqual(jasmine.objectContaining({ + adjustedTripodLegRoll: 2, + location: 'RL', + })); + expect(resolveMekFallHitLocation('tripod', 'front', 5, 3)).toEqual(jasmine.objectContaining({ + adjustedTripodLegRoll: 3, + location: 'CL', + })); + }); + + it('applies armor, internal damage, and normal inward transfer for each group', () => { + const harness = createDamageHarness({ + armor: { LA: 1, LT: 10 }, + internal: { LA: 2, LT: 10, CT: 10 }, + }); + + const result = applyMekFallDamage(harness.unit, [group('LA', 5)], false); + + expect(harness.armorHits).toEqual(new Map([ + ['LA', 1], + ['LT', 2], + ])); + expect(harness.internalHits).toEqual(new Map([['LA', 2]])); + expect(result.appliedDamage).toBe(5); + expect(result.locations.map(entry => entry.location)).toEqual(['LA', 'LT']); + }); + + it('transfers hits from every destroyed quad leg into the correct side torso', () => { + const cases = [ + { arc: 'rear', roll: 3, location: 'FRL', torso: 'RT' }, + { arc: 'rear', roll: 9, location: 'RLL', torso: 'LT' }, + { arc: 'rear', roll: 10, location: 'FLL', torso: 'LT' }, + { arc: 'rear', roll: 5, location: 'RRL', torso: 'RT' }, + ] as const; + + for (const testCase of cases) { + const resolved = resolveMekFallHitLocation('quad', testCase.arc, testCase.roll); + if (resolved.location === null || resolved.locationLabel === null) { + fail(`Expected ${testCase.arc}:${testCase.roll} to resolve`); + continue; + } + const harness = createDamageHarness({ + armor: { [testCase.location]: 0, [testCase.torso]: 10 }, + internal: { FLL: 5, FRL: 5, RLL: 5, RRL: 5, LT: 10, RT: 10, CT: 10, HD: 3 }, + initialInternalHits: { [testCase.location]: 5 }, + }); + + const result = applyMekFallDamage(harness.unit, [{ + ...resolved, + damage: 5, + location: resolved.location, + locationLabel: resolved.locationLabel, + }], false); + + expect(resolved.location).withContext(`${testCase.arc}:${testCase.roll}`).toBe(testCase.location); + expect(harness.armorHits.get(testCase.torso)).withContext(testCase.location).toBe(5); + expect(result.locations.map(entry => entry.location)) + .withContext(testCase.location) + .toEqual([testCase.location, testCase.torso]); + } + }); + + it('halves a group that reaches intact Impact-Resistant Armor, rounding down', () => { + const harness = createDamageHarness({ + armorType: 'Impact-Resistant', + armor: { CT: 10 }, + internal: { CT: 10 }, + }); + + const result = applyMekFallDamage(harness.unit, [group('CT', 5)], true); + + expect(harness.armorHits.get('CT')).toBe(2); + expect(result.appliedDamage).toBe(2); + }); + + it('keeps the minimum one point when Impact-Resistant Armor halves a one-point group', () => { + const harness = createDamageHarness({ + armorType: 'Impact_Resistant', + armor: { CT: 10 }, + internal: { CT: 10 }, + }); + + const result = applyMekFallDamage(harness.unit, [group('CT', 1)], true); + + expect(harness.armorHits.get('CT')).toBe(1); + expect(result.appliedDamage).toBe(1); + }); + + it('queues a table critical in addition to applying internal damage', () => { + const harness = createDamageHarness({ + armor: { CT: 0 }, + internal: { CT: 10 }, + }); + + applyMekFallDamage(harness.unit, [group('CT', 5, false, true)], false); + + expect(harness.addInternalHits).toHaveBeenCalledOnceWith( + 'CT', + 5, + false, + { hardenedArmorApplies: false }, + ); + expect(harness.queueMekCriticalChance).toHaveBeenCalledOnceWith('CT', { + consolidateImmediately: false, + hardenedArmorApplies: false, + throughArmorHitArc: 'front', + }); + }); + + it('retains the hit-table facing for each queued through-armor critical', () => { + const harness = createDamageHarness({ + armor: { LT: 0, RT: 0, 'CT-rear': 0 }, + internal: { LT: 10, RT: 10, CT: 10 }, + }); + + applyMekFallDamage(harness.unit, [ + group('LT', 1, false, true), + group('RT', 1, false, true), + group('CT', 1, true, true), + ], false); + + expect(harness.queueMekCriticalChance.calls.allArgs()).toEqual([ + ['LT', jasmine.objectContaining({ throughArmorHitArc: 'left' })], + ['RT', jasmine.objectContaining({ throughArmorHitArc: 'right' })], + ['CT', jasmine.objectContaining({ throughArmorHitArc: 'rear' })], + ]); + }); + + it('lets remaining Anti-Penetrative Ablation Armor suppress a table critical', () => { + const harness = createDamageHarness({ + armorType: 'Anti_Penetrative_Ablation', + armor: { CT: 10 }, + internal: { CT: 10 }, + }); + + applyMekFallDamage(harness.unit, [group('CT', 5, false, true)], false); + + expect(harness.armorHits.get('CT')).toBe(5); + expect(harness.queueMekCriticalChance).not.toHaveBeenCalled(); + }); +}); + +function group(location: string, damage: number, rear = false, critical = false): ResolvedMekFallDamageGroup { + return { + damage, + hitLocationRoll: 7, + rawTableResult: location, + tableLabel: location, + location, + locationLabel: location, + rear, + critical, + }; +} + +function createDamageHarness(options: { + armor: Readonly>; + internal: Readonly>; + initialInternalHits?: Readonly>; + armorType?: string; +}): { + unit: CBTForceUnit; + armorHits: Map; + internalHits: Map; + addInternalHits: jasmine.Spy; + queueMekCriticalChance: jasmine.Spy; +} { + const armorHits = new Map(); + const internalHits = new Map(Object.entries(options.initialInternalHits ?? {})); + const armorKey = (location: string, rear = false) => rear ? `${location}-rear` : location; + const addInternalHits = jasmine.createSpy('addInternalHits').and.callFake((location: string, hits: number) => { + internalHits.set(location, (internalHits.get(location) ?? 0) + hits); + }); + const queueMekCriticalChance = jasmine.createSpy('queueMekCriticalChance').and.returnValue(true); + const unit = { + locations: { internal: new Map(Object.keys(options.internal).map(location => [location, { loc: location }])) }, + getUnit: () => ({ + type: 'Mek', + subtype: 'BattleMek', + armorType: options.armorType ?? 'Standard Armor', + structureType: 'Standard', + }), + getArmorPoints: (location: string, rear = false) => options.armor[armorKey(location, rear)] ?? 0, + getArmorHits: (location: string, rear = false) => armorHits.get(armorKey(location, rear)) ?? 0, + addArmorHits: (location: string, hits: number, rear = false) => { + const key = armorKey(location, rear); + armorHits.set(key, (armorHits.get(key) ?? 0) + hits); + }, + getInternalPoints: (location: string) => options.internal[location] ?? 0, + getInternalHits: (location: string) => internalHits.get(location) ?? 0, + addInternalHits, + queueMekCriticalChance, + } as unknown as CBTForceUnit; + return { unit, armorHits, internalHits, addInternalHits, queueMekCriticalChance }; +} diff --git a/src/app/utils/mek-falling.util.ts b/src/app/utils/mek-falling.util.ts new file mode 100644 index 000000000..c9053668b --- /dev/null +++ b/src/app/utils/mek-falling.util.ts @@ -0,0 +1,310 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import { getMekLocationLabel, getTopologyFor, MEK_TORSO_LOCATIONS } from '../models/entity/types'; +import type { MekHitArc } from '../models/force-serialization'; +import { + hitLocationCellDefinition, + type MekHitLocationTable, +} from './record-sheet-reference-table'; + +export type MekFallRulesId = 'core2026' | 'tw'; +export type MekFallHitArc = MekHitArc; + +export interface MekFallOrientation { + readonly roll: number; + /** Clockwise hexside change from the facing before the fall. */ + readonly facingOffset: number; + readonly facingInstruction: string; + readonly hitArc: MekFallHitArc; + readonly hitArcLabel: string; + readonly rulesExplanation: string; +} + +export interface MekFallHitLocationResult { + readonly hitLocationRoll: number; + readonly rawTableResult: string; + readonly tableLabel: string; + readonly location: string | null; + readonly locationLabel: string | null; + readonly rear: boolean; + readonly critical: boolean; + readonly tripodLegRoll?: number; + readonly tripodLegModifier?: number; + readonly adjustedTripodLegRoll?: number; +} + +export interface ResolvedMekFallDamageGroup extends MekFallHitLocationResult { + readonly damage: number; + readonly location: string; + readonly locationLabel: string; +} + +export interface AppliedMekFallLocationDamage { + readonly location: string; + readonly rear: boolean; + readonly armorDamage: number; + readonly internalDamage: number; +} + +export interface AppliedMekFallDamage { + readonly appliedDamage: number; + readonly headHits: number; + readonly locations: readonly AppliedMekFallLocationDamage[]; +} + +const TW_ORIENTATION: Readonly>> = { + 1: { + facingOffset: 0, + facingInstruction: 'Keep the current facing', + hitArc: 'front', + hitArcLabel: 'Front', + rulesExplanation: 'The Mek falls forward without changing facing.', + }, + 2: { + facingOffset: 1, + facingInstruction: 'Turn 1 hexside to the right', + hitArc: 'right', + hitArcLabel: 'Right side', + rulesExplanation: 'The new facing is one hexside clockwise.', + }, + 3: { + facingOffset: 2, + facingInstruction: 'Turn 2 hexsides to the right', + hitArc: 'right', + hitArcLabel: 'Right side', + rulesExplanation: 'The new facing is two hexsides clockwise.', + }, + 4: { + facingOffset: 3, + facingInstruction: 'Reverse facing', + hitArc: 'rear', + hitArcLabel: 'Rear', + rulesExplanation: 'The new facing is opposite the old facing.', + }, + 5: { + facingOffset: -2, + facingInstruction: 'Turn 2 hexsides to the left', + hitArc: 'left', + hitArcLabel: 'Left side', + rulesExplanation: 'The new facing is two hexsides counter-clockwise.', + }, + 6: { + facingOffset: -1, + facingInstruction: 'Turn 1 hexside to the left', + hitArc: 'left', + hitArcLabel: 'Left side', + rulesExplanation: 'The new facing is one hexside counter-clockwise.', + }, +}; + +/** Resolves the rulebook-specific 1D6 orientation/damage-arc roll. */ +export function resolveMekFallOrientation(rulesId: MekFallRulesId, roll: number): MekFallOrientation { + assertIntegerInRange(roll, 1, 6, 'Fall orientation roll'); + if (rulesId === 'core2026') { + const rear = roll === 1; + return { + roll, + facingOffset: 0, + facingInstruction: 'Keep the current facing', + hitArc: rear ? 'rear' : 'front', + hitArcLabel: rear ? 'Rear' : 'Front', + rulesExplanation: rear + ? 'The Mek keeps its existing facing; a roll of 1 applies all fall damage to the rear.' + : 'The Mek keeps its existing facing; a roll of 2–6 applies all fall damage to the front.', + }; + } + return { roll, ...TW_ORIENTATION[roll] }; +} + +/** Damage before terrain or armor-specific reductions. */ +export function mekFallDamage(tons: number, levelsFallen = 0): number { + const normalizedTons = Number.isFinite(tons) ? Math.max(0, tons) : 0; + const normalizedLevels = Number.isFinite(levelsFallen) + ? Math.max(0, Math.trunc(levelsFallen)) + : 0; + return Math.ceil(normalizedTons / 10) * (normalizedLevels + 1); +} + +/** Splits a fall into independently located groups of at most 5 damage. */ +export function mekFallDamageGroups(damage: number): readonly number[] { + let remaining = Number.isFinite(damage) ? Math.max(0, Math.trunc(damage)) : 0; + const groups: number[] = []; + while (remaining > 0) { + const group = Math.min(5, remaining); + groups.push(group); + remaining -= group; + } + return groups; +} + +/** Resolves one 2D6 hit-location roll, including the extra tripod leg roll. */ +export function resolveMekFallHitLocation( + table: MekHitLocationTable, + arc: MekFallHitArc, + hitLocationRoll: number, + tripodLegRoll?: number, +): MekFallHitLocationResult { + assertIntegerInRange(hitLocationRoll, 2, 12, 'Fall hit-location roll'); + if (tripodLegRoll !== undefined) { + assertIntegerInRange(tripodLegRoll, 1, 6, 'Tripod leg roll'); + } + + const cell = hitLocationCellDefinition(table, hitLocationRoll, arc); + const tripodLeg = cell.tripodLegModifier !== undefined; + let location: string | null; + let adjustedTripodLegRoll: number | undefined; + + if (tripodLeg) { + if (tripodLegRoll === undefined) { + location = null; + } else { + adjustedTripodLegRoll = tripodLegRoll + cell.tripodLegModifier!; + location = adjustedTripodLegRoll <= 2 ? 'RL' + : adjustedTripodLegRoll <= 4 ? 'CL' : 'LL'; + } + } else { + location = cell.location; + } + + return { + hitLocationRoll, + rawTableResult: cell.tableText, + tableLabel: cell.tableLabel, + location, + locationLabel: getMekLocationLabel(location ?? undefined), + rear: arc === 'rear' && location !== null && MEK_TORSO_LOCATIONS.has(location), + critical: cell.critical, + ...(tripodLegRoll !== undefined && tripodLeg ? { tripodLegRoll } : {}), + ...(cell.tripodLegModifier !== undefined ? { tripodLegModifier: cell.tripodLegModifier } : {}), + ...(adjustedTripodLegRoll !== undefined ? { adjustedTripodLegRoll } : {}), + }; +} + +export function isResolvedMekFallHitLocation( + result: MekFallHitLocationResult, +): result is MekFallHitLocationResult & { readonly location: string; readonly locationLabel: string } { + return result.location !== null && result.locationLabel !== null; +} + +/** Applies resolved fall groups to armor/structure and follows normal Mek damage transfer. */ +export function applyMekFallDamage( + unit: CBTForceUnit, + groups: readonly ResolvedMekFallDamageGroup[], + consolidateImmediately: boolean, +): AppliedMekFallDamage { + const topology = getTopologyFor(unit.locations?.internal.keys() ?? []); + const compositeMultiplier = unit.getUnit().structureType?.trim().toLowerCase() === 'composite' ? 2 : 1; + const armorType = unit.getUnit().armorType; + const impactResistant = isImpactResistantArmor(armorType); + const antiPenetrativeAblation = isAntiPenetrativeAblationArmor(armorType); + const locations: AppliedMekFallLocationDamage[] = []; + let appliedDamage = 0; + let headHits = 0; + + for (const group of groups) { + let damage = Math.max(0, Math.trunc(group.damage)); + let location: string | null = group.location; + const originalRear = group.rear && MEK_TORSO_LOCATIONS.has(group.location); + const originalArmor = Math.max( + 0, + unit.getArmorPoints(group.location, originalRear) + - unit.getArmorHits(group.location, originalRear), + ); + let impactReductionApplied = false; + const visited = new Set(); + let groupAppliedDamage = 0; + + while (location && damage > 0 && !visited.has(location)) { + visited.add(location); + const rear = group.rear && MEK_TORSO_LOCATIONS.has(location); + const remainingArmor = Math.max(0, unit.getArmorPoints(location, rear) - unit.getArmorHits(location, rear)); + if (impactResistant && remainingArmor > 0 && !impactReductionApplied) { + damage = Math.max(1, Math.floor(damage / 2)); + impactReductionApplied = true; + } + + const armorDamage = Math.min(damage, remainingArmor); + if (armorDamage > 0) { + unit.addArmorHits(location, armorDamage, rear, consolidateImmediately); + damage -= armorDamage; + appliedDamage += armorDamage; + groupAppliedDamage += armorDamage; + } + + const remainingInternal = Math.max( + 0, + unit.getInternalPoints(location) - unit.getInternalHits(location), + ); + const internalDamage = Math.min(remainingInternal, damage * compositeMultiplier); + if (internalDamage > 0) { + unit.addInternalHits(location, internalDamage, consolidateImmediately, { + hardenedArmorApplies: remainingArmor > 0, + }); + const damagePoints = internalDamage / compositeMultiplier; + damage = Math.max(0, damage - damagePoints); + appliedDamage += damagePoints; + groupAppliedDamage += damagePoints; + } + + locations.push({ + location, + rear, + armorDamage, + internalDamage, + }); + + if (damage <= 0) break; + location = topology[location as keyof typeof topology]?.transfersTo ?? null; + } + + if (group.location === 'HD' && groupAppliedDamage > 0) headHits++; + if (group.critical && groupAppliedDamage > 0 + && !(antiPenetrativeAblation && originalArmor > 0)) { + unit.queueMekCriticalChance(group.location, { + consolidateImmediately, + hardenedArmorApplies: originalArmor > 0, + throughArmorHitArc: throughArmorHitArc(group), + }); + } + } + + return { appliedDamage, headHits, locations }; +} + +function throughArmorHitArc(group: ResolvedMekFallDamageGroup): MekFallHitArc { + if (group.location === 'LT') return 'left'; + if (group.location === 'RT') return 'right'; + return group.rear ? 'rear' : 'front'; +} + +const IMPACT_RESISTANT_ARMOR_NAMES = new Set([ + 'impact-resistant', + 'impact resistant', + 'impact_resistant', +]); + +const ANTI_PENETRATIVE_ABLATION_ARMOR_NAMES = new Set([ + 'anti-penetrative-ablation', + 'anti penetrative ablation', + 'anti_penetrative_ablation', + 'anti-penetrative-ablative', + 'anti penetrative ablative', + 'anti_penetrative_ablative', +]); + +export function isImpactResistantArmor(armorType: string): boolean { + return IMPACT_RESISTANT_ARMOR_NAMES.has(armorType.trim().toLowerCase()); +} + +function isAntiPenetrativeAblationArmor(armorType: string): boolean { + return ANTI_PENETRATIVE_ABLATION_ARMOR_NAMES.has(armorType.trim().toLowerCase()); +} + +function assertIntegerInRange(value: number, min: number, max: number, label: string): void { + if (!Number.isInteger(value) || value < min || value > max) { + throw new RangeError(`${label} must be an integer from ${min} to ${max}.`); + } +} diff --git a/src/app/utils/pilot-damage-group.util.ts b/src/app/utils/pilot-damage-group.util.ts new file mode 100644 index 000000000..93e35b8ee --- /dev/null +++ b/src/app/utils/pilot-damage-group.util.ts @@ -0,0 +1,67 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { uuidv7 } from './uuid.util'; + +const PHASE_CLOSED_PREFIX = 'phase-closed:'; +const TURN_CLOSED_PREFIX = 'turn-closed:'; +const COMBAT_PREFIX = 'combat:'; +const HEAT_PREFIX = 'heat:'; +const IMMEDIATE_PREFIX = 'immediate:'; + +/** One rules event whose pilot damage shares consciousness-roll timing. */ +export function createPilotDamageGroup( + timing: 'combat' | 'heat' | 'immediate', + scope = uuidv7(), +): string { + return `${timing}:${scope}`; +} + +/** Makes a Core combat-phase consciousness roll actionable. */ +export function closePilotDamagePhase(group: string): string { + return group.startsWith(PHASE_CLOSED_PREFIX) || group.startsWith(TURN_CLOSED_PREFIX) + ? group + : `${PHASE_CLOSED_PREFIX}${group}`; +} + +/** Records that the End Phase containing this damage has already completed. */ +export function closePilotDamageTurn(group: string): string { + return group.startsWith(TURN_CLOSED_PREFIX) + ? group + : `${TURN_CLOSED_PREFIX}${stripCommitPrefix(group)}`; +} + +export function isOpenCombatPilotDamageGroup(group: string | undefined): boolean { + return !!group && group.startsWith(COMBAT_PREFIX); +} + +export function isPilotDamageGroup(group: string | undefined): boolean { + const unwrapped = stripCommitPrefix(group); + return unwrapped.startsWith(COMBAT_PREFIX) + || unwrapped.startsWith(HEAT_PREFIX) + || unwrapped.startsWith(IMMEDIATE_PREFIX); +} + +export function isCombatPilotDamageGroup(group: string | undefined): boolean { + return stripCommitPrefix(group).startsWith(COMBAT_PREFIX); +} + +export function isHeatPilotDamageGroup(group: string | undefined): boolean { + return stripCommitPrefix(group).startsWith(HEAT_PREFIX); +} + +export function isImmediatePilotDamageGroup(group: string | undefined): boolean { + return stripCommitPrefix(group).startsWith(IMMEDIATE_PREFIX); +} + +export function isTurnClosedPilotDamageGroup(group: string | undefined): boolean { + return !!group && group.startsWith(TURN_CLOSED_PREFIX); +} + +function stripCommitPrefix(group: string | undefined): string { + if (!group) return ''; + if (group.startsWith(TURN_CLOSED_PREFIX)) return group.slice(TURN_CLOSED_PREFIX.length); + if (group.startsWith(PHASE_CLOSED_PREFIX)) return group.slice(PHASE_CLOSED_PREFIX.length); + return group; +} diff --git a/src/app/utils/record-sheet-reference-table.ts b/src/app/utils/record-sheet-reference-table.ts index 2465fcfae..d2aa38537 100644 --- a/src/app/utils/record-sheet-reference-table.ts +++ b/src/app/utils/record-sheet-reference-table.ts @@ -5,6 +5,7 @@ import type { UnitSummary } from '../models/unit-summary.model'; import { WeaponEquipment, type Equipment } from '../models/equipment.model'; import type { EquipmentFlag } from '../models/equipment-flags.type'; +import type { MekHitArc } from '../models/force-serialization'; import { CORE_2026_GAME_RULES, TW_GAME_RULES, type PhysicalLocationRow } from '../models/rules/game-rules'; import { clusterHits } from './cluster-hit-table'; @@ -23,6 +24,21 @@ export interface HitLocationRow { readonly rightSide: string; } +export interface HitLocationCellDefinition { + readonly tableText: string; + readonly tableLabel: string; + readonly location: string | null; + readonly critical: boolean; + readonly tripodLegModifier?: -1 | 0 | 1; +} + +interface HitLocationDefinitionRow { + readonly roll: string; + readonly leftSide: HitLocationCellDefinition; + readonly frontRear: HitLocationCellDefinition; + readonly rightSide: HitLocationCellDefinition; +} + export type PhysicalLocationColumn = | 'punchLeftSide' | 'punchFrontRear' @@ -49,52 +65,79 @@ export const REFERENCE_TABLE_NOTE_FLAGS: Readonly>; + +const BIPED_DEFINITION_ROWS: readonly HitLocationDefinitionRow[] = [ + { roll: '2*', leftSide: HIT_LOCATION_CELLS.LEFT_TORSO_CRITICAL, frontRear: HIT_LOCATION_CELLS.CENTER_TORSO_CRITICAL, rightSide: HIT_LOCATION_CELLS.RIGHT_TORSO_CRITICAL }, + { roll: '3', leftSide: HIT_LOCATION_CELLS.LL, frontRear: HIT_LOCATION_CELLS.RA, rightSide: HIT_LOCATION_CELLS.RL }, + { roll: '4', leftSide: HIT_LOCATION_CELLS.LA, frontRear: HIT_LOCATION_CELLS.RA, rightSide: HIT_LOCATION_CELLS.RA }, + { roll: '5', leftSide: HIT_LOCATION_CELLS.LA, frontRear: HIT_LOCATION_CELLS.RL, rightSide: HIT_LOCATION_CELLS.RA }, + { roll: '6', leftSide: HIT_LOCATION_CELLS.LL, frontRear: HIT_LOCATION_CELLS.RT, rightSide: HIT_LOCATION_CELLS.RL }, + { roll: '7', leftSide: HIT_LOCATION_CELLS.LT, frontRear: HIT_LOCATION_CELLS.CT, rightSide: HIT_LOCATION_CELLS.RT }, + { roll: '8', leftSide: HIT_LOCATION_CELLS.CT, frontRear: HIT_LOCATION_CELLS.LT, rightSide: HIT_LOCATION_CELLS.CT }, + { roll: '9', leftSide: HIT_LOCATION_CELLS.RT, frontRear: HIT_LOCATION_CELLS.LL, rightSide: HIT_LOCATION_CELLS.LT }, + { roll: '10', leftSide: HIT_LOCATION_CELLS.RA, frontRear: HIT_LOCATION_CELLS.LA, rightSide: HIT_LOCATION_CELLS.LA }, + { roll: '11', leftSide: HIT_LOCATION_CELLS.RL, frontRear: HIT_LOCATION_CELLS.LA, rightSide: HIT_LOCATION_CELLS.LL }, + { roll: '12', leftSide: HIT_LOCATION_CELLS.HD, frontRear: HIT_LOCATION_CELLS.HD, rightSide: HIT_LOCATION_CELLS.HD }, ]; -const QUAD_ROWS: readonly HitLocationRow[] = [ - { roll: '2*', leftSide: 'LT(C)', frontRear: 'CT(C)', rightSide: 'RT(C)' }, - { roll: '3', leftSide: 'LRL', frontRear: 'RFL', rightSide: 'RRL' }, - { roll: '4', leftSide: 'LFL', frontRear: 'RFL', rightSide: 'RFL' }, - { roll: '5', leftSide: 'LFL', frontRear: 'RRL', rightSide: 'RFL' }, - { roll: '6', leftSide: 'LRL', frontRear: 'RT', rightSide: 'RRL' }, - { roll: '7', leftSide: 'LT', frontRear: 'CT', rightSide: 'RT' }, - { roll: '8', leftSide: 'CT', frontRear: 'LT', rightSide: 'CT' }, - { roll: '9', leftSide: 'RT', frontRear: 'LRL', rightSide: 'LT' }, - { roll: '10', leftSide: 'RFL', frontRear: 'LFL', rightSide: 'LFL' }, - { roll: '11', leftSide: 'RRL', frontRear: 'LFL', rightSide: 'LRL' }, - { roll: '12', leftSide: 'HD', frontRear: 'HD', rightSide: 'HD' }, +const QUAD_DEFINITION_ROWS: readonly HitLocationDefinitionRow[] = [ + { roll: '2*', leftSide: HIT_LOCATION_CELLS.LEFT_TORSO_CRITICAL, frontRear: HIT_LOCATION_CELLS.CENTER_TORSO_CRITICAL, rightSide: HIT_LOCATION_CELLS.RIGHT_TORSO_CRITICAL }, + { roll: '3', leftSide: HIT_LOCATION_CELLS.LEFT_REAR_LEG, frontRear: HIT_LOCATION_CELLS.RIGHT_FRONT_LEG, rightSide: HIT_LOCATION_CELLS.RIGHT_REAR_LEG }, + { roll: '4', leftSide: HIT_LOCATION_CELLS.LEFT_FRONT_LEG, frontRear: HIT_LOCATION_CELLS.RIGHT_FRONT_LEG, rightSide: HIT_LOCATION_CELLS.RIGHT_FRONT_LEG }, + { roll: '5', leftSide: HIT_LOCATION_CELLS.LEFT_FRONT_LEG, frontRear: HIT_LOCATION_CELLS.RIGHT_REAR_LEG, rightSide: HIT_LOCATION_CELLS.RIGHT_FRONT_LEG }, + { roll: '6', leftSide: HIT_LOCATION_CELLS.LEFT_REAR_LEG, frontRear: HIT_LOCATION_CELLS.RT, rightSide: HIT_LOCATION_CELLS.RIGHT_REAR_LEG }, + { roll: '7', leftSide: HIT_LOCATION_CELLS.LT, frontRear: HIT_LOCATION_CELLS.CT, rightSide: HIT_LOCATION_CELLS.RT }, + { roll: '8', leftSide: HIT_LOCATION_CELLS.CT, frontRear: HIT_LOCATION_CELLS.LT, rightSide: HIT_LOCATION_CELLS.CT }, + { roll: '9', leftSide: HIT_LOCATION_CELLS.RT, frontRear: HIT_LOCATION_CELLS.LEFT_REAR_LEG, rightSide: HIT_LOCATION_CELLS.LT }, + { roll: '10', leftSide: HIT_LOCATION_CELLS.RIGHT_FRONT_LEG, frontRear: HIT_LOCATION_CELLS.LEFT_FRONT_LEG, rightSide: HIT_LOCATION_CELLS.LEFT_FRONT_LEG }, + { roll: '11', leftSide: HIT_LOCATION_CELLS.RIGHT_REAR_LEG, frontRear: HIT_LOCATION_CELLS.LEFT_FRONT_LEG, rightSide: HIT_LOCATION_CELLS.LEFT_REAR_LEG }, + { roll: '12', leftSide: HIT_LOCATION_CELLS.HD, frontRear: HIT_LOCATION_CELLS.HD, rightSide: HIT_LOCATION_CELLS.HD }, ]; -const TRIPOD_ROWS: readonly HitLocationRow[] = [ - { roll: '2*', leftSide: 'LT(C)', frontRear: 'CT(C)', rightSide: 'RT(C)' }, - { roll: '3', leftSide: 'Leg (+1)†', frontRear: 'RA', rightSide: 'Leg (-1)†' }, - { roll: '4', leftSide: 'LA', frontRear: 'RA', rightSide: 'RA' }, - { roll: '5', leftSide: 'LA', frontRear: 'Leg†', rightSide: 'RA' }, - { roll: '6', leftSide: 'Leg (+1)†', frontRear: 'RT', rightSide: 'Leg (-1)†' }, - { roll: '7', leftSide: 'LT', frontRear: 'CT', rightSide: 'RT' }, - { roll: '8', leftSide: 'CT', frontRear: 'LT', rightSide: 'CT' }, - { roll: '9', leftSide: 'RT', frontRear: 'Leg†', rightSide: 'LT' }, - { roll: '10', leftSide: 'RA', frontRear: 'LA', rightSide: 'LA' }, - { roll: '11', leftSide: 'Leg (+1)†', frontRear: 'LA', rightSide: 'Leg (-1)†' }, - { roll: '12', leftSide: 'HD', frontRear: 'HD', rightSide: 'HD' }, +const TRIPOD_DEFINITION_ROWS: readonly HitLocationDefinitionRow[] = [ + { roll: '2*', leftSide: HIT_LOCATION_CELLS.LEFT_TORSO_CRITICAL, frontRear: HIT_LOCATION_CELLS.CENTER_TORSO_CRITICAL, rightSide: HIT_LOCATION_CELLS.RIGHT_TORSO_CRITICAL }, + { roll: '3', leftSide: HIT_LOCATION_CELLS.TRIPOD_LEFT_LEG, frontRear: HIT_LOCATION_CELLS.RA, rightSide: HIT_LOCATION_CELLS.TRIPOD_RIGHT_LEG }, + { roll: '4', leftSide: HIT_LOCATION_CELLS.LA, frontRear: HIT_LOCATION_CELLS.RA, rightSide: HIT_LOCATION_CELLS.RA }, + { roll: '5', leftSide: HIT_LOCATION_CELLS.LA, frontRear: HIT_LOCATION_CELLS.TRIPOD_CENTER_LEG, rightSide: HIT_LOCATION_CELLS.RA }, + { roll: '6', leftSide: HIT_LOCATION_CELLS.TRIPOD_LEFT_LEG, frontRear: HIT_LOCATION_CELLS.RT, rightSide: HIT_LOCATION_CELLS.TRIPOD_RIGHT_LEG }, + { roll: '7', leftSide: HIT_LOCATION_CELLS.LT, frontRear: HIT_LOCATION_CELLS.CT, rightSide: HIT_LOCATION_CELLS.RT }, + { roll: '8', leftSide: HIT_LOCATION_CELLS.CT, frontRear: HIT_LOCATION_CELLS.LT, rightSide: HIT_LOCATION_CELLS.CT }, + { roll: '9', leftSide: HIT_LOCATION_CELLS.RT, frontRear: HIT_LOCATION_CELLS.TRIPOD_CENTER_LEG, rightSide: HIT_LOCATION_CELLS.LT }, + { roll: '10', leftSide: HIT_LOCATION_CELLS.RA, frontRear: HIT_LOCATION_CELLS.LA, rightSide: HIT_LOCATION_CELLS.LA }, + { roll: '11', leftSide: HIT_LOCATION_CELLS.TRIPOD_LEFT_LEG, frontRear: HIT_LOCATION_CELLS.LA, rightSide: HIT_LOCATION_CELLS.TRIPOD_RIGHT_LEG }, + { roll: '12', leftSide: HIT_LOCATION_CELLS.HD, frontRear: HIT_LOCATION_CELLS.HD, rightSide: HIT_LOCATION_CELLS.HD }, ]; +const LOCATION_DEFINITION_ROWS: Readonly> = { + biped: BIPED_DEFINITION_ROWS, + quad: QUAD_DEFINITION_ROWS, + tripod: TRIPOD_DEFINITION_ROWS, +}; + const LOCATION_ROWS: Readonly> = { - biped: BIPED_ROWS, - quad: QUAD_ROWS, - tripod: TRIPOD_ROWS, + biped: displayHitLocationRows(BIPED_DEFINITION_ROWS), + quad: displayHitLocationRows(QUAD_DEFINITION_ROWS), + tripod: displayHitLocationRows(TRIPOD_DEFINITION_ROWS), }; export const PHYSICAL_LOCATION_ROWS: readonly PhysicalLocationRow[] = CORE_2026_GAME_RULES.physicalLocationRows; @@ -113,6 +156,45 @@ export function hitLocationRows(table: MekHitLocationTable): readonly HitLocatio return LOCATION_ROWS[table]; } +/** Resolves the exact table text and its rule metadata without parsing display strings. */ +export function hitLocationCellDefinition( + table: MekHitLocationTable, + roll: number, + arc: MekHitArc, +): HitLocationCellDefinition { + const row = LOCATION_DEFINITION_ROWS[table][roll - 2]; + if (!row) throw new RangeError('Hit-location roll must be an integer from 2 to 12.'); + if (arc === 'left') return row.leftSide; + if (arc === 'right') return row.rightSide; + return row.frontRear; +} + +function locationCell( + tableText: string, + location: string, + tableLabel = tableText, + critical = false, +): HitLocationCellDefinition { + return { tableText, tableLabel, location, critical }; +} + +function tripodLegCell( + tableText: string, + tableLabel: string, + tripodLegModifier: -1 | 0 | 1, +): HitLocationCellDefinition { + return { tableText, tableLabel, location: null, critical: false, tripodLegModifier }; +} + +function displayHitLocationRows(rows: readonly HitLocationDefinitionRow[]): readonly HitLocationRow[] { + return rows.map(row => ({ + roll: row.roll, + leftSide: row.leftSide.tableText, + frontRear: row.frontRear.tableText, + rightSide: row.rightSide.tableText, + })); +} + export function referenceTableNotes( table: MekHitLocationTable | undefined, equipment: readonly Pick[] = [], diff --git a/src/app/utils/unit-check.util.ts b/src/app/utils/unit-check.util.ts new file mode 100644 index 000000000..8fbb7ad34 --- /dev/null +++ b/src/app/utils/unit-check.util.ts @@ -0,0 +1,500 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import { getConsciousnessHitCount, getConsciousnessTarget, isCrewMemberAboard, isCrewMemberAvailable } from '../models/crew-member.model'; +import type { SerializedPendingUnitCheck } from '../models/force-serialization'; +import type { CBTAutomationKey } from '../models/options.model'; +import type { PSRCheck } from '../models/rules/unit-type-rules'; +import { + UNIT_CHECK_CAUSE, + UNIT_CHECK_KIND, + unitCheckActionLabel, + unitCheckAutomaticEffect, + unitCheckAutomaticLabel, + unitCheckAutomationKey, + unitCheckDescription, + unitCheckDialogTitle, + unitCheckFailureOutcome, + unitCheckIsApprovedAutomatic, + unitCheckIsCascadeParticipant, + unitCheckIsCrewOwned, + unitCheckLabel, + unitCheckPriority, + unitCheckRequiresAmmoSelection, + unitCheckResolvesBeforePsr, + unitCheckReviewDescription, + unitCheckUsesPilotAutomation, + type PendingUnitCheckKind, + type UnitCheckCause, + type UnitCheckContext, + type UnitCheckOutcome, +} from '../models/unit-check.model'; +import { getPreferredHeatAmmoExplosionCandidates } from './heat-effects.util'; + +export interface PendingUnitCheckEntry { + readonly unit: CBTForceUnit; + readonly check: SerializedPendingUnitCheck; +} + +export interface PendingPsrCheckEntry { + readonly unit: CBTForceUnit; + readonly check: PSRCheck; +} + +export type PendingCheckReviewEntry = PendingUnitCheckEntry | PendingPsrCheckEntry; + +export function isPendingUnitCheckEntry( + entry: PendingCheckReviewEntry, +): entry is PendingUnitCheckEntry { + return 'type' in entry.check && entry.check.type === 'unit-check'; +} + +export function pendingCheckReviewEntryKey(entry: PendingCheckReviewEntry): string { + return `${entry.unit.id}:${isPendingUnitCheckEntry(entry) ? 'unit' : 'psr'}:${entry.check.id ?? ''}`; +} + +function pendingFallCount(unit: CBTForceUnit): number { + return unit.turnState().pendingFallCount?.() ?? unit.pendingFallCount?.() ?? 0; +} + +/** Whether an aerospace unit has, or can later regain, a controller. */ +export function canRetryAeroControlRecovery(unit: CBTForceUnit): boolean { + if (unit.rules.isRemoteDrone() || unit.rules.getActivePilotCrewId() !== null) return true; + return unit.getCrewMembers().some(crew => { + const state = crew.getState(); + return state !== 'dead' && state !== 'ejected' && state !== 'killed'; + }); +} + +/** Rule order for checks whose effects can change later checks. */ +export function pendingUnitCheckPriority( + unit: CBTForceUnit, + check: SerializedPendingUnitCheck, +): number { + return unitCheckPriority(check.kind, !unit.gameRules.aggregatedEndPhaseConsciousRolls); +} + +/** Every generic check that can currently be reviewed, in rules resolution order. */ +export function pendingUnitCheckList( + unit: CBTForceUnit, + atPhaseEnd = false, +): readonly SerializedPendingUnitCheck[] { + return visiblePendingUnitChecks(unit, atPhaseEnd, false); +} + +/** Generic checks visible in one review, including rows that follow an interactive PSR. */ +function pendingUnitCheckReviewList( + unit: CBTForceUnit, + atPhaseEnd = false, +): readonly SerializedPendingUnitCheck[] { + return visiblePendingUnitChecks(unit, atPhaseEnd, true); +} + +function visiblePendingUnitChecks( + unit: CBTForceUnit, + atPhaseEnd: boolean, + includeChecksAfterPsr: boolean, +): readonly SerializedPendingUnitCheck[] { + const turnState = unit.turnState(); + if (pendingFallCount(unit) > 0) return []; + let checks = atPhaseEnd + ? turnState.phaseEndPendingUnitChecks() + : turnState.actionablePendingUnitChecks(); + const hasPendingCriticals = turnState.pendingCriticalChanceCount() > 0 + || turnState.pendingCriticalHitCount() > 0; + if (hasPendingCriticals) { + if (unit.gameRules.aggregatedEndPhaseConsciousRolls) return []; + checks = checks.filter(isConsciousnessCheck); + } + if (!includeChecksAfterPsr && turnState.PSRRollsCount() > 0) { + if (unit.gameRules.aggregatedEndPhaseConsciousRolls) return []; + checks = checks.filter(check => unitCheckResolvesBeforePsr(check.kind)); + } + return [...checks].sort((left, right) => + pendingUnitCheckPriority(unit, left) - pendingUnitCheckPriority(unit, right)); +} + +/** Earliest application stage for one unit. Later rows may still be reviewed together. */ +export function pendingUnitCheckStage( + unit: CBTForceUnit, + atPhaseEnd = false, +): readonly SerializedPendingUnitCheck[] { + const checks = pendingUnitCheckList(unit, atPhaseEnd); + if (checks.length < 2) return checks; + const priority = Math.min(...checks.map(check => pendingUnitCheckPriority(unit, check))); + const stage = checks.filter(check => pendingUnitCheckPriority(unit, check) === priority); + if (!stage[0] || !isConsciousnessCheck(stage[0])) return stage; + + // Per-hit TW checks are sequential: once one fails, later checks for that crew vanish. + const crewIds = new Set(); + return stage.filter(check => { + const crewId = pendingUnitCheckCrewId(check); + if (crewIds.has(crewId)) return false; + crewIds.add(crewId); + return true; + }); +} + +/** Full generic-check review list across a force, globally sorted by rules order. */ +export function pendingUnitCheckGroupList( + units: readonly CBTForceUnit[], + atPhaseEnd = false, +): readonly PendingUnitCheckEntry[] { + return units.flatMap(unit => + pendingUnitCheckList(unit, atPhaseEnd).map(check => ({ unit, check }))) + .sort((left, right) => pendingUnitCheckPriority(left.unit, left.check) + - pendingUnitCheckPriority(right.unit, right.check)); +} + +/** + * Full interactive check review across the force. PSRs occupy their existing + * rules barrier: immediate TW consciousness first, then PSRs, then later rows. + */ +export function pendingCheckReviewGroupList( + units: readonly CBTForceUnit[], + atPhaseEnd = false, +): readonly PendingCheckReviewEntry[] { + if (units.some(unit => pendingFallCount(unit) > 0)) return []; + + const beforePsr: PendingUnitCheckEntry[] = []; + const afterPsr: PendingUnitCheckEntry[] = []; + const psrs: PendingPsrCheckEntry[] = []; + let hasPendingCriticals = false; + + for (const unit of units) { + const turnState = unit.turnState(); + hasPendingCriticals ||= turnState.pendingCriticalChanceCount() > 0 + || turnState.pendingCriticalHitCount() > 0; + const unitPsrs = pendingPsrReviewList(unit); + psrs.push(...unitPsrs.map(check => ({ unit, check }))); + + for (const check of pendingUnitCheckReviewList(unit, atPhaseEnd)) { + const entry = { unit, check }; + if (turnState.PSRRollsCount() === 0) { + beforePsr.push(entry); + } else if (!unit.gameRules.aggregatedEndPhaseConsciousRolls + && unitCheckResolvesBeforePsr(check.kind)) { + beforePsr.push(entry); + } else if (unitPsrs.length > 0) { + afterPsr.push(entry); + } + } + } + + const byUnitCheckPriority = (left: PendingUnitCheckEntry, right: PendingUnitCheckEntry): number => + pendingUnitCheckPriority(left.unit, left.check) + - pendingUnitCheckPriority(right.unit, right.check); + beforePsr.sort(byUnitCheckPriority); + afterPsr.sort(byUnitCheckPriority); + + // Dedicated critical dialogs remain a global barrier before PSRs. + return hasPendingCriticals + ? beforePsr + : [...beforePsr, ...psrs, ...afterPsr]; +} + +export function pendingPsrReviewList(unit: CBTForceUnit): readonly PSRCheck[] { + const turnState = unit.turnState(); + if (turnState.PSRRollsCount() === 0 + || unit.automationMode('pilotSkillCheck') !== 'ask' + || turnState.automaticPSRFailure() + || turnState.actionablePSRRollsCount() === 0) return []; + return turnState.getPSRChecks().filter(check => + check.fallCheck !== undefined + && check.id !== undefined + && pendingPsrCommittedOutcome(unit, check) === undefined); +} + +export function pendingPsrCommittedOutcome( + unit: CBTForceUnit, + check: PSRCheck, +): 'success' | 'failed' | undefined { + if (check.resolution) { + const current = unit.getRuleCheck(check.resolution.key); + return !current + || current.token !== check.resolution.token + || current.status === 'pending' + ? undefined + : current.status; + } + return check.id ? unit.turnState().getPSROutcome(check.id) : undefined; +} + +/** Earliest application stage across a force. Equal-priority checks resolve together. */ +export function pendingUnitCheckGroupStage( + units: readonly CBTForceUnit[], + atPhaseEnd = false, +): readonly PendingUnitCheckEntry[] { + const candidates = units.flatMap(unit => + pendingUnitCheckStage(unit, atPhaseEnd).map(check => ({ unit, check }))); + if (candidates.length < 2) return candidates; + const priority = Math.min(...candidates.map(entry => pendingUnitCheckPriority(entry.unit, entry.check))); + return candidates.filter(entry => pendingUnitCheckPriority(entry.unit, entry.check) === priority); +} + +export function pendingUnitCheckOutcome(check: SerializedPendingUnitCheck): UnitCheckOutcome | undefined { + if (!check.result) return undefined; + if (check.result.kind !== 'roll') return check.result.outcome; + if (check.target === undefined) return undefined; + return check.result.dice[0] + check.result.dice[1] >= check.target ? 'success' : 'failed'; +} + +export function pendingUnitCheckIsAutomatic(check: SerializedPendingUnitCheck): boolean { + return check.result?.kind === 'automatic'; +} + +export interface UnitCheckDetails { + readonly kind: PendingUnitCheckKind; + readonly target?: number; + readonly hits?: number; + readonly cause?: UnitCheckCause; + readonly crewId?: number; +} + +export function pendingUnitCheckContext( + unit: CBTForceUnit, + check: UnitCheckDetails, + heat = unit.getHeat().current, +): UnitCheckContext { + const crew = unitCheckIsCrewOwned(check.kind) + ? unit.getCrewMember(check.crewId ?? 0) + : undefined; + const crewName = crew && unit.getCrewMembers().length > 1 + ? crew.getName() || `Crew ${crew.getId() + 1}` + : undefined; + return unitCheckContext(check, heat, crewName, crew?.getHits() ?? 0); +} + +export function pendingUnitCheckLabel(check: UnitCheckDetails, review = false): string { + return unitCheckLabel(check.kind, review); +} + +export function pendingUnitCheckDescription( + unit: CBTForceUnit, + check: UnitCheckDetails, + heat?: number, +): string { + return unitCheckDescription(check.kind, pendingUnitCheckContext(unit, check, heat)); +} + +export function pendingUnitCheckReviewDescription( + unit: CBTForceUnit, + check: UnitCheckDetails, + heat?: number, +): string { + return unitCheckReviewDescription(check.kind, pendingUnitCheckContext(unit, check, heat)); +} + +/** Concise consequence shown separately from the descriptive check context. */ +export function pendingUnitCheckFailureOutcome(check: UnitCheckDetails): string { + return unitCheckFailureOutcome(check.kind, unitCheckContext(check)); +} + +export function pendingUnitCheckActionLabel( + check: UnitCheckDetails, + outcome: UnitCheckOutcome, +): string { + return unitCheckActionLabel(check.kind, outcome); +} + +export function pendingUnitCheckAutomaticLabel( + check: UnitCheckDetails, + outcome: UnitCheckOutcome, +): string { + return unitCheckAutomaticLabel(check.kind, outcome); +} + +export function pendingUnitCheckAutomaticEffect( + check: UnitCheckDetails, + outcome: UnitCheckOutcome, +): string | null { + return unitCheckAutomaticEffect(check.kind, unitCheckContext(check), outcome); +} + +export function pendingUnitCheckAutomationKey(check: UnitCheckDetails): CBTAutomationKey { + return unitCheckAutomationKey(check.kind, unitCheckContext(check)); +} + +export function pendingUnitCheckUsesPilotAutomation(check: UnitCheckDetails): boolean { + return unitCheckUsesPilotAutomation(check.kind, unitCheckContext(check)); +} + +export function pendingUnitCheckIsApprovedAutomatic(check: UnitCheckDetails): boolean { + return unitCheckIsApprovedAutomatic(check.kind); +} + +export function pendingUnitCheckDialogTitle(check: UnitCheckDetails): string | undefined { + return unitCheckDialogTitle(check.kind); +} + +export function pendingUnitCheckNeedsSelection(unit: CBTForceUnit, check: SerializedPendingUnitCheck): boolean { + return unitCheckRequiresAmmoSelection(check.kind) + && pendingUnitCheckOutcome(check) === 'failed' + && getPreferredHeatAmmoExplosionCandidates(unit).length > 1; +} + +export function pendingUnitCheckIsResolved(unit: CBTForceUnit, check: SerializedPendingUnitCheck): boolean { + return pendingUnitCheckOutcome(check) !== undefined + && (!pendingUnitCheckNeedsSelection(unit, check) + || (isAmmoExplosionCheck(check) && !!check.selectionId)); +} + +export type PendingUnitCheckOf = + Extract; + +export type CrewOwnedUnitCheck = Extract; +export type CascadeUnitCheck = PendingUnitCheckOf< + typeof UNIT_CHECK_KIND.CONSCIOUSNESS | typeof UNIT_CHECK_KIND.SEATBELT +>; + +export function isPendingUnitCheckKind( + check: SerializedPendingUnitCheck, + kind: K, +): check is PendingUnitCheckOf { + return check.kind === kind; +} + +export function isConsciousnessCheck( + check: SerializedPendingUnitCheck, +): check is PendingUnitCheckOf { + return isPendingUnitCheckKind(check, UNIT_CHECK_KIND.CONSCIOUSNESS); +} + +export function isConsciousnessRecoveryCheck( + check: SerializedPendingUnitCheck, +): check is PendingUnitCheckOf { + return isPendingUnitCheckKind(check, UNIT_CHECK_KIND.CONSCIOUSNESS_RECOVERY); +} + +export function isConsciousnessSequenceCheck( + check: SerializedPendingUnitCheck, +): check is PendingUnitCheckOf< + typeof UNIT_CHECK_KIND.CONSCIOUSNESS | typeof UNIT_CHECK_KIND.CONSCIOUSNESS_RECOVERY +> { + return isConsciousnessCheck(check) || isConsciousnessRecoveryCheck(check); +} + +export function isAmmoExplosionCheck( + check: SerializedPendingUnitCheck, +): check is PendingUnitCheckOf { + return isPendingUnitCheckKind(check, UNIT_CHECK_KIND.HEAT_AMMO_EXPLOSION); +} + +export function isAeroControlRecoveryCheck( + check: SerializedPendingUnitCheck, +): check is PendingUnitCheckOf { + return isPendingUnitCheckKind(check, UNIT_CHECK_KIND.AERO_CONTROL_RECOVERY); +} + +export function isHeatControlRecoveryCheck( + check: SerializedPendingUnitCheck, +): check is PendingUnitCheckOf + & { readonly cause: UnitCheckCause } { + return isAeroControlRecoveryCheck(check) + && check.cause === UNIT_CHECK_CAUSE.HEAT_RANDOM_MOVEMENT; +} + +export function isCrewOwnedUnitCheck(check: SerializedPendingUnitCheck): check is CrewOwnedUnitCheck { + return unitCheckIsCrewOwned(check.kind); +} + +export function isCascadeUnitCheck(check: SerializedPendingUnitCheck): check is CascadeUnitCheck { + return unitCheckIsCascadeParticipant(check.kind); +} + +export function pendingUnitCheckCrewId(check: SerializedPendingUnitCheck): number { + return isCrewOwnedUnitCheck(check) ? check.crewId : 0; +} + +/** Revalidates the few checks whose target or applicability changes over time. */ +export function refreshPendingUnitCheck( + unit: CBTForceUnit, + pending: SerializedPendingUnitCheck, +): SerializedPendingUnitCheck | null { + if (isConsciousnessCheck(pending)) { + const crew = unit.getCrewMember(pending.crewId); + if (!crew || crew.getState() !== 'healthy') return null; + const currentTarget = getConsciousnessTarget(crew.getHits()); + if (currentTarget === null) return null; + if (unit.gameRules.aggregatedEndPhaseConsciousRolls) { + return withRefreshedUnitCheckTarget(pending, currentTarget); + } + const checkHit = pending.target === undefined + ? null + : getConsciousnessHitCount(pending.target); + return checkHit !== null && checkHit <= crew.getHits() ? pending : null; + } + if (isConsciousnessRecoveryCheck(pending)) { + const crew = unit.getCrewMember(pending.crewId); + const target = crew ? getConsciousnessTarget(crew.getHits()) : null; + return crew?.getState() === 'unconscious' && target !== null + ? withRefreshedUnitCheckTarget(pending, target) + : null; + } + if (isPendingUnitCheckKind(pending, UNIT_CHECK_KIND.AERO_CONTROL_RECOVERY)) { + if (!unit.getCondition('out-of-control')) return null; + if (unit.rules.getActivePilotCrewId() === null && !unit.rules.isRemoteDrone()) { + if (!canRetryAeroControlRecovery(unit)) return null; + return withAutomaticUnitCheckOutcome(pending, 'failed'); + } + const target = unit.rules.getStandardControlRollTarget(); + return target > 12 + ? withAutomaticUnitCheckOutcome(pending, 'failed') + : withRefreshedUnitCheckTarget(pending, target); + } + if (isPendingUnitCheckKind(pending, UNIT_CHECK_KIND.SEATBELT)) { + const crew = unit.getCrewMember(pending.crewId); + if (!crew || !isCrewMemberAboard(crew.getState())) return null; + if (!isCrewMemberAvailable(crew.getState()) + || unit.getCondition('shutdown') + || unit.getCondition('immobile') + || (pending.target ?? 13) > 12) { + return withAutomaticUnitCheckOutcome(pending, 'failed'); + } + } + return pending; +} + +function unitCheckContext( + check: UnitCheckDetails, + heat = 0, + crewName?: string, + crewHits = 0, +): UnitCheckContext { + return { + target: check.target, + heat, + hits: check.hits ?? 1, + cause: check.cause, + crewName, + crewHits, + consciousnessCheckHit: check.target === undefined + ? null + : getConsciousnessHitCount(check.target), + }; +} + +function withRefreshedUnitCheckTarget( + pending: SerializedPendingUnitCheck, + target: number, +): SerializedPendingUnitCheck { + if (pending.target === target) return pending; + const { target: _staleTarget, result: staleResult, ...facts } = pending; + return { + ...facts, + target, + ...(staleResult?.kind === 'roll' || staleResult?.kind === 'automatic' + ? { result: staleResult } + : {}), + } as SerializedPendingUnitCheck; +} + +function withAutomaticUnitCheckOutcome( + pending: SerializedPendingUnitCheck, + outcome: UnitCheckOutcome, +): SerializedPendingUnitCheck { + const { target: _target, result: _result, ...facts } = pending; + return { ...facts, result: { kind: 'automatic', outcome } } as SerializedPendingUnitCheck; +} diff --git a/src/styles.scss b/src/styles.scss index 4c71252b9..3e603165d 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -985,6 +985,14 @@ hr { transition: opacity 0.2s ease-in-out; &.large { + flex: 0 0 36px; + width: 36px; + height: 36px; + background-size: 32px 32px; + + } + + &.huge { flex: 0 0 64px; width: 64px; height: 64px; From bee6312213c89ea98affda6f0e070544ed4a65ac Mon Sep 17 00:00:00 2001 From: exeea Date: Thu, 27 Aug 2026 14:12:02 +0200 Subject: [PATCH 33/87] test --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index b201bbc86..b13394ed2 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "postbuild": "node scripts/generate-seo-pages.js", "build:next": "npm run prerun && ng build --configuration=next", "postbuild:next": "npm run postbuild", - "test:ratgenerator": "tsx scripts/ratgenerator_build_table.test.ts", "test:force-name-words": "tsx scripts/generate-force-name-words.test.ts", "test:unit-report-oracles": "tsx scripts/unit-report-oracles.test.ts", "test:unit-diagnostics": "tsx scripts/unit-diagnostics.test.ts", From 4106c2b0f69dfd7bf7e14130d67deae3111f4150 Mon Sep 17 00:00:00 2001 From: exeea Date: Thu, 27 Aug 2026 16:12:33 +0200 Subject: [PATCH 34/87] types --- .../page-psr-warning-panel.component.html | 2 +- .../page-psr-warning-panel.component.spec.ts | 60 ++++++-- .../page-psr-warning-panel.component.ts | 16 ++- .../overlay/page-turn-summary.util.ts | 6 +- ...ending-unit-check-dialog.component.spec.ts | 5 +- .../pending-unit-check-dialog.component.ts | 7 +- .../pending-unit-check-row.component.ts | 4 +- ...unit-notification-badges.component.spec.ts | 4 +- .../unit-notification-tooltip.util.ts | 10 +- src/app/models/cbt-force-unit.model.spec.ts | 7 +- src/app/models/rules/mek-rules.spec.ts | 86 ++++++------ src/app/models/rules/mek-rules.ts | 80 ++++++++--- src/app/models/rules/tw-rules.spec.ts | 11 +- src/app/models/rules/tw-rules.ts | 42 ++++-- src/app/models/rules/unit-type-rules.ts | 88 ++++++++++-- src/app/models/rules/vehicle-rules.ts | 6 +- src/app/models/turn-state.model.spec.ts | 129 +++++++++++++++--- src/app/models/turn-state.model.ts | 22 +-- .../cbt-phase-resolution.service.spec.ts | 27 ++-- .../services/cbt-phase-resolution.service.ts | 6 +- src/app/services/options.service.spec.ts | 55 ++++++++ src/app/services/options.service.ts | 28 +--- .../unit-check-resolution.service.spec.ts | 4 +- 23 files changed, 518 insertions(+), 187 deletions(-) diff --git a/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.html b/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.html index 79b404fe7..9ae52213b 100644 --- a/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.html +++ b/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.html @@ -23,7 +23,7 @@ {{ location }} } - Failure: {{ check.failureOutcome }} + Failure: {{ failureLabel(check) }}
@if (!isAutomaticFailure(check) && outcome(check); as result) { diff --git a/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.spec.ts b/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.spec.ts index ddb3a0b5c..d88d119e1 100644 --- a/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.spec.ts +++ b/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.spec.ts @@ -8,7 +8,12 @@ import { Overlay } from '@angular/cdk/overlay'; import { Subject } from 'rxjs'; import { DiceRollerComponent } from '../../dice-roller/dice-roller.component'; import { OverlayManagerService } from '../../../services/overlay-manager.service'; -import type { PSRCheck } from '../../../models/rules/unit-type-rules'; +import { + FALL_PSR_FAILURE, + PSR_CHECK_KIND, + PSR_FAILURE_KIND, + type PSRCheck, +} from '../../../models/rules/unit-type-rules'; import { PageInteractionOverlayComponent } from './page-interaction-overlay.component'; import { PagePsrWarningPanelComponent, PSR_WARNING_UNIT, psrRollOutcome, togglePsrWarningOverlay } from './page-psr-warning-panel.component'; @@ -53,10 +58,19 @@ describe('psrRollOutcome', () => { describe('PagePsrWarningPanelComponent', () => { it('stages virtual and physical-dice outcomes until the results are accepted', () => { const check: PSRCheck = { - id: 'fall-check', fallCheck: 0, loc: 'RL', reason: 'Hip hit', failureOutcome: 'Fall' + id: 'fall-check', + kind: PSR_CHECK_KIND.HIP_HIT, + failure: FALL_PSR_FAILURE, + fallCheck: 0, + loc: 'RL', + reason: 'Hip hit', }; const damageCheck: PSRCheck = { - id: 'damage-check', fallCheck: 1, reason: 'Received 20 damage', failureOutcome: 'Fall' + id: 'damage-check', + kind: PSR_CHECK_KIND.DAMAGE_THRESHOLD, + failure: FALL_PSR_FAILURE, + fallCheck: 1, + reason: 'Received 20 damage', }; const outcomes = new Map(); const resolvePSRCheck = jasmine.createSpy('resolvePSRCheck').and.callFake( @@ -142,9 +156,10 @@ describe('PagePsrWarningPanelComponent', () => { it('keeps a rule-check choice provisional until it is accepted', () => { const check: PSRCheck = { id: 'torso-check', + kind: PSR_CHECK_KIND.TORSO_DESTROYED, + failure: { kind: PSR_FAILURE_KIND.RULE_RESOLUTION, label: 'Shutdown' }, fallCheck: 0, reason: 'RISC emergency shutdown', - failureOutcome: 'Shutdown', resolution: { key: 'risc-shutdown', token: 'token-1' }, }; let checks: PSRCheck[] = [check]; @@ -207,7 +222,11 @@ describe('PagePsrWarningPanelComponent', () => { it('keeps provisional choices when closed and restores them when reopened', () => { const check: PSRCheck = { - id: 'fall-check', fallCheck: 0, reason: 'Received 20 damage', failureOutcome: 'Fall' + id: 'fall-check', + kind: PSR_CHECK_KIND.DAMAGE_THRESHOLD, + failure: FALL_PSR_FAILURE, + fallCheck: 0, + reason: 'Received 20 damage', }; const resolvePSRCheck = jasmine.createSpy('resolvePSRCheck'); const closeManagedOverlay = jasmine.createSpy('closeManagedOverlay'); @@ -271,12 +290,27 @@ describe('PagePsrWarningPanelComponent', () => { it('locks later Fall checks as failed while preserving independent checks', () => { const checks: PSRCheck[] = [ - { id: 'first-fall', fallCheck: 0, reason: 'First fall check', failureOutcome: 'Fall' }, - { id: 'second-fall', fallCheck: 1, reason: 'Second fall check', failureOutcome: 'Fall' }, - { id: 'control', fallCheck: 2, reason: 'Control check', failureOutcome: 'Immobilized' }, - { id: 'third-fall', fallCheck: 3, reason: 'Third fall check', failureOutcome: 'Fall' }, + { + id: 'first-fall', kind: PSR_CHECK_KIND.GYRO_HIT, + failure: FALL_PSR_FAILURE, fallCheck: 0, reason: 'First fall check', + }, + { + id: 'second-fall', kind: PSR_CHECK_KIND.DAMAGE_THRESHOLD, + failure: FALL_PSR_FAILURE, fallCheck: 1, reason: 'Second fall check', + }, + { + id: 'control', kind: PSR_CHECK_KIND.TORSO_DESTROYED, + failure: { kind: PSR_FAILURE_KIND.RULE_RESOLUTION, label: 'Immobilized' }, + fallCheck: 2, reason: 'Control check', + resolution: { key: 'control-check', token: 'control-1' }, + }, + { + id: 'third-fall', kind: PSR_CHECK_KIND.LEG_DESTROYED, + failure: FALL_PSR_FAILURE, fallCheck: 3, reason: 'Third fall check', + }, ]; const resolvePSRCheck = jasmine.createSpy('resolvePSRCheck').and.returnValue(true); + const resolveRuleCheck = jasmine.createSpy('resolveRuleCheck'); const closeManagedOverlay = jasmine.createSpy('closeManagedOverlay'); const turnState = { getPSRChecks: () => checks, @@ -295,7 +329,8 @@ describe('PagePsrWarningPanelComponent', () => { turnState: () => turnState, PSRTargetRoll: () => 8, PSRModifiers: () => ({ modifiers: [] }), - resolveRuleCheck: jasmine.createSpy('resolveRuleCheck'), + getRuleCheck: () => undefined, + resolveRuleCheck, }; TestBed.configureTestingModule({ @@ -337,18 +372,19 @@ describe('PagePsrWarningPanelComponent', () => { expect(resolvePSRCheck.calls.allArgs()).toEqual([ ['first-fall', 'success'], ['second-fall', 'failed'], - ['control', 'success'], ['third-fall', 'failed'], ]); + expect(resolveRuleCheck).toHaveBeenCalledOnceWith('control-check', 'control-1', 'success'); expect(closeManagedOverlay).toHaveBeenCalledOnceWith('psrWarning-unit-1'); }); it('presents an unconscious pilot\'s pending PSR as an automatic failure', () => { const check: PSRCheck = { id: 'gyro-destroyed', + kind: PSR_CHECK_KIND.GYRO_DESTROYED, + failure: FALL_PSR_FAILURE, fallCheck: 6, reason: 'Gyro destroyed', - failureOutcome: 'Fall', }; const turnState = { getPSRChecks: () => [check], diff --git a/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.ts b/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.ts index 5d7d4dfe8..6a30a158e 100644 --- a/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.ts +++ b/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.ts @@ -6,7 +6,7 @@ import { ChangeDetectionStrategy, Component, computed, inject, InjectionToken, I import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; import { Overlay } from '@angular/cdk/overlay'; import { ComponentPortal } from '@angular/cdk/portal'; -import type { PSRCheck } from '../../../models/rules/unit-type-rules'; +import { isFallPSRCheck, psrFailureLabel, type PSRCheck } from '../../../models/rules/unit-type-rules'; import { OverlayManagerService } from '../../../services/overlay-manager.service'; import { DiceRollerComponent } from '../../dice-roller/dice-roller.component'; import type { CBTForceUnit } from '../../../models/cbt-force-unit.model'; @@ -172,7 +172,11 @@ export class PagePsrWarningPanelComponent { return this.committedOutcome(check) === undefined && turnState !== undefined && (turnState.isPSRCheckAutomaticFailure(check) - || (turnState.autoFall() && check.failureOutcome === 'Fall')); + || (turnState.autoFall() && isFallPSRCheck(check))); + } + + failureLabel(check: PSRCheck): string { + return psrFailureLabel(check); } readonly canAccept = computed(() => { @@ -255,22 +259,22 @@ export class PagePsrWarningPanelComponent { const committed = this.committedOutcome(check); if (committed) { states.set(check.id, { outcome: committed, source: 'committed' }); - if (committed === 'failed' && check.failureOutcome === 'Fall') priorFallFailed = true; + if (committed === 'failed' && isFallPSRCheck(check)) priorFallFailed = true; continue; } if (this.isAutomaticFailure(check)) { states.set(check.id, { outcome: 'failed', source: 'automatic' }); - if (check.failureOutcome === 'Fall') priorFallFailed = true; + if (isFallPSRCheck(check)) priorFallFailed = true; continue; } - if (priorFallFailed && check.failureOutcome === 'Fall') { + if (priorFallFailed && isFallPSRCheck(check)) { states.set(check.id, { outcome: 'failed', source: 'cascade' }); continue; } const outcome = selected[check.id]; if (!outcome) continue; states.set(check.id, { outcome, source: 'selected' }); - if (outcome === 'failed' && check.failureOutcome === 'Fall') priorFallFailed = true; + if (outcome === 'failed' && isFallPSRCheck(check)) priorFallFailed = true; } return states; }); diff --git a/src/app/components/page-viewer/overlay/page-turn-summary.util.ts b/src/app/components/page-viewer/overlay/page-turn-summary.util.ts index 5ba53af24..32ee6b55b 100644 --- a/src/app/components/page-viewer/overlay/page-turn-summary.util.ts +++ b/src/app/components/page-viewer/overlay/page-turn-summary.util.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import type { PSRCheck, UnitHeatSource } from '../../../models/rules/unit-type-rules'; +import type { PSRModifier, UnitHeatSource } from '../../../models/rules/unit-type-rules'; import type { SelectedInventoryWeaponHeat } from '../../../utils/inventory-control-heat.util'; import type { MotiveModes } from '../../../models/motiveModes.model'; import type { ManagedOverlayRef, OverlayManagerService } from '../../../services/overlay-manager.service'; @@ -78,9 +78,9 @@ export function composeTurnSummaryHeatRows( return result; } -export function displayPsrModifiers(modifiers: readonly PSRCheck[]): Array { +export function displayPsrModifiers(modifiers: readonly PSRModifier[]): Array { return modifiers - .filter((modifier): modifier is PSRCheck & { pilotCheck: number } => + .filter((modifier): modifier is PSRModifier & { pilotCheck: number } => modifier.pilotCheck !== undefined && modifier.pilotCheck !== 0 ) .map(modifier => ({ diff --git a/src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.spec.ts b/src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.spec.ts index aaebcafc2..cf4ef4263 100644 --- a/src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.spec.ts +++ b/src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.spec.ts @@ -8,7 +8,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; import type { SerializedPendingUnitCheck } from '../../models/force-serialization'; -import type { PSRCheck } from '../../models/rules/unit-type-rules'; +import { FALL_PSR_FAILURE, PSR_CHECK_KIND, type PSRCheck } from '../../models/rules/unit-type-rules'; import { PendingUnitCheckDialogComponent, type PendingUnitCheckDialogData, @@ -238,9 +238,10 @@ describe('PendingUnitCheckDialogComponent', () => { .aggregatedEndPhaseConsciousRolls = false; first.psrChecks.set([{ id: 'psr:one', + kind: PSR_CHECK_KIND.DAMAGE_THRESHOLD, + failure: FALL_PSR_FAILURE, fallCheck: 0, reason: '20 or more damage', - failureOutcome: 'Fall', }]); fixture.detectChanges(); diff --git a/src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.ts b/src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.ts index d6b73f250..a08f5b0fd 100644 --- a/src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.ts +++ b/src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.ts @@ -5,6 +5,7 @@ import { ChangeDetectionStrategy, Component, computed, inject, viewChildren } from '@angular/core'; import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; +import { isFallPSRCheck } from '../../models/rules/unit-type-rules'; import { isConsciousnessCheck, isPendingUnitCheckEntry, @@ -105,14 +106,14 @@ export class PendingUnitCheckDialogComponent { const checkId = entry.check.id; const isForced = failedControllers.has(entry.unit.id) || entry.unit.turnState().isPSRCheckAutomaticFailure(entry.check) - || (entry.unit.turnState().autoFall() && entry.check.failureOutcome === 'Fall') - || (failedFallChecks.has(entry.unit.id) && entry.check.failureOutcome === 'Fall'); + || (entry.unit.turnState().autoFall() && isFallPSRCheck(entry.check)) + || (failedFallChecks.has(entry.unit.id) && isFallPSRCheck(entry.check)); if (isForced) forced.add(entryKey); const outcome = isForced ? 'failed' : pendingPsrCommittedOutcome(entry.unit, entry.check) ?? (checkId ? entry.unit.psrOutcomeSelections()[checkId] : undefined); - if (outcome === 'failed' && entry.check.failureOutcome === 'Fall') { + if (outcome === 'failed' && isFallPSRCheck(entry.check)) { failedFallChecks.add(entry.unit.id); } } diff --git a/src/app/components/pending-unit-check-dialog/pending-unit-check-row.component.ts b/src/app/components/pending-unit-check-dialog/pending-unit-check-row.component.ts index 261f61b88..ddbf5ae14 100644 --- a/src/app/components/pending-unit-check-dialog/pending-unit-check-row.component.ts +++ b/src/app/components/pending-unit-check-dialog/pending-unit-check-row.component.ts @@ -5,6 +5,7 @@ import { ChangeDetectionStrategy, Component, computed, input, viewChild } from '@angular/core'; import type { SerializedPendingUnitCheck } from '../../models/force-serialization'; import { getMekLocationLabel } from '../../models/entity/types'; +import { psrFailureLabel } from '../../models/rules/unit-type-rules'; import { getPreferredHeatAmmoExplosionCandidates } from '../../utils/heat-effects.util'; import { isAmmoExplosionCheck, @@ -160,7 +161,8 @@ export class PendingUnitCheckRowComponent { const check = this.currentUnitCheck(); return check ? pendingUnitCheckFailureOutcome(check) : ''; } - return this.currentPsrCheck()?.failureOutcome ?? 'Fall'; + const check = this.currentPsrCheck(); + return check ? psrFailureLabel(check) : ''; }); readonly target = computed(() => { const entry = this.entry(); diff --git a/src/app/components/unit-notification-badges/unit-notification-badges.component.spec.ts b/src/app/components/unit-notification-badges/unit-notification-badges.component.spec.ts index 4ab8ebb21..8e964edb3 100644 --- a/src/app/components/unit-notification-badges/unit-notification-badges.component.spec.ts +++ b/src/app/components/unit-notification-badges/unit-notification-badges.component.spec.ts @@ -11,6 +11,7 @@ import type { SerializedPendingMekCriticalChance, SerializedPendingUnitCheck, } from '../../models/force-serialization'; +import { FALL_PSR_FAILURE, PSR_CHECK_KIND } from '../../models/rules/unit-type-rules'; import { UnitNotificationBadgesComponent } from './unit-notification-badges.component'; describe('UnitNotificationBadgesComponent', () => { @@ -197,8 +198,9 @@ describe('UnitNotificationBadgesComponent', () => { PSRRollsCount: () => psrOutcome() === undefined ? psrCount() : 0, getPSRChecks: () => Array.from({ length: psrCount() }, (_, index) => ({ id: `psr:${index}`, + kind: PSR_CHECK_KIND.DAMAGE_THRESHOLD, + failure: FALL_PSR_FAILURE, fallCheck: 1, - failureOutcome: 'Fall', reason: `PSR ${index + 1}`, })), getPSROutcome: () => psrOutcome(), diff --git a/src/app/components/unit-notification-badges/unit-notification-tooltip.util.ts b/src/app/components/unit-notification-badges/unit-notification-tooltip.util.ts index f2305903f..ac666c540 100644 --- a/src/app/components/unit-notification-badges/unit-notification-tooltip.util.ts +++ b/src/app/components/unit-notification-badges/unit-notification-tooltip.util.ts @@ -9,7 +9,7 @@ import type { SerializedPendingUnitCheck, } from '../../models/force-serialization'; import { getMekLocationLabel } from '../../models/entity/types'; -import type { PSRCheck } from '../../models/rules/unit-type-rules'; +import { isFallPSRCheck, psrFailureLabel, type PSRCheck } from '../../models/rules/unit-type-rules'; import { pendingUnitCheckOutcome, pendingUnitCheckPriority, @@ -146,21 +146,21 @@ function buildPsrEventTooltip( if (check.fallCheck === undefined || check.id === undefined) return false; const outcome = turnState.getPSROutcome(check.id); if (mode === 'automatic-fall') { - return check.failureOutcome === 'Fall' + return isFallPSRCheck(check) && (turnState.autoFall() || turnState.isPSRCheckAutomaticFailure(check)) && (outcome === undefined || (outcome === 'failed' && !unit.getCondition('prone'))); } return outcome === undefined && !turnState.isPSRCheckAutomaticFailure(check) - && (!turnState.autoFall() || check.failureOutcome !== 'Fall'); + && (!turnState.autoFall() || !isFallPSRCheck(check)); }); if (pending.length === 0) return null; return pending.map(check => ({ label: psrCheckLabel(check), value: mode === 'automatic-fall' - ? check.failureOutcome ?? 'Fall' - : `Target ${unit.PSRTargetRoll()}+ · ${check.failureOutcome ?? 'Fall'}`, + ? psrFailureLabel(check) + : `Target ${unit.PSRTargetRoll()}+ · ${psrFailureLabel(check)}`, })); } diff --git a/src/app/models/cbt-force-unit.model.spec.ts b/src/app/models/cbt-force-unit.model.spec.ts index 0789e9f39..08c464dba 100644 --- a/src/app/models/cbt-force-unit.model.spec.ts +++ b/src/app/models/cbt-force-unit.model.spec.ts @@ -34,7 +34,7 @@ import { UACFiringModeHandler } from '../equipment-handlers/uac-firing-mode.hand import { EquipmentFlag } from './equipment-flags.type'; import { EquipmentRegistry } from './equipment-lookup'; import { OptionsService } from '../services/options.service'; -import { formatPilotingDisplay, type ChargeDamage } from './rules/unit-type-rules'; +import { FALL_PSR_FAILURE, formatPilotingDisplay, PSR_CHECK_KIND, type ChargeDamage } from './rules/unit-type-rules'; import { registerAllHandlers } from '../equipment-handlers'; import { PPC_CAPACITOR_CHARGING_STATE, @@ -2950,7 +2950,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { forceUnit.turnState().setPSRCheckState({ shutdown: true }); const [shutdownCheck] = forceUnit.turnState().getPSRChecks(); - expect(shutdownCheck.kind).toBe('shutdown'); + expect(shutdownCheck.kind).toBe(PSR_CHECK_KIND.SHUTDOWN); expect(forceUnit.turnState().PSRRollsCount()).toBe(1); expect(forceUnit.turnState().actionablePSRRollsCount()).toBe(1); expect(forceUnit.turnState().automaticPSRFailure()).toBeFalse(); @@ -3094,8 +3094,9 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(forceUnit.turnState().getPendingUnitChecks()).toEqual([]); expect(forceUnit.turnState().getPSRChecks()).toContain(jasmine.objectContaining({ + kind: PSR_CHECK_KIND.DAMAGE_THRESHOLD, + failure: FALL_PSR_FAILURE, reason: jasmine.stringMatching(/20/), - failureOutcome: 'Fall', })); }); diff --git a/src/app/models/rules/mek-rules.spec.ts b/src/app/models/rules/mek-rules.spec.ts index 5f1c93518..3ddfc7966 100644 --- a/src/app/models/rules/mek-rules.spec.ts +++ b/src/app/models/rules/mek-rules.spec.ts @@ -17,7 +17,7 @@ import { EquipmentInteractionRegistryService } from '../../services/equipment-in import { UnitInitializerService } from '../../services/unit-initializer.service'; import { createEmptyUnit } from '../../testing/unit-test-helpers'; import { type ToHitModifierBreakdownEntry } from './game-rules'; -import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE } from './unit-type-rules'; +import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE, PSR_CHECK_KIND, PSR_FAILURE_KIND } from './unit-type-rules'; import { MekRules } from './mek-rules'; import { MascHandler, MASC_ACTIVE_STATE_KEY } from '../../equipment-handlers/masc.handler'; import { HAG_FLAK_MODE, HAG_MODE_STATE_KEY, HAG_STANDARD_MODE, HagHandler } from '../../equipment-handlers/hag.handler'; @@ -2465,13 +2465,16 @@ describe('MekRules', () => { committedDestroyedLocations: ['LT'], }); const successfulCheck = successfulUnit.turnState().getPSRChecks() - .find(check => check.reason === 'Torso destroyed'); + .find(check => check.kind === PSR_CHECK_KIND.TORSO_DESTROYED); expect(successfulUnit.rules.hasComputedCondition('crippled')).toBeFalse(); expect(successfulCheck).toBeDefined(); expect(successfulCheck?.loc).toBe('LT'); expect(successfulCheck?.pilotCheck).toBe(0); - expect(successfulCheck?.failureOutcome).toBe('Crippled'); + expect(successfulCheck?.failure).toEqual({ + kind: PSR_FAILURE_KIND.RULE_RESOLUTION, + label: 'Crippled', + }); expect(successfulCheck?.resolution).toBeDefined(); expect(successfulUnit.resolveRuleCheck( successfulCheck!.resolution!.key, @@ -2480,12 +2483,12 @@ describe('MekRules', () => { )).toBeTrue(); expect(successfulUnit.rules.hasComputedCondition('crippled')).toBeFalse(); expect(successfulUnit.turnState().getPSRChecks().some(check => - check.reason === 'Torso destroyed' + check.kind === PSR_CHECK_KIND.TORSO_DESTROYED )).toBeFalse(); successfulUnit.endTurn(); expect(successfulUnit.rules.hasComputedCondition('crippled')).toBeFalse(); expect(successfulUnit.turnState().getPSRChecks().some(check => - check.reason === 'Torso destroyed' + check.kind === PSR_CHECK_KIND.TORSO_DESTROYED )).toBeFalse(); const failedUnit = createForceUnitHarness({ @@ -2493,7 +2496,7 @@ describe('MekRules', () => { committedDestroyedLocations: ['RT'], }); const failedCheck = failedUnit.turnState().getPSRChecks() - .find(check => check.reason === 'Torso destroyed'); + .find(check => check.kind === PSR_CHECK_KIND.TORSO_DESTROYED); expect(failedUnit.resolveRuleCheck( failedCheck!.resolution!.key, @@ -2502,7 +2505,7 @@ describe('MekRules', () => { )).toBeTrue(); expect(failedUnit.rules.hasComputedCondition('crippled')).toBeTrue(); expect(failedUnit.turnState().getPSRChecks().some(check => - check.reason === 'Torso destroyed' + check.kind === PSR_CHECK_KIND.TORSO_DESTROYED )).toBeFalse(); failedUnit.endTurn(); expect(failedUnit.rules.hasComputedCondition('crippled')).toBeTrue(); @@ -2517,7 +2520,7 @@ describe('MekRules', () => { expect(forceUnit.rules.hasComputedCondition('crippled')).toBeFalse(); expect(forceUnit.turnState().getPSRChecks().some(check => - check.reason === 'Torso destroyed' + check.kind === PSR_CHECK_KIND.TORSO_DESTROYED )).toBeFalse(); optionsService.options.update(current => ({ @@ -2528,7 +2531,7 @@ describe('MekRules', () => { expect(forceUnit.rules.hasComputedCondition('crippled')).toBeFalse(); expect(forceUnit.turnState().getPSRChecks().some(check => - check.reason === 'Torso destroyed' + check.kind === PSR_CHECK_KIND.TORSO_DESTROYED )).toBeTrue(); optionsService.options.update(current => ({ @@ -2539,7 +2542,7 @@ describe('MekRules', () => { expect(forceUnit.rules.hasComputedCondition('crippled')).toBeFalse(); expect(forceUnit.turnState().getPSRChecks().some(check => - check.reason === 'Torso destroyed' + check.kind === PSR_CHECK_KIND.TORSO_DESTROYED )).toBeFalse(); }); @@ -2549,14 +2552,14 @@ describe('MekRules', () => { committedDestroyedLocations: ['LT'], }); const firstCheck = forceUnit.turnState().getPSRChecks() - .find(check => check.reason === 'Torso destroyed')!; + .find(check => check.kind === PSR_CHECK_KIND.TORSO_DESTROYED)!; forceUnit.setInternalHits('LT', 0); expect(forceUnit.getRuleCheck(firstCheck.resolution!.key)).toBeUndefined(); forceUnit.setInternalHits('LT', 1); const secondCheck = forceUnit.turnState().getPSRChecks() - .find(check => check.reason === 'Torso destroyed')!; + .find(check => check.kind === PSR_CHECK_KIND.TORSO_DESTROYED)!; expect(secondCheck.resolution!.token).not.toBe(firstCheck.resolution!.token); expect(forceUnit.resolveRuleCheck( @@ -2579,20 +2582,20 @@ describe('MekRules', () => { committedDestroyedLocations: ['LT'], }); const check = forceUnit.turnState().getPSRChecks() - .find(entry => entry.reason === 'Torso destroyed')!; + .find(entry => entry.kind === PSR_CHECK_KIND.TORSO_DESTROYED)!; forceUnit.resolveRuleCheck(check.resolution!.key, check.resolution!.token, 'success'); forceUnit.setInternalHits('RT', 1); expect(forceUnit.rules.hasComputedCondition('crippled')).toBeTrue(); expect(forceUnit.turnState().getPSRChecks().some(entry => - entry.reason === 'Torso destroyed' + entry.kind === PSR_CHECK_KIND.TORSO_DESTROYED )).toBeFalse(); forceUnit.setInternalHits('RT', 0); expect(forceUnit.rules.hasComputedCondition('crippled')).toBeFalse(); expect(forceUnit.getRuleCheck(check.resolution!.key)?.token).toBe(check.resolution!.token); expect(forceUnit.turnState().getPSRChecks().some(entry => - entry.reason === 'Torso destroyed' + entry.kind === PSR_CHECK_KIND.TORSO_DESTROYED )).toBeFalse(); }); @@ -2602,13 +2605,13 @@ describe('MekRules', () => { committedDestroyedLocations: ['LT'], }); const firstCheck = forceUnit.turnState().getPSRChecks() - .find(entry => entry.reason === 'Torso destroyed')!; + .find(entry => entry.kind === PSR_CHECK_KIND.TORSO_DESTROYED)!; forceUnit.resolveRuleCheck(firstCheck.resolution!.key, firstCheck.resolution!.token, 'success'); forceUnit.setInternalHits('RT', 1); forceUnit.setInternalHits('LT', 0); const nextCheck = forceUnit.turnState().getPSRChecks() - .find(entry => entry.reason === 'Torso destroyed')!; + .find(entry => entry.kind === PSR_CHECK_KIND.TORSO_DESTROYED)!; expect(nextCheck.resolution!.token).not.toBe(firstCheck.resolution!.token); expect(forceUnit.getRuleCheck(nextCheck.resolution!.key)?.trigger).toBe('RT'); @@ -2621,7 +2624,7 @@ describe('MekRules', () => { committedDestroyedLocations: ['LT'], }); const check = source.turnState().getPSRChecks() - .find(entry => entry.reason === 'Torso destroyed')!; + .find(entry => entry.kind === PSR_CHECK_KIND.TORSO_DESTROYED)!; source.resolveRuleCheck(check.resolution!.key, check.resolution!.token, 'failed'); const serialized = source.serialize(); @@ -2640,7 +2643,7 @@ describe('MekRules', () => { expect(restored.rules.hasComputedCondition('crippled')).toBeTrue(); expect(restored.turnState().getPSRChecks().some(entry => - entry.reason === 'Torso destroyed' + entry.kind === PSR_CHECK_KIND.TORSO_DESTROYED )).toBeFalse(); }); @@ -2659,11 +2662,11 @@ describe('MekRules', () => { expect(compactUnit.rules.hasComputedCondition('crippled')).toBeFalse(); expect(compactUnit.turnState().getPSRChecks().some(check => - check.reason === 'Torso destroyed' + check.kind === PSR_CHECK_KIND.TORSO_DESTROYED )).toBeTrue(); expect(xlUnit.rules.hasComputedCondition('crippled')).toBeTrue(); expect(xlUnit.turnState().getPSRChecks().some(check => - check.reason === 'Torso destroyed' + check.kind === PSR_CHECK_KIND.TORSO_DESTROYED )).toBeFalse(); }); @@ -2675,7 +2678,7 @@ describe('MekRules', () => { expect(forceUnit.rules.hasComputedCondition('crippled')).toBeTrue(); expect(forceUnit.turnState().getPSRChecks().some(check => - check.reason === 'Torso destroyed' + check.kind === PSR_CHECK_KIND.TORSO_DESTROYED )).toBeFalse(); }); @@ -2689,7 +2692,7 @@ describe('MekRules', () => { expect(forceUnit.rules.hasComputedCondition('crippled')).toBeFalse(); expect(forceUnit.turnState().getPSRChecks().some(check => - check.reason === 'Torso destroyed' + check.kind === PSR_CHECK_KIND.TORSO_DESTROYED )).toBeFalse(); }); @@ -3444,7 +3447,7 @@ describe('MekRules', () => { expect(runOption()?.psr).toBeTrue(); }); - it('treats two destroyed Core Quad legs as a hip hit when using Running MP', () => { + it('applies hip-hit movement effects to a Core Quad with two destroyed legs', () => { const forceUnit = createForceUnitHarness({ internalLocations: ['RLL', 'FLL', 'RRL', 'FRL'], committedDestroyedLocations: ['RLL', 'FLL'], @@ -3459,15 +3462,15 @@ describe('MekRules', () => { .find(option => option.mode === 'run'); expect(runOption?.psr).toBeFalse(); - expect(turnState.getPSRChecks()).not.toContain(jasmine.objectContaining({ - reason: 'Running with damaged hip', - })); + expect(turnState.getPSRChecks().some( + check => check.kind === PSR_CHECK_KIND.QUAD_TWO_DESTROYED_LEGS_MOVEMENT, + )).toBeFalse(); turnState.moveDistance.set(1); expect(forceUnit.getAvailableMotiveModes(false).find(option => option.mode === 'run')?.psr).toBeTrue(); expect(turnState.getPSRChecks()).toContain(jasmine.objectContaining({ - reason: 'Running with damaged hip', + kind: PSR_CHECK_KIND.QUAD_TWO_DESTROYED_LEGS_MOVEMENT, })); }); @@ -3672,7 +3675,7 @@ describe('MekRules', () => { } }); - it('keeps the Core Quad two-leg hip-equivalent run trigger to one PSR', () => { + it('keeps the Core Quad two-destroyed-leg run trigger to one PSR', () => { const forceUnit = createForceUnitHarness({ internalLocations: ['RLL', 'FLL', 'RRL', 'FRL'], committedDestroyedLocations: ['RLL', 'FLL'], @@ -3685,7 +3688,9 @@ describe('MekRules', () => { turnState.moveMode.set('run'); turnState.moveDistance.set(1); - expect(turnState.getPSRChecks().filter(check => check.reason === 'Running with damaged hip').length) + expect(turnState.getPSRChecks().filter( + check => check.kind === PSR_CHECK_KIND.QUAD_TWO_DESTROYED_LEGS_MOVEMENT, + ).length) .toBe(1); }); @@ -3733,9 +3738,12 @@ describe('MekRules', () => { expect(oneLegQuad.getCommittedDamageMovementModePSRCheck('run', 1)).toBeNull(); expect(oneLegQuad.getCommittedDamageMovementModePSRCheck('jump', 1)).toBeNull(); expect(twoLegQuad.getCommittedDamageMovementModePSRCheck('run', 0)).toBeNull(); - expect(twoLegQuad.getCommittedDamageMovementModePSRCheck('run', 1)?.reason).toBe('Running with damaged hip'); - expect(twoLegQuad.getCommittedDamageMovementModePSRCheck('run', 1)?.kind).toBeUndefined(); - expect(twoLegQuad.getCommittedDamageMovementModePSRCheck('jump', 0)?.reason).toBe('Jumping with damaged hip'); + expect(twoLegQuad.getCommittedDamageMovementModePSRCheck('run', 1)?.reason) + .toBe('Running with two destroyed legs'); + expect(twoLegQuad.getCommittedDamageMovementModePSRCheck('run', 1)?.kind) + .toBe(PSR_CHECK_KIND.QUAD_TWO_DESTROYED_LEGS_MOVEMENT); + expect(twoLegQuad.getCommittedDamageMovementModePSRCheck('jump', 0)?.reason) + .toBe('Jumping with two destroyed legs'); expect(twoLegQuad.getCommittedDamageMovementModePSRCheck('run', 1)?.loc).toBeUndefined(); expect(threeLegQuad.getCommittedDamageMovementModePSRCheck('run', 0)).toBeNull(); expect(threeLegQuad.getCommittedDamageMovementModePSRCheck('run', 1)?.reason) @@ -3767,7 +3775,7 @@ describe('MekRules', () => { expect(twDamagedHip.getCommittedDamageMovementModePSRCheck('run', 0)?.reason) .toBe('Running with damaged hip'); expect(twDamagedHip.getCommittedDamageMovementModePSRCheck('run', 0)?.kind) - .toBe('damaged-hip-movement'); + .toBe(PSR_CHECK_KIND.DAMAGED_HIP_MOVEMENT); }); it('requires a jump PSR for foot damage without requiring a run PSR', () => { @@ -3778,7 +3786,7 @@ describe('MekRules', () => { expect(rules.getCommittedDamageMovementModePSRCheck('jump', 0)?.reason) .toBe('Jumping with damaged leg actuator'); expect(rules.getCommittedDamageMovementModePSRCheck('jump', 0)?.kind) - .toBe('damaged-leg-actuator-movement'); + .toBe(PSR_CHECK_KIND.DAMAGED_LEG_ACTUATOR_MOVEMENT); expect(rules.getCommittedDamageMovementModePSRCheck('run', 1)).toBeNull(); }); @@ -3866,8 +3874,8 @@ describe('MekRules', () => { 'Gyro hit', ]); expect(checks.map(check => check.pilotCheck)).toEqual([1, 1, 2]); - expect(checks.find(check => check.reason === 'Received 20 damage')?.loc).toBeUndefined(); - expect(checks.find(check => check.reason === 'Hip hit')?.loc).toBe('LL'); + expect(checks.find(check => check.kind === PSR_CHECK_KIND.DAMAGE_THRESHOLD)?.loc).toBeUndefined(); + expect(checks.find(check => check.kind === PSR_CHECK_KIND.LEG_DAMAGE)?.loc).toBe('LL'); expect(turnState.PSRRollsCount()).toBe(3); expect(forceUnit.rules.PSRModifiers().modifier).toBe(4); }); @@ -4015,7 +4023,9 @@ describe('MekRules', () => { const turnState = forceUnit.turnState(); turnState.setPSRCheckState({ gyroHit: 1, gyroDestroyed: false }); - expect(turnState.getPSRChecks().some(check => check.reason === 'Gyro hit')).toBeFalse(); + expect(turnState.getPSRChecks().some( + check => check.kind === PSR_CHECK_KIND.GYRO_HIT, + )).toBeFalse(); expect(forceUnit.rules.PSRModifiers()).toEqual(jasmine.objectContaining({ modifier: destroyedCount })); expect(forceUnit.rules.PSRModifiers().modifiers).toContain(jasmine.objectContaining({ pilotCheck: destroyedCount, diff --git a/src/app/models/rules/mek-rules.ts b/src/app/models/rules/mek-rules.ts index 56b9a184c..8d310ab48 100644 --- a/src/app/models/rules/mek-rules.ts +++ b/src/app/models/rules/mek-rules.ts @@ -7,7 +7,7 @@ import type { CBTForceUnit, EquipmentAction } from '../cbt-force-unit.model'; import { isCrewMemberAvailable, type CrewMember } from '../crew-member.model'; import type { MountedEquipment } from '../mounted-equipment.model'; import type { CriticalSlot, RuleCheckOutcome } from '../force-serialization'; -import { CrewStateControlDefinition, CrewStateDefinition, crewStateDefinitions, NARC_CONDITION_COLOR, sortPSRModifiers, UnitConditionControl, unitConditionControls, UnitTypeRulesBase, type ChargeDamage, type LocationConditionControl, type PSRCheck, type PSRCheckKind, type UnitHeatSource, type UnitModifierBreakdownEntry, type UnitRuleModifier } from './unit-type-rules'; +import { CrewStateControlDefinition, CrewStateDefinition, crewStateDefinitions, FALL_PSR_FAILURE, NARC_CONDITION_COLOR, PSR_CHECK_KIND, PSR_FAILURE_KIND, sortPSRModifiers, UnitConditionControl, unitConditionControls, UnitTypeRulesBase, type ChargeDamage, type LocationConditionControl, type PSRCheck, type PSRCheckKind, type PSRModifier, type UnitHeatSource, type UnitModifierBreakdownEntry, type UnitRuleModifier } from './unit-type-rules'; import type { EquipmentStatus, EquipmentStatusFacts } from '../equipment-status.model'; import type { TurnState } from '../turn-state.model'; import { type HeatScaleEntry, HeatManagement, getHeatEffects } from './heat-management'; @@ -36,8 +36,8 @@ import { uuidv7 } from '../../utils/uuid.util'; type ArmLocation = 'LA' | 'RA'; const LEG_DAMAGE_MOVEMENT_CRITICAL_NAMES: Partial> = { - 'damaged-leg-actuator-movement': ['Leg', 'Foot', 'Hip'], - 'damaged-hip-movement': ['Hip'], + [PSR_CHECK_KIND.DAMAGED_LEG_ACTUATOR_MOVEMENT]: ['Leg', 'Foot', 'Hip'], + [PSR_CHECK_KIND.DAMAGED_HIP_MOVEMENT]: ['Hip'], }; interface MekArmStatus { @@ -428,11 +428,12 @@ export class MekRules extends UnitTypeRulesBase { && torsoCheck?.trigger === destroyedTorsos[0] && torsoCheck.status === 'pending') { checks.push({ + kind: PSR_CHECK_KIND.TORSO_DESTROYED, fallCheck: 0, pilotCheck: 0, loc: destroyedTorsos[0], reason: 'Torso destroyed', - failureOutcome: 'Crippled', + failure: { kind: PSR_FAILURE_KIND.RULE_RESOLUTION, label: 'Crippled' }, resolution: { key: TORSO_CRIPPLE_CHECK_KEY, token: torsoCheck.token, @@ -453,6 +454,8 @@ export class MekRules extends UnitTypeRulesBase { const check = this.destroyedLegPSR(isQuadruped); psr.legsDestroyed?.forEach((loc => { checks.push({ + kind: PSR_CHECK_KIND.LEG_DESTROYED, + failure: FALL_PSR_FAILURE, fallCheck: check.fallCheck, pilotCheck: check.pilotCheck, loc: loc, @@ -463,14 +466,17 @@ export class MekRules extends UnitTypeRulesBase { } else { if (psr.shutdown) { checks.push({ + kind: PSR_CHECK_KIND.SHUTDOWN, + failure: FALL_PSR_FAILURE, fallCheck: 3, pilotCheck: 3, - kind: 'shutdown', reason: 'Shutdown' }); } if (turnState.dmgReceived() >= 20) { checks.push({ + kind: PSR_CHECK_KIND.DAMAGE_THRESHOLD, + failure: FALL_PSR_FAILURE, fallCheck: 1, pilotCheck: 1, reason: `Received ${turnState.dmgReceived()} damage` @@ -513,6 +519,8 @@ export class MekRules extends UnitTypeRulesBase { psr.legActuators?.forEach((count, loc) => { if (count <= 0) return; checks.push({ + kind: PSR_CHECK_KIND.LEG_DAMAGE, + failure: FALL_PSR_FAILURE, fallCheck: count, pilotCheck: count, loc, @@ -522,6 +530,8 @@ export class MekRules extends UnitTypeRulesBase { }); psr.hipsHit?.forEach(loc => { checks.push({ + kind: PSR_CHECK_KIND.LEG_DAMAGE, + failure: FALL_PSR_FAILURE, fallCheck: this.hipPSRModifier, pilotCheck: this.hipPSRModifier, loc, @@ -538,7 +548,7 @@ export class MekRules extends UnitTypeRulesBase { if (!check.loc) continue; const existing = checksByLeg.get(check.loc); if (!existing) { - checksByLeg.set(check.loc, check); + checksByLeg.set(check.loc, { ...check, kind: PSR_CHECK_KIND.LEG_DAMAGE }); continue; } checksByLeg.set(check.loc, { @@ -546,6 +556,7 @@ export class MekRules extends UnitTypeRulesBase { fallCheck: (existing.fallCheck ?? 0) + (check.fallCheck ?? 0), pilotCheck: (existing.pilotCheck ?? 0) + (check.pilotCheck ?? 0), legFilter: existing.legFilter ?? check.legFilter, + movementMode: existing.movementMode ?? check.movementMode, reason: this.formatLegActuatorPSRReasons(existing.reason, check.reason), modifierReason: this.formatLegActuatorModifierReason( this.formatLegActuatorPSRReasons(existing.reason, check.reason), @@ -604,6 +615,8 @@ export class MekRules extends UnitTypeRulesBase { protected gyroHitPSRCheck(_gyroHits: number): PSRCheck | null { if (this.hasHeavyDutyGyro()) return null; return { + kind: PSR_CHECK_KIND.GYRO_HIT, + failure: FALL_PSR_FAILURE, fallCheck: this.gyroHitPSRModifier, pilotCheck: this.gyroHitPSRModifier, reason: 'Gyro hit', @@ -614,6 +627,8 @@ export class MekRules extends UnitTypeRulesBase { protected destroyedGyroPSRCheck(): PSRCheck | null { if (this.hasHeavyDutyGyro()) return null; return { + kind: PSR_CHECK_KIND.GYRO_DESTROYED, + failure: FALL_PSR_FAILURE, fallCheck: this.gyroHitPSRModifier, pilotCheck: this.gyroHitPSRModifier, reason: 'Gyro hit', @@ -661,7 +676,7 @@ export class MekRules extends UnitTypeRulesBase { const isQuadruped = QUAD_LEG_LOCATIONS.some(loc => internalLocations.has(loc)); const destroyedLegsCount = this.systemsStatus().destroyedLegsCount; const damagedLegRequiresCheck = this.damagedLegRequiresMovementCheck(isQuadruped, destroyedLegsCount); - const destroyedLegsApplyHipCheck = this.destroyedLegsApplyHipMovementCheck( + const twoDestroyedQuadLegsApplyHipEffects = this.twoDestroyedQuadLegsApplyHipMovementEffects( isQuadruped, destroyedLegsCount, ); @@ -671,11 +686,14 @@ export class MekRules extends UnitTypeRulesBase { const check = this.damagedGyroMovementPSRCheck(moveMode); return check ? this.withPSRLocation(check, damagedGyro.loc) : null; } - if (destroyedLegsApplyHipCheck) { + if (twoDestroyedQuadLegsApplyHipEffects) { return { + kind: PSR_CHECK_KIND.QUAD_TWO_DESTROYED_LEGS_MOVEMENT, + failure: FALL_PSR_FAILURE, + movementMode: moveMode, fallCheck: 0, pilotCheck: 0, - reason: 'Jumping with damaged hip', + reason: 'Jumping with two destroyed legs', }; } if (hasDamagedLeg && damagedLegRequiresCheck) { @@ -685,6 +703,9 @@ export class MekRules extends UnitTypeRulesBase { destroyedLegsCount, ); return { + kind: PSR_CHECK_KIND.DAMAGED_LEG_MOVEMENT, + failure: FALL_PSR_FAILURE, + movementMode: moveMode, fallCheck: modifier, pilotCheck: modifier, ...(modifier === 0 && damagedLegLocation && { loc: damagedLegLocation }), @@ -693,9 +714,11 @@ export class MekRules extends UnitTypeRulesBase { } if (hasDamagedLegActuators) { return { + kind: PSR_CHECK_KIND.DAMAGED_LEG_ACTUATOR_MOVEMENT, + failure: FALL_PSR_FAILURE, + movementMode: moveMode, fallCheck: 0, pilotCheck: 0, - kind: 'damaged-leg-actuator-movement', reason: 'Jumping with damaged leg actuator' }; } @@ -706,17 +729,23 @@ export class MekRules extends UnitTypeRulesBase { const gyroMovementCheck = this.damagedGyroMovementPSRCheck(moveMode); if (gyroMovementCheck) return this.withPSRLocation(gyroMovementCheck, damagedGyro.loc); } - if (destroyedLegsApplyHipCheck) { + if (twoDestroyedQuadLegsApplyHipEffects) { return { + kind: PSR_CHECK_KIND.QUAD_TWO_DESTROYED_LEGS_MOVEMENT, + failure: FALL_PSR_FAILURE, + movementMode: moveMode, fallCheck: 0, pilotCheck: 0, - reason: 'Running with damaged hip', + reason: 'Running with two destroyed legs', }; } if (this.runningWithDestroyedLegRequiresCheck() && hasDamagedLeg && damagedLegRequiresCheck) { return { + kind: PSR_CHECK_KIND.DAMAGED_LEG_MOVEMENT, + failure: FALL_PSR_FAILURE, + movementMode: moveMode, fallCheck: 0, pilotCheck: 0, ...(damagedLegLocation && { loc: damagedLegLocation }), @@ -731,9 +760,11 @@ export class MekRules extends UnitTypeRulesBase { }); if (hasDamagedHip) { return { + kind: PSR_CHECK_KIND.DAMAGED_HIP_MOVEMENT, + failure: FALL_PSR_FAILURE, + movementMode: moveMode, fallCheck: 0, pilotCheck: 0, - kind: 'damaged-hip-movement', reason: 'Running with damaged hip' }; } @@ -745,6 +776,9 @@ export class MekRules extends UnitTypeRulesBase { if (this.hasHeavyDutyGyro()) { if (moveMode === 'run') return null; return { + kind: PSR_CHECK_KIND.DAMAGED_GYRO_MOVEMENT, + failure: FALL_PSR_FAILURE, + movementMode: moveMode, fallCheck: 2, pilotCheck: 2, reason: 'Jumping with damaged HD gyro', @@ -752,6 +786,9 @@ export class MekRules extends UnitTypeRulesBase { }; } return { + kind: PSR_CHECK_KIND.DAMAGED_GYRO_MOVEMENT, + failure: FALL_PSR_FAILURE, + movementMode: moveMode, fallCheck: 0, pilotCheck: 0, reason: `${moveMode === 'jump' ? 'Jumping' : 'Running'} with damaged gyro`, @@ -778,7 +815,10 @@ export class MekRules extends UnitTypeRulesBase { return true; } - protected destroyedLegsApplyHipMovementCheck(isQuadruped: boolean, destroyedLegsCount: number): boolean { + protected twoDestroyedQuadLegsApplyHipMovementEffects( + isQuadruped: boolean, + destroyedLegsCount: number, + ): boolean { return isQuadruped && destroyedLegsCount === 2; } @@ -1185,10 +1225,10 @@ export class MekRules extends UnitTypeRulesBase { // ── PSR ────────────────────────────────────────────────────────────────── - override readonly PSRModifiers = computed<{ modifier: number; modifiers: PSRCheck[] }>(() => { + override readonly PSRModifiers = computed<{ modifier: number; modifiers: PSRModifier[] }>(() => { const ignoreLeg = new Set(); let preExisting = 0; - const modifiers: PSRCheck[] = []; + const modifiers: PSRModifier[] = []; const { config, destroyedLegs } = this.currentLegState(); const undamagedLegs = destroyedLegs.length === 0; @@ -1298,7 +1338,7 @@ export class MekRules extends UnitTypeRulesBase { protected getPreExistingDestroyedLegPSRModifiers( config: MekConfig, destroyedLegs: readonly string[], - ): PSRCheck[] { + ): PSRModifier[] { if (config !== 'Quad') { const modifier = this.destroyedLegPSR(false).pilotCheck; return destroyedLegs.map(loc => ({ @@ -1326,7 +1366,7 @@ export class MekRules extends UnitTypeRulesBase { protected getPreExistingLegActuatorPSRModifiers( critSlots: readonly CriticalSlot[], ignoreLeg: Set, - ): { modifier: number; modifiers: PSRCheck[] } { + ): { modifier: number; modifiers: PSRModifier[] } { const relevantSlots = critSlots.filter(slot => slot.loc && LEG_LOCATIONS.has(slot.loc) && !ignoreLeg.has(slot.loc) @@ -1337,7 +1377,7 @@ export class MekRules extends UnitTypeRulesBase { slots.push(slot); slotsByLocation.set(slot.loc!, slots); } - const modifiers: PSRCheck[] = []; + const modifiers: PSRModifier[] = []; for (const [loc, slots] of slotsByLocation) { const destroyedHipsCount = slots.filter(slot => this.isNamedCrit(slot, 'Hip')).length; const destroyedLegActuatorsCount = slots.filter(slot => this.isNamedCrit(slot, 'Leg')).length; @@ -1375,7 +1415,7 @@ export class MekRules extends UnitTypeRulesBase { }).length; } - protected preExistingGyroPSRModifier(destroyedGyroCount: number): PSRCheck | null { + protected preExistingGyroPSRModifier(destroyedGyroCount: number): PSRModifier | null { if (destroyedGyroCount === 0) return null; if (this.hasHeavyDutyGyro()) { return { diff --git a/src/app/models/rules/tw-rules.spec.ts b/src/app/models/rules/tw-rules.spec.ts index 3fea4f4ea..69a60cf91 100644 --- a/src/app/models/rules/tw-rules.spec.ts +++ b/src/app/models/rules/tw-rules.spec.ts @@ -15,6 +15,7 @@ import { createEmptyUnit } from '../../testing/unit-test-helpers'; import { OptionsService } from '../../services/options.service'; import { TWMekRules } from './tw-rules'; import { MEK_LOCATIONS } from '../entity/types'; +import { FALL_PSR_FAILURE, PSR_CHECK_KIND } from './unit-type-rules'; class TestCBTForce extends CBTForce { override emitChanged(): void { @@ -168,7 +169,7 @@ describe('TWMekRules', () => { const checks = turnState.getPSRChecks(); expect(turnState.autoFall()).withContext(label).toBeFalse(); expect(checks.length).withContext(label).toBe(1); - expect(checks[0].failureOutcome).withContext(label).toBe('Fall'); + expect(checks[0].failure).withContext(label).toEqual(FALL_PSR_FAILURE); expect(turnState.isPSRCheckAutomaticFailure(checks[0])).withContext(label).toBeTrue(); expect(turnState.actionablePSRRollsCount()).withContext(label).toBe(0); } @@ -446,7 +447,7 @@ describe('TWMekRules', () => { expect(turnState.getPSRChecks()).toEqual([jasmine.objectContaining({ fallCheck: 0, pilotCheck: 0, - kind: 'damaged-leg-actuator-movement', + kind: PSR_CHECK_KIND.DAMAGED_LEG_ACTUATOR_MOVEMENT, reason: 'Jumping with damaged leg actuator', })]); expect(turnState.PSRRollsCount()).toBe(1); @@ -466,14 +467,16 @@ describe('TWMekRules', () => { turnState.moveMode.set('jump'); turnState.moveDistance.set(1); spyOn(forceUnit.rules, 'getCommittedDamageMovementModePSRCheck').and.returnValue({ + kind: PSR_CHECK_KIND.DAMAGED_LEG_ACTUATOR_MOVEMENT, + failure: FALL_PSR_FAILURE, + movementMode: 'jump', fallCheck: 0, pilotCheck: 0, - kind: 'damaged-leg-actuator-movement', reason: 'Localized movement check label', }); expect(turnState.getPSRChecks()).toEqual([jasmine.objectContaining({ - kind: 'damaged-leg-actuator-movement', + kind: PSR_CHECK_KIND.DAMAGED_LEG_ACTUATOR_MOVEMENT, reason: 'Localized movement check label', })]); }); diff --git a/src/app/models/rules/tw-rules.ts b/src/app/models/rules/tw-rules.ts index efe3b95a9..9b6858428 100644 --- a/src/app/models/rules/tw-rules.ts +++ b/src/app/models/rules/tw-rules.ts @@ -8,7 +8,14 @@ import { InfantryRules } from './infantry-rules'; import { MekRules, type MekLegDamageState, type MekLegMovementResult } from './mek-rules'; import { ProtoMekRules } from './protomek-rules'; import { VehicleRules } from './vehicle-rules'; -import type { ChargeDamage, PSRCheck, UnitHeatSource } from './unit-type-rules'; +import { + FALL_PSR_FAILURE, + PSR_CHECK_KIND, + type ChargeDamage, + type PSRCheck, + type PSRModifier, + type UnitHeatSource, +} from './unit-type-rules'; import type { CriticalSlot, SerializedC3NetworkGroup } from '../force-serialization'; import type { CBTForceUnit } from '../cbt-force-unit.model'; import { C3TaxCalculator } from '../c3-network.model'; @@ -155,6 +162,8 @@ export class TWMekRules extends MekRules { psr.legActuators?.forEach((count, loc) => { for (let index = 0; index < count; index++) { checks.push({ + kind: PSR_CHECK_KIND.LEG_ACTUATOR_HIT, + failure: FALL_PSR_FAILURE, fallCheck: 1, pilotCheck: 1, loc, @@ -164,6 +173,8 @@ export class TWMekRules extends MekRules { }); psr.hipsHit?.forEach(loc => { checks.push({ + kind: PSR_CHECK_KIND.HIP_HIT, + failure: FALL_PSR_FAILURE, fallCheck: this.hipPSRModifier, pilotCheck: this.hipPSRModifier, loc, @@ -180,13 +191,13 @@ export class TWMekRules extends MekRules { protected override getPreExistingLegActuatorPSRModifiers( critSlots: readonly CriticalSlot[], ignoreLeg: Set, - ): { modifier: number; modifiers: PSRCheck[] } { + ): { modifier: number; modifiers: PSRModifier[] } { let modifier = 0; - const modifiers: PSRCheck[] = []; + const modifiers: PSRModifier[] = []; const turnState = this.unit.turnState(); const currentPSR = turnState.getPSRCheckState(); const activeHipHits = new Set(turnState.getPSRChecks() - .filter(check => check.reason === 'Hip hit' && check.loc) + .filter(check => check.kind === PSR_CHECK_KIND.HIP_HIT && check.loc) .map(check => check.loc!)); const destroyedHips = critSlots.filter(slot => slot.loc && LEG_LOCATIONS.has(slot.loc) @@ -350,10 +361,17 @@ export class TWMekRules extends MekRules { const previouslyDestroyedGyroCount = this.unit.getCritSlots() .filter(slot => !this.unit.isEquipmentOperational(slot) && slot.name?.includes('Gyro')).length; if (previouslyDestroyedGyroCount + gyroHits === 1) { - return { pilotCheck: 1, reason: 'Gyro hit' }; + return { + kind: PSR_CHECK_KIND.GYRO_HIT, + failure: FALL_PSR_FAILURE, + pilotCheck: 1, + reason: 'Gyro hit', + }; } } return { + kind: PSR_CHECK_KIND.GYRO_HIT, + failure: FALL_PSR_FAILURE, fallCheck: this.gyroHitPSRModifier, pilotCheck: this.gyroHitPSRModifier, reason: 'Gyro hit', @@ -363,6 +381,8 @@ export class TWMekRules extends MekRules { protected override destroyedGyroPSRCheck(): PSRCheck { return { + kind: PSR_CHECK_KIND.GYRO_DESTROYED, + failure: FALL_PSR_FAILURE, fallCheck: 100, pilotCheck: 6, reason: 'Gyro destroyed', @@ -372,6 +392,9 @@ export class TWMekRules extends MekRules { protected override damagedGyroMovementPSRCheck(moveMode: 'run' | 'jump'): PSRCheck { return { + kind: PSR_CHECK_KIND.DAMAGED_GYRO_MOVEMENT, + failure: FALL_PSR_FAILURE, + movementMode: moveMode, fallCheck: 0, pilotCheck: 0, reason: `${moveMode === 'jump' ? 'Jumping' : 'Running'} with damaged gyro`, @@ -387,7 +410,7 @@ export class TWMekRules extends MekRules { .filter(slot => !this.unit.isEquipmentOperational(slot) && slot.name?.includes('Gyro')).length; } - protected override preExistingGyroPSRModifier(destroyedGyroCount: number): PSRCheck | null { + protected override preExistingGyroPSRModifier(destroyedGyroCount: number): PSRModifier | null { if (destroyedGyroCount === 0) return null; if (this.hasHeavyDutyGyro() && destroyedGyroCount === 1) { return { pilotCheck: 1, reason: 'Heavy Duty Gyro first damage' }; @@ -410,7 +433,7 @@ export class TWMekRules extends MekRules { protected override getPreExistingDestroyedLegPSRModifiers( config: MekConfig, destroyedLegs: readonly string[], - ): PSRCheck[] { + ): PSRModifier[] { if (config !== 'Quad') return super.getPreExistingDestroyedLegPSRModifiers(config, destroyedLegs); if (destroyedLegs.length !== 2) return []; return [{ @@ -440,7 +463,10 @@ export class TWMekRules extends MekRules { return false; } - protected override destroyedLegsApplyHipMovementCheck(_isQuadruped: boolean, _destroyedLegsCount: number): boolean { + protected override twoDestroyedQuadLegsApplyHipMovementEffects( + _isQuadruped: boolean, + _destroyedLegsCount: number, + ): boolean { return false; } diff --git a/src/app/models/rules/unit-type-rules.ts b/src/app/models/rules/unit-type-rules.ts index ebe5f93d0..f1d90dc34 100644 --- a/src/app/models/rules/unit-type-rules.ts +++ b/src/app/models/rules/unit-type-rules.ts @@ -30,26 +30,94 @@ import type { UnitSystemStatusFacts, } from '../equipment-status.model'; -export type PSRCheckKind = 'shutdown' | 'damaged-leg-actuator-movement' | 'damaged-hip-movement'; +export const PSR_CHECK_KIND = { + TORSO_DESTROYED: 'torso-destroyed', + SHUTDOWN: 'shutdown', + DAMAGE_THRESHOLD: 'damage-threshold', + LEG_DESTROYED: 'leg-destroyed', + LEG_DAMAGE: 'leg-damage', + LEG_ACTUATOR_HIT: 'leg-actuator-hit', + HIP_HIT: 'hip-hit', + GYRO_HIT: 'gyro-hit', + GYRO_DESTROYED: 'gyro-destroyed', + DAMAGED_GYRO_MOVEMENT: 'damaged-gyro-movement', + DAMAGED_LEG_MOVEMENT: 'damaged-leg-movement', + QUAD_TWO_DESTROYED_LEGS_MOVEMENT: 'quad-two-destroyed-legs-movement', + DAMAGED_LEG_ACTUATOR_MOVEMENT: 'damaged-leg-actuator-movement', + DAMAGED_HIP_MOVEMENT: 'damaged-hip-movement', +} as const; + +export type PSRCheckKind = typeof PSR_CHECK_KIND[keyof typeof PSR_CHECK_KIND]; + +export const PSR_FAILURE_KIND = { + FALL: 'fall', + RULE_RESOLUTION: 'rule-resolution', +} as const; + +export type PSRFailure = + | { readonly kind: typeof PSR_FAILURE_KIND.FALL } + | { + readonly kind: typeof PSR_FAILURE_KIND.RULE_RESOLUTION; + /** Presentation only. Rule behavior is owned by `resolution`. */ + readonly label: string; + }; -export interface PSRCheck { - id?: string; - fallCheck?: number; +export const FALL_PSR_FAILURE: { readonly kind: typeof PSR_FAILURE_KIND.FALL } = Object.freeze({ + kind: PSR_FAILURE_KIND.FALL, +}); + +/** Presentation-only modifier contributing to a PSR target. */ +export interface PSRModifier { pilotCheck?: number; - kind?: PSRCheckKind; reason: string; modifierReason?: string; - failureOutcome?: string; loc?: string; +} + +interface PSRCheckBase extends PSRModifier { + id?: string; + fallCheck?: number; + /** Stable rules identity. Never derive this from `reason`. */ + kind: PSRCheckKind; + /** Typed consequence. Presentation text must never drive resolution. */ + failure: PSRFailure; + movementMode?: 'run' | 'jump'; legFilter?: string; ignorePreExistingGyro?: boolean; - resolution?: { +} + +export interface FallingPSRCheck extends PSRCheckBase { + failure: { readonly kind: typeof PSR_FAILURE_KIND.FALL }; + resolution?: never; +} + +export interface RuleResolutionPSRCheck extends PSRCheckBase { + failure: { + readonly kind: typeof PSR_FAILURE_KIND.RULE_RESOLUTION; + readonly label: string; + }; + resolution: { key: string; token: string; }; } -export function sortPSRModifiers(modifiers: readonly PSRCheck[]): PSRCheck[] { +export type PSRCheck = FallingPSRCheck | RuleResolutionPSRCheck; + +export function isFallPSRCheck(check: PSRCheck): check is FallingPSRCheck { + return check.failure.kind === PSR_FAILURE_KIND.FALL; +} + +export function psrFailureLabel(check: PSRCheck): string { + switch (check.failure.kind) { + case PSR_FAILURE_KIND.FALL: + return 'Fall'; + case PSR_FAILURE_KIND.RULE_RESOLUTION: + return check.failure.label; + } +} + +export function sortPSRModifiers(modifiers: readonly PSRModifier[]): PSRModifier[] { return [...modifiers].sort((left, right) => { const leftIsNegative = (left.pilotCheck ?? 0) < 0; const rightIsNegative = (right.pilotCheck ?? 0) < 0; @@ -256,7 +324,7 @@ export interface UnitTypeRules { readonly controlRollFullLabel: string; /** Piloting Skill Roll modifiers. Non-Mek types return { modifier: 0, modifiers: [] }. */ - readonly PSRModifiers: Signal<{ modifier: number; modifiers: PSRCheck[] }>; + readonly PSRModifiers: Signal<{ modifier: number; modifiers: PSRModifier[] }>; /** PSR target roll number (piloting skill + modifiers). Non-Mek types return 0. */ readonly PSRTargetRoll: Signal; @@ -439,7 +507,7 @@ export interface UnitTypeRules { export abstract class UnitTypeRulesBase implements UnitTypeRules { readonly controlRollShortLabel: string; readonly controlRollFullLabel: string; - readonly PSRModifiers: Signal<{ modifier: number; modifiers: PSRCheck[] }> = signal({ modifier: 0, modifiers: [] }); + readonly PSRModifiers: Signal<{ modifier: number; modifiers: PSRModifier[] }> = signal({ modifier: 0, modifiers: [] }); readonly PSRTargetRoll: Signal = signal(0); readonly standingUpPSRModifier: number = 0; protected readonly ruleModifiers: Signal = computed(() => [ diff --git a/src/app/models/rules/vehicle-rules.ts b/src/app/models/rules/vehicle-rules.ts index 453f75352..3d18bc6aa 100644 --- a/src/app/models/rules/vehicle-rules.ts +++ b/src/app/models/rules/vehicle-rules.ts @@ -4,11 +4,11 @@ import { computed } from '@angular/core'; import type { CBTForceUnit, EquipmentAction } from '../cbt-force-unit.model'; -import type { CrewStateControlDefinition, CrewStateDefinition, UnitConditionControl, UnitRuleModifier } from './unit-type-rules'; +import type { CrewStateControlDefinition, CrewStateDefinition, PSRModifier, UnitConditionControl, UnitRuleModifier } from './unit-type-rules'; import type { EquipmentStatus, EquipmentStatusFacts, UnitSystemStatusFacts } from '../equipment-status.model'; import type { ToHitModifierBreakdownEntry } from './game-rules'; import { crewStateDefinitions, sortPSRModifiers, unitConditionControls, UnitTypeRulesBase } from './unit-type-rules'; -import type { PSRCheck, TurnState } from '../turn-state.model'; +import type { TurnState } from '../turn-state.model'; import type { MountedEquipment } from '../mounted-equipment.model'; import type { CriticalSlot } from '../force-serialization'; import { WeaponEquipment } from '../equipment.model'; @@ -255,7 +255,7 @@ export class VehicleRules extends UnitTypeRulesBase { } } - override readonly PSRModifiers = computed<{ modifier: number; modifiers: PSRCheck[] }>(() => { + override readonly PSRModifiers = computed<{ modifier: number; modifiers: PSRModifier[] }>(() => { const projected = this.psrModifiers(); return { modifier: projected.reduce((total, modifier) => total + modifier.modifier, 0), diff --git a/src/app/models/turn-state.model.spec.ts b/src/app/models/turn-state.model.spec.ts index fde229745..6dc2b53ed 100644 --- a/src/app/models/turn-state.model.spec.ts +++ b/src/app/models/turn-state.model.spec.ts @@ -9,7 +9,13 @@ import { type CriticalSlot, type HeatProfile } from './force-serialization'; import { AeroRules } from './rules/aero-rules'; import { InfantryRules } from './rules/infantry-rules'; import { MekRules } from './rules/mek-rules'; -import type { UnitTypeRules } from './rules/unit-type-rules'; +import { + FALL_PSR_FAILURE, + PSR_CHECK_KIND, + PSR_FAILURE_KIND, + type PSRCheck, + type UnitTypeRules, +} from './rules/unit-type-rules'; import type { UnitSummary } from './unit-summary.model'; import { calculateHeatProjection, TurnState } from './turn-state.model'; import { Equipment, MiscEquipment } from './equipment.model'; @@ -482,7 +488,7 @@ describe('TurnState', () => { const check = turnState.getPSRChecks().find(entry => entry.fallCheck !== undefined)!; expect(check.id).toBeDefined(); - expect(check.failureOutcome).toBe('Fall'); + expect(check.failure).toEqual(FALL_PSR_FAILURE); expect(turnState.resolvePSRCheck(check.id!, 'success')).toBeTrue(); expect(turnState.PSRRollsCount()).toBe(0); @@ -498,7 +504,8 @@ describe('TurnState', () => { it('fails every check with the same outcome and applies prone', () => { const { turnState } = createTurnStateHarness({ rulesId: 'tw' }); turnState.setPSRCheckState({ legActuators: new Map([['LL', 2]]) }); - const checks = turnState.getPSRChecks().filter(entry => entry.reason === 'Leg actuator hit'); + const checks = turnState.getPSRChecks() + .filter(entry => entry.kind === PSR_CHECK_KIND.LEG_ACTUATOR_HIT); expect(checks.length).toBe(2); expect(checks[0].id).not.toBe(checks[1].id); @@ -510,12 +517,28 @@ describe('TurnState', () => { expect(turnState.PSRRollsCount()).toBe(0); }); - it('groups unresolved failures by outcome without overwriting resolved checks', () => { + it('cascades typed fall failures without overwriting resolved or independent checks', () => { const { turnState, rules } = createTurnStateHarness(); spyOn(rules, 'getPSRChecks').and.returnValue([ - { reason: 'First fall check', fallCheck: 0, failureOutcome: 'Fall' }, - { reason: 'Second fall check', fallCheck: 1, failureOutcome: 'Fall' }, - { reason: 'Control check', fallCheck: 2, failureOutcome: 'Immobilized' }, + { + kind: PSR_CHECK_KIND.GYRO_HIT, + failure: FALL_PSR_FAILURE, + reason: 'First fall check', + fallCheck: 0, + }, + { + kind: PSR_CHECK_KIND.DAMAGE_THRESHOLD, + failure: FALL_PSR_FAILURE, + reason: 'Second fall check', + fallCheck: 1, + }, + { + kind: PSR_CHECK_KIND.TORSO_DESTROYED, + failure: { kind: PSR_FAILURE_KIND.RULE_RESOLUTION, label: 'Immobilized' }, + reason: 'Control check', + fallCheck: 2, + resolution: { key: 'control-check', token: 'control-1' }, + }, ]); const [firstFall, secondFall, control] = turnState.getPSRChecks(); @@ -529,9 +552,25 @@ describe('TurnState', () => { it('does not expose fall rolls made moot by an automatic fall', () => { const { turnState, rules } = createTurnStateHarness(); spyOn(rules, 'getPSRChecks').and.returnValue([ - { reason: 'First fall check', fallCheck: 0, failureOutcome: 'Fall' }, - { reason: 'Second fall check', fallCheck: 1, failureOutcome: 'Fall' }, - { reason: 'Control check', fallCheck: 2, failureOutcome: 'Immobilized' }, + { + kind: PSR_CHECK_KIND.GYRO_HIT, + failure: FALL_PSR_FAILURE, + reason: 'First fall check', + fallCheck: 0, + }, + { + kind: PSR_CHECK_KIND.DAMAGE_THRESHOLD, + failure: FALL_PSR_FAILURE, + reason: 'Second fall check', + fallCheck: 1, + }, + { + kind: PSR_CHECK_KIND.TORSO_DESTROYED, + failure: { kind: PSR_FAILURE_KIND.RULE_RESOLUTION, label: 'Fall' }, + reason: 'Control check', + fallCheck: 2, + resolution: { key: 'control-check', token: 'control-1' }, + }, ]); expect(turnState.PSRRollsCount()).toBe(3); @@ -547,11 +586,23 @@ describe('TurnState', () => { it('does not trigger another fall when a fall PSR is resolved while already prone', () => { const { turnState, rules } = createTurnStateHarness({ prone: true }); spyOn(rules, 'getPSRChecks').and.returnValue([ - { reason: 'Fall check', fallCheck: 0, failureOutcome: 'Fall' }, - { reason: 'Control check', fallCheck: 1, failureOutcome: 'Immobilized' }, + { + kind: PSR_CHECK_KIND.DAMAGE_THRESHOLD, + failure: FALL_PSR_FAILURE, + reason: 'Fall check', + fallCheck: 0, + }, + { + kind: PSR_CHECK_KIND.TORSO_DESTROYED, + failure: { kind: PSR_FAILURE_KIND.RULE_RESOLUTION, label: 'Immobilized' }, + reason: 'Control check', + fallCheck: 1, + resolution: { key: 'control-check', token: 'control-1' }, + }, ]); - const fallCheck = turnState.getPSRChecks().find(check => check.reason === 'Fall check'); + const fallCheck = turnState.getPSRChecks() + .find(check => check.kind === PSR_CHECK_KIND.DAMAGE_THRESHOLD); expect(fallCheck?.id).toBeDefined(); expect(turnState.resolvePSRCheck(fallCheck!.id!, 'failed')).toBeTrue(); @@ -563,8 +614,19 @@ describe('TurnState', () => { it('does not offer any PSR to a unit without a conscious pilot', () => { const { turnState, rules } = createTurnStateHarness({ crewState: 'unconscious' }); spyOn(rules, 'getPSRChecks').and.returnValue([ - { reason: 'Fall check', fallCheck: 0, failureOutcome: 'Fall' }, - { reason: 'System check', fallCheck: 1, failureOutcome: 'Crippled' }, + { + kind: PSR_CHECK_KIND.DAMAGE_THRESHOLD, + failure: FALL_PSR_FAILURE, + reason: 'Fall check', + fallCheck: 0, + }, + { + kind: PSR_CHECK_KIND.TORSO_DESTROYED, + failure: { kind: PSR_FAILURE_KIND.RULE_RESOLUTION, label: 'Crippled' }, + reason: 'System check', + fallCheck: 1, + resolution: { key: 'system-check', token: 'system-1' }, + }, ]); expect(turnState.automaticPSRFailure()).toBeTrue(); @@ -576,10 +638,18 @@ describe('TurnState', () => { const mixed = createTurnStateHarness({ shutdown: true }); const forcedOnly = createTurnStateHarness({ shutdown: true }); const prone = createTurnStateHarness({ shutdown: true, prone: true }); - const shutdown = { - kind: 'shutdown', reason: 'Shutdown', fallCheck: 3, failureOutcome: 'Fall', - } as const; - const later = { reason: 'Received 20 damage', fallCheck: 1, failureOutcome: 'Fall' } as const; + const shutdown: PSRCheck = { + kind: PSR_CHECK_KIND.SHUTDOWN, + failure: FALL_PSR_FAILURE, + reason: 'Shutdown', + fallCheck: 3, + }; + const later: PSRCheck = { + kind: PSR_CHECK_KIND.DAMAGE_THRESHOLD, + failure: FALL_PSR_FAILURE, + reason: 'Received 20 damage', + fallCheck: 1, + }; spyOn(mixed.rules, 'getPSRChecks').and.returnValue([shutdown, later]); spyOn(forcedOnly.rules, 'getPSRChecks').and.returnValue([later]); spyOn(prone.rules, 'getPSRChecks').and.returnValue([later]); @@ -593,6 +663,27 @@ describe('TurnState', () => { expect(prone.turnState.isPSRCheckAutomaticFailure(later)).toBeFalse(); }); + it('keeps persistence identity stable when presentation copy changes', () => { + const first = createTurnStateHarness(); + const second = createTurnStateHarness(); + const typedCheck = { + kind: PSR_CHECK_KIND.DAMAGE_THRESHOLD, + failure: FALL_PSR_FAILURE, + fallCheck: 1, + } as const; + spyOn(first.rules, 'getPSRChecks').and.returnValue([{ + ...typedCheck, + reason: 'Received 20 damage', + }]); + spyOn(second.rules, 'getPSRChecks').and.returnValue([{ + ...typedCheck, + reason: 'Localized or rewritten copy', + }]); + + expect(first.turnState.getPSRChecks()[0].id) + .toBe(second.turnState.getPSRChecks()[0].id); + }); + it('round-trips turn signals and PSR check state through a plain object', () => { const { turnState } = createTurnStateHarness(); turnState.airborne.set(true); diff --git a/src/app/models/turn-state.model.ts b/src/app/models/turn-state.model.ts index b1c05f1d2..565d704fd 100644 --- a/src/app/models/turn-state.model.ts +++ b/src/app/models/turn-state.model.ts @@ -20,7 +20,7 @@ import type { SerializedPSRChecks, SerializedTurnState, } from "./force-serialization"; -import { calculateModifierTotal, type PSRCheck, type UnitHeatSource, type UnitModifierBreakdownEntry, type UnitModifierTotal } from "./rules/unit-type-rules"; +import { calculateModifierTotal, isFallPSRCheck, PSR_CHECK_KIND, type PSRCheck, type UnitHeatSource, type UnitModifierBreakdownEntry, type UnitModifierTotal } from "./rules/unit-type-rules"; import { deserializeUnitCover, isUnitBuildingLevel, isUnitWaterDepth, resolveUnitBuildingCoverState, resolveUnitWaterState, serializeUnitCover, type UnitCover } from "./unit-cover.model"; import { closePilotDamagePhase, @@ -189,7 +189,6 @@ export class TurnState { return { ...check, id: occurrence === 0 ? baseId : `${baseId}#${occurrence + 1}`, - failureOutcome: check.failureOutcome ?? 'Fall', }; }); }); @@ -248,7 +247,7 @@ export class TurnState { const checks = this.unresolvedPSRChecks() .filter(check => !this.isPSRCheckAutomaticFailure(check)); return this.autoFall() - ? checks.filter(check => check.failureOutcome !== 'Fall').length + ? checks.filter(check => !isFallPSRCheck(check)).length : checks.length; }); @@ -266,7 +265,7 @@ export class TurnState { || (unit.getUnit().type === 'Mek' && unit.getCondition('shutdown') && !unit.getCondition('prone') - && check.kind !== 'shutdown'); + && check.kind !== PSR_CHECK_KIND.SHUTDOWN); } getPSROutcome(checkId: string): RuleCheckOutcome | undefined { @@ -276,11 +275,11 @@ export class TurnState { resolvePSRCheck(checkId: string, outcome: RuleCheckOutcome): boolean { const check = this.getPSRChecks().find(entry => entry.id === checkId); if (!check || check.resolution || this.getPSROutcome(checkId)) return false; - const resolvedChecks = outcome === 'failed' + const resolvedChecks = outcome === 'failed' && isFallPSRCheck(check) ? this.getPSRChecks().filter(entry => !entry.resolution && entry.id !== undefined - && entry.failureOutcome === check.failureOutcome + && isFallPSRCheck(entry) && this.getPSROutcome(entry.id) === undefined ) : [check]; @@ -289,7 +288,7 @@ export class TurnState { ...Object.fromEntries(resolvedChecks.map(entry => [entry.id!, outcome])), })); if (outcome === 'failed') { - if (check.failureOutcome === 'Fall' && !this.unitState.hasCondition('prone')) { + if (isFallPSRCheck(check) && !this.unitState.hasCondition('prone')) { this.unitState.unit.queueFall('psr'); this.unitState.unit.setCondition('prone', true); } @@ -372,12 +371,13 @@ export class TurnState { private psrCheckBaseId(check: PSRCheck): string { return [ - check.reason.replace(/\d+(?:\.\d+)?/g, '#'), + 'psr', + check.kind, + check.movementMode ?? '', check.loc ?? '', check.legFilter ?? '', - check.fallCheck ?? '', - check.pilotCheck ?? '', - check.ignorePreExistingGyro ? 'ignore-gyro' : '', + check.resolution?.key ?? '', + check.resolution?.token ?? '', ].join('|'); } diff --git a/src/app/services/cbt-phase-resolution.service.spec.ts b/src/app/services/cbt-phase-resolution.service.spec.ts index bd8852040..54578d797 100644 --- a/src/app/services/cbt-phase-resolution.service.spec.ts +++ b/src/app/services/cbt-phase-resolution.service.spec.ts @@ -4,7 +4,12 @@ import { TestBed } from '@angular/core/testing'; import type { CBTForceUnit } from '../models/cbt-force-unit.model'; -import type { PSRCheck } from '../models/rules/unit-type-rules'; +import { + FALL_PSR_FAILURE, + isFallPSRCheck, + PSR_CHECK_KIND, + type PSRCheck, +} from '../models/rules/unit-type-rules'; import type { AutomationMode } from '../models/options.model'; import { CBTPhaseResolutionService } from './cbt-phase-resolution.service'; import { FallingResolutionService } from './falling-resolution.service'; @@ -178,16 +183,16 @@ describe('CBTPhaseResolutionService', () => { harness.mode = 'yes'; harness.checks = [ { - id: 'shutdown', kind: 'shutdown', reason: 'Shutdown', - fallCheck: 3, failureOutcome: 'Fall', + id: 'shutdown', kind: PSR_CHECK_KIND.SHUTDOWN, + failure: FALL_PSR_FAILURE, reason: 'Shutdown', fallCheck: 3, }, { - id: 'damage', reason: 'Received 20 damage', - fallCheck: 1, failureOutcome: 'Fall', + id: 'damage', kind: PSR_CHECK_KIND.DAMAGE_THRESHOLD, + failure: FALL_PSR_FAILURE, reason: 'Received 20 damage', fallCheck: 1, }, ]; spyOn(harness.unit.turnState(), 'isPSRCheckAutomaticFailure') - .and.callFake(check => check.kind !== 'shutdown'); + .and.callFake(check => check.kind !== PSR_CHECK_KIND.SHUTDOWN); spyOn(Math, 'random').and.returnValues(0.99, 0.99); resumeFall.and.callFake(async () => { harness.pendingFallId = undefined; @@ -342,7 +347,7 @@ function createHarness(): PhaseHarness { if (harness.outcomes.has(id)) return false; harness.outcomes.set(id, outcome); const check = harness.checks.find(candidate => candidate.id === id); - if (outcome === 'failed' && check?.failureOutcome === 'Fall' && !harness.prone) { + if (outcome === 'failed' && check && isFallPSRCheck(check) && !harness.prone) { harness.prone = true; harness.pendingFallId = 'fall:psr'; } @@ -385,5 +390,11 @@ function createHarness(): PhaseHarness { } function fallCheck(id: string): PSRCheck { - return { id, fallCheck: 0, reason: id, failureOutcome: 'Fall' }; + return { + id, + kind: PSR_CHECK_KIND.DAMAGE_THRESHOLD, + failure: FALL_PSR_FAILURE, + fallCheck: 0, + reason: id, + }; } diff --git a/src/app/services/cbt-phase-resolution.service.ts b/src/app/services/cbt-phase-resolution.service.ts index 1019cb2ce..d57ee9c42 100644 --- a/src/app/services/cbt-phase-resolution.service.ts +++ b/src/app/services/cbt-phase-resolution.service.ts @@ -4,7 +4,7 @@ import { inject, Injectable } from '@angular/core'; import type { CBTForceUnit } from '../models/cbt-force-unit.model'; -import type { PSRCheck } from '../models/rules/unit-type-rules'; +import { isFallPSRCheck, type PSRCheck } from '../models/rules/unit-type-rules'; import { CBTAutomationToastService } from './cbt-automation-toast.service'; import { FallingResolutionService } from './falling-resolution.service'; import { MekCriticalResolutionService } from './mek-critical-resolution.service'; @@ -41,11 +41,11 @@ export function resolvePilotSkillChecksAutomatically( // An automatic fall is resolved after any independent checks. This // prevents becoming prone from making an unrelated check disappear. const check = turnState.autoFall() - ? unresolved.find(candidate => candidate.failureOutcome !== 'Fall') ?? unresolved[0] + ? unresolved.find(candidate => !isFallPSRCheck(candidate)) ?? unresolved[0] : unresolved[0]; const target = unit.PSRTargetRoll(); const automaticFailure = turnState.isPSRCheckAutomaticFailure(check) - || (turnState.autoFall() && check.failureOutcome === 'Fall'); + || (turnState.autoFall() && isFallPSRCheck(check)); const dice = automaticFailure ? null : [rollD6(random), rollD6(random)] as const; diff --git a/src/app/services/options.service.spec.ts b/src/app/services/options.service.spec.ts index 0ba1849d4..8c2b98f2e 100644 --- a/src/app/services/options.service.spec.ts +++ b/src/app/services/options.service.spec.ts @@ -254,6 +254,61 @@ describe('OptionsService', () => { }); }); + it('uses the current CBT automation defaults', async () => { + savedOptions = null; + + const service = await createService(); + + expect(service.options().cbtAutomationOptions).toEqual({ + pilotSkillCheck: 'no', + heatAndDissipationResolution: 'no', + heatEffectsCheck: 'no', + pilotHitsAndConsciousnessCheck: 'no', + internalExplosionsCheck: 'ask', + criticalHitChanceCheck: 'no', + breachAndFloodCheck: 'yes', + fallingCheck: 'no', + }); + }); + + it('restores and validates current CBT automation modes per setting', async () => { + savedOptions = { + cbtAutomationOptions: { + pilotSkillCheck: 'yes', + heatAndDissipationResolution: 'ask', + heatEffectsCheck: 'sometimes', + internalExplosionsCheck: 'no', + breachAndFloodCheck: 'ask', + }, + }; + + const service = await createService(); + + expect(service.options().cbtAutomationOptions).toEqual({ + pilotSkillCheck: 'yes', + heatAndDissipationResolution: 'ask', + heatEffectsCheck: 'no', + pilotHitsAndConsciousnessCheck: 'no', + internalExplosionsCheck: 'no', + criticalHitChanceCheck: 'no', + breachAndFloodCheck: 'ask', + fallingCheck: 'no', + }); + }); + + it('updates and persists the canonical CBT automation settings', async () => { + savedOptions = null; + const service = await createService(); + + await service.setCbtAutomationMode('pilotSkillCheck', 'yes'); + + expect(service.cbtAutomationMode('pilotSkillCheck')).toBe('yes'); + expect(service.options().cbtAutomationOptions.pilotSkillCheck).toBe('yes'); + expect(dbService.saveOptions).toHaveBeenCalledOnceWith(jasmine.objectContaining({ + cbtAutomationOptions: jasmine.objectContaining({ pilotSkillCheck: 'yes' }), + })); + }); + it('restores structured CBT optional rules', async () => { savedOptions = { CBTOptionalRules: { diff --git a/src/app/services/options.service.ts b/src/app/services/options.service.ts index cc2f89910..e5af88acd 100644 --- a/src/app/services/options.service.ts +++ b/src/app/services/options.service.ts @@ -270,9 +270,6 @@ function resolveUnitServers(saved: unknown): string[] { @Injectable({ providedIn: 'root' }) export class OptionsService { private dbService = inject(DbService); - private readonly cbtAutomationOptionsState = signal({ - ...DEFAULT_OPTIONS.cbtAutomationOptions, - }); readonly initialized = signal(false); public options = signal({ @@ -317,7 +314,6 @@ export class OptionsService { async initOptions() { const saved = await this.dbService.getOptions(); const cbtAutomationOptions = resolveCBTAutomationOptions(saved); - this.cbtAutomationOptionsState.set(cbtAutomationOptions); this.options.set({ colorScheme: resolveSavedValue(saved?.colorScheme, DEFAULT_OPTIONS.colorScheme, OPTION_VALUES.colorScheme), pickerStyle: resolveSavedValue(saved?.pickerStyle, DEFAULT_OPTIONS.pickerStyle, OPTION_VALUES.pickerStyle), @@ -358,41 +354,23 @@ export class OptionsService { } async setOption(key: K, value: Options[K]) { - if (key === 'cbtAutomationOptions') { - await this.setCbtAutomationOptions(value as CBTAutomationOptions); - return; - } - const updated = { ...this.options(), [key]: value }; this.options.set(updated); await this.dbService.saveOptions(updated); } async setCbtAutomationMode(key: CBTAutomationKey, value: AutomationMode) { - const current = this.cbtAutomationOptionsState(); + const current = this.options().cbtAutomationOptions; if (current[key] === value) { return; } - const cbtAutomationOptions = { ...current, [key]: value }; - await this.setCbtAutomationOptions(cbtAutomationOptions); - } - - private async setCbtAutomationOptions(cbtAutomationOptions: CBTAutomationOptions) { - this.cbtAutomationOptionsState.set(cbtAutomationOptions); - - // Keep the compatibility snapshot current without invalidating every - // consumer of the global options signal for this granular setting. - this.options().cbtAutomationOptions = cbtAutomationOptions; - await this.dbService.saveOptions({ - ...this.options(), - cbtAutomationOptions, - }); + await this.setOption('cbtAutomationOptions', { ...current, [key]: value }); } /** Returns the configured mode for one CBT automation. */ cbtAutomationMode(key: CBTAutomationKey): AutomationMode { - return this.cbtAutomationOptionsState()[key]; + return this.options().cbtAutomationOptions[key]; } async updateForceGeneratorOptions( diff --git a/src/app/services/unit-check-resolution.service.spec.ts b/src/app/services/unit-check-resolution.service.spec.ts index 2d5866810..4afc29b11 100644 --- a/src/app/services/unit-check-resolution.service.spec.ts +++ b/src/app/services/unit-check-resolution.service.spec.ts @@ -8,6 +8,7 @@ import { of } from 'rxjs'; import type { CBTForceUnit } from '../models/cbt-force-unit.model'; import type { PendingEventInput, SerializedPendingUnitCheck } from '../models/force-serialization'; import type { AutomationMode } from '../models/options.model'; +import { FALL_PSR_FAILURE, PSR_CHECK_KIND } from '../models/rules/unit-type-rules'; import type { HeatAmmoExplosionCandidate } from '../utils/heat-effects.util'; import { pendingCheckReviewGroupList, @@ -45,9 +46,10 @@ describe('UnitCheckResolutionService', () => { ])); const psrChecks = Array.from({ length: psrCount }, (_value, index) => ({ id: `psr:${index + 1}`, + kind: PSR_CHECK_KIND.DAMAGE_THRESHOLD, + failure: FALL_PSR_FAILURE, fallCheck: 0, reason: `PSR ${index + 1}`, - failureOutcome: 'Fall', })); const psrOutcomes = new Map(); const psrOutcomeSelections = signal>>({}); From 6f6bc589ed24a0fb90e26681e13a1bf1bf6d9681 Mon Sep 17 00:00:00 2001 From: exeea Date: Thu, 27 Aug 2026 16:51:41 +0200 Subject: [PATCH 35/87] quad hip effect core vs TW --- src/app/models/rules/mek-rules.spec.ts | 130 +++++++++++++++++++++++++ src/app/models/rules/mek-rules.ts | 91 ++++++++++++++--- src/app/models/rules/tw-rules.ts | 5 + 3 files changed, 211 insertions(+), 15 deletions(-) diff --git a/src/app/models/rules/mek-rules.spec.ts b/src/app/models/rules/mek-rules.spec.ts index 3ddfc7966..72083e66f 100644 --- a/src/app/models/rules/mek-rules.spec.ts +++ b/src/app/models/rules/mek-rules.spec.ts @@ -3575,6 +3575,92 @@ describe('MekRules', () => { } }); + it('starts standard CORE quad hip movement and PSR effects with the second hip hit', () => { + const locations = ['FLL', 'FRL', 'RLL', 'RRL']; + + for (let hipHits = 0; hipHits <= locations.length; hipHits++) { + const standardHipHits = Math.max(0, hipHits - 1); + const rules = createRulesHarness({ + subtype: 'Quad BattleMek', + internalLocations: locations, + critSlots: locations.slice(0, hipHits).map((loc, index) => ({ + ...crit('Hip'), + id: `${loc}-hip`, + loc, + slot: 0, + destroyed: index + 1, + })), + walk: 5, + run: 8, + }); + const movement = rules.movementState(); + const hipPSRModifier = rules.PSRModifiers().modifiers + .filter(modifier => modifier.reason === 'Hip Destroyed') + .reduce((total, modifier) => total + (modifier.pilotCheck ?? 0), 0); + + expect(rules.systemsStatus().destroyedHipsCount).withContext(`${hipHits} hip hits`).toBe(hipHits); + expect(movement).withContext(`${hipHits} hip hits`).toEqual(jasmine.objectContaining({ + walk: 5 - standardHipHits, + run: Math.round((5 - standardHipHits) * 1.5), + moveImpaired: standardHipHits > 0, + })); + expect(hipPSRModifier).withContext(`${hipHits} hip hits`).toBe(standardHipHits); + expect(rules.getCommittedDamageMovementModePSRCheck('run', 1)?.kind) + .withContext(`${hipHits} hip hits while running`) + .toBe(standardHipHits > 0 ? PSR_CHECK_KIND.DAMAGED_HIP_MOVEMENT : undefined); + expect(rules.getCommittedDamageMovementModePSRCheck('jump', 1)?.kind) + .withContext(`${hipHits} hip hits while jumping`) + .toBe(standardHipHits > 0 ? PSR_CHECK_KIND.DAMAGED_LEG_ACTUATOR_MOVEMENT : undefined); + } + }); + + it('starts current-hit CORE quad hip PSRs with the second hip hit', () => { + const locations = ['FLL', 'FRL', 'RLL', 'RRL']; + + for (let hipHits = 1; hipHits <= locations.length; hipHits++) { + const forceUnit = createForceUnitHarness({ + subtype: 'Quad BattleMek', + internalLocations: locations, + critSlots: locations.slice(0, hipHits).map((loc, index) => ({ + ...crit('Hip', false), + id: `${loc}-hip`, + loc, + slot: 0, + destroying: index + 1, + })), + }); + forceUnit.getCritSlots().forEach(slot => forceUnit.rules.evaluateCritSlotHit(slot)); + const hipChecks = forceUnit.turnState().getPSRChecks() + .filter(check => check.reason === 'Hip hit'); + + expect(hipChecks.length).withContext(`${hipHits} current hip hits`).toBe(hipHits - 1); + expect(hipChecks.reduce((total, check) => total + (check.pilotCheck ?? 0), 0)) + .withContext(`${hipHits} current hip hits`) + .toBe(hipHits - 1); + } + }); + + it('applies a CORE quad hip PSR when the first hip was destroyed on an earlier turn', () => { + const forceUnit = createForceUnitHarness({ + subtype: 'Quad BattleMek', + internalLocations: ['FLL', 'FRL', 'RLL', 'RRL'], + critSlots: [ + { ...crit('Hip'), id: 'FLL-hip', loc: 'FLL', slot: 0, destroyed: 1 }, + { ...crit('Hip', false), id: 'FRL-hip', loc: 'FRL', slot: 0, destroying: 2 }, + ], + }); + const currentHip = forceUnit.getCritSlots().find(slot => slot.loc === 'FRL')!; + + forceUnit.rules.evaluateCritSlotHit(currentHip); + + expect(forceUnit.turnState().getPSRChecks()).toEqual([jasmine.objectContaining({ + loc: 'FRL', + fallCheck: 1, + pilotCheck: 1, + reason: 'Hip hit', + })]); + }); + it('keeps the fixed Core 1/2 profile when the sole remaining Quad leg has actuator damage', () => { const rules = createRulesHarness({ internalLocations: ['RLL', 'FLL', 'RRL', 'FRL'], @@ -3644,6 +3730,50 @@ describe('MekRules', () => { expect(rules.movementState()).toEqual(jasmine.objectContaining({ walk: 2, run: 3 })); }); + it('applies every TW Quad hip hit to movement and PSRs from the first hit', () => { + const locations = ['RLL', 'FLL', 'RRL', 'FRL']; + const expectedMovement = [ + { walk: 5, run: 8 }, + { walk: 3, run: 5 }, + { walk: 2, run: 3 }, + { walk: 1, run: 2 }, + { walk: 0, run: 0 }, + ]; + + for (let hipHits = 0; hipHits <= locations.length; hipHits++) { + const rules = createRulesHarness({ + subtype: 'Quad BattleMek', + internalLocations: locations, + critSlots: locations.slice(0, hipHits).map((loc, index) => ({ + ...crit('Hip'), + id: `${loc}-hip`, + loc, + slot: 0, + destroyed: index + 1, + })), + rulesId: 'tw', + walk: 5, + run: 8, + }); + const hipPSRModifier = rules.PSRModifiers().modifiers + .filter(modifier => modifier.reason === 'Hip Destroyed') + .reduce((total, modifier) => total + (modifier.pilotCheck ?? 0), 0); + + expect(rules.movementState()).withContext(`${hipHits} hip hits`) + .toEqual(jasmine.objectContaining({ + ...expectedMovement[hipHits], + moveImpaired: hipHits > 0, + })); + expect(hipPSRModifier).withContext(`${hipHits} hip hits`).toBe(hipHits * 2); + expect(rules.getCommittedDamageMovementModePSRCheck('run', 1)?.kind) + .withContext(`${hipHits} hip hits while running`) + .toBe(hipHits > 0 ? PSR_CHECK_KIND.DAMAGED_HIP_MOVEMENT : undefined); + expect(rules.getCommittedDamageMovementModePSRCheck('jump', 1)?.kind) + .withContext(`${hipHits} hip hits while jumping`) + .toBe(hipHits > 0 ? PSR_CHECK_KIND.DAMAGED_LEG_ACTUATOR_MOVEMENT : undefined); + } + }); + it('reduces TW ground MP to zero at the terminal hip threshold without making the Mek immobile', () => { const scenarios = [ { name: 'biped', locations: ['LL', 'RL'], hips: ['LL', 'RL'] }, diff --git a/src/app/models/rules/mek-rules.ts b/src/app/models/rules/mek-rules.ts index 8d310ab48..35055446d 100644 --- a/src/app/models/rules/mek-rules.ts +++ b/src/app/models/rules/mek-rules.ts @@ -100,6 +100,11 @@ export class MekRules extends UnitTypeRulesBase { override readonly standingUpPSRModifier: number = -1; protected get gyroHitPSRModifier(): number { return 2; } protected get hipPSRModifier(): number { return 1; } + + /** The first hip hit on a CORE quad has only the quad-specific effects. */ + protected standardHipEffectHitCount(totalHipHits: number, isQuadruped: boolean): number { + return Math.max(0, totalHipHits - (isQuadruped ? 1 : 0)); + } protected get lowerArmFireModifier(): number { return 0; } protected get footHitsCausePSR(): boolean { return false; } protected get shieldBashPunchBonusEnabled(): boolean { return true; } @@ -528,7 +533,7 @@ export class MekRules extends UnitTypeRulesBase { modifierReason: this.formatLegActuatorModifierReason('Leg Actuator hit', count), }); }); - psr.hipsHit?.forEach(loc => { + this.currentStandardHipHitLocations(psr.hipsHit).forEach(loc => { checks.push({ kind: PSR_CHECK_KIND.LEG_DAMAGE, failure: FALL_PSR_FAILURE, @@ -567,6 +572,47 @@ export class MekRules extends UnitTypeRulesBase { return Array.from(checksByLeg.values()); } + private currentStandardHipHitLocations(currentHipHits: ReadonlySet | undefined): string[] { + const currentLocations = Array.from(currentHipHits ?? []); + if (currentLocations.length === 0) return []; + + const isQuadruped = this.isQuadrupedMek(); + const committedHipHits = this.unit.getCritSlots().filter(slot => slot.loc + && LEG_LOCATIONS.has(slot.loc) + && !this.isLegDestroyed(slot.loc, true) + && this.isNamedCrit(slot, 'Hip') + && this.isCritUnavailable(slot)).length; + const previousEffectHits = this.standardHipEffectHitCount(committedHipHits, isQuadruped); + const totalEffectHits = this.standardHipEffectHitCount( + committedHipHits + currentLocations.length, + isQuadruped, + ); + const currentEffectHits = Math.max(0, totalEffectHits - previousEffectHits); + return currentEffectHits === 0 + ? [] + : currentLocations.slice(currentLocations.length - currentEffectHits); + } + + private standardHipEffectSlots( + slots: readonly CriticalSlot[], + isQuadruped: boolean, + ): CriticalSlot[] { + const hipSlots = slots + .map((slot, index) => ({ slot, index })) + .filter(entry => this.isNamedCrit(entry.slot, 'Hip')) + .sort((left, right) => { + const leftTimestamp = left.slot.destroyed ?? left.slot.destroying ?? Number.MAX_SAFE_INTEGER; + const rightTimestamp = right.slot.destroyed ?? right.slot.destroying ?? Number.MAX_SAFE_INTEGER; + if (leftTimestamp !== rightTimestamp) return leftTimestamp - rightTimestamp; + const leftTurn = left.slot.destroyedTurn ?? Number.MAX_SAFE_INTEGER; + const rightTurn = right.slot.destroyedTurn ?? Number.MAX_SAFE_INTEGER; + return leftTurn - rightTurn || left.index - right.index; + }) + .map(entry => entry.slot); + const effectHits = this.standardHipEffectHitCount(hipSlots.length, isQuadruped); + return effectHits === 0 ? [] : hipSlots.slice(hipSlots.length - effectHits); + } + protected isLegDamageMovementPSRCheck( check: PSRCheck | null, ): check is PSRCheck & { kind: PSRCheckKind } { @@ -580,12 +626,20 @@ export class MekRules extends UnitTypeRulesBase { : LEG_DAMAGE_MOVEMENT_CRITICAL_NAMES[check.kind]; if (!criticalNames) return null; + const committedHipEffectSlots = new Set(this.standardHipEffectSlots( + this.unit.getCritSlots().filter(slot => slot.loc + && LEG_LOCATIONS.has(slot.loc) + && !this.isLegDestroyed(slot.loc, true) + && this.isCritUnavailable(slot)), + this.isQuadrupedMek(), + )); const reasonsByLeg = new Map>(); this.unit.getCritSlots().forEach(slot => { if (!slot.loc || !LEG_LOCATIONS.has(slot.loc) || !this.isCritUnavailable(slot) || !criticalNames.some(name => this.isNamedCrit(slot, name))) return; + if (this.isNamedCrit(slot, 'Hip') && !committedHipEffectSlots.has(slot)) return; const reasons = reasonsByLeg.get(slot.loc) ?? new Set(); if (this.isNamedCrit(slot, 'Hip')) reasons.add('Hip hit'); else if (this.isNamedCrit(slot, 'Foot')) reasons.add('Foot hit'); @@ -663,17 +717,23 @@ export class MekRules extends UnitTypeRulesBase { const hasDamagedLeg = damagedLegLocations.length > 0; const damagedLegLocation = damagedLegLocations.length === 1 ? damagedLegLocations[0] : undefined; - const hasDamagedLegActuators = critSlots.some(slot => { + const internalLocations = this.systemsStatus().internalLocations; + const isQuadruped = QUAD_LEG_LOCATIONS.some(loc => internalLocations.has(loc)); + const relevantCommittedLegActuators = critSlots.filter(slot => slot.loc + && LEG_LOCATIONS.has(slot.loc) + && !this.isLegDestroyed(slot.loc, true) + && this.isCritUnavailable(slot)); + const committedHipEffectSlots = new Set(this.standardHipEffectSlots( + relevantCommittedLegActuators, + isQuadruped, + )); + const hasDamagedLegActuators = relevantCommittedLegActuators.some(slot => { if (!slot.name || !slot.loc || !this.isCritUnavailable(slot)) return false; - if (!LEG_LOCATIONS.has(slot.loc)) return false; - if (this.isLegDestroyed(slot.loc, true)) return false; return this.isNamedCrit(slot, 'Leg') || this.isNamedCrit(slot, 'Foot') - || this.isNamedCrit(slot, 'Hip'); + || committedHipEffectSlots.has(slot); }); - const internalLocations = this.systemsStatus().internalLocations; - const isQuadruped = QUAD_LEG_LOCATIONS.some(loc => internalLocations.has(loc)); const destroyedLegsCount = this.systemsStatus().destroyedLegsCount; const damagedLegRequiresCheck = this.damagedLegRequiresMovementCheck(isQuadruped, destroyedLegsCount); const twoDestroyedQuadLegsApplyHipEffects = this.twoDestroyedQuadLegsApplyHipMovementEffects( @@ -753,11 +813,7 @@ export class MekRules extends UnitTypeRulesBase { }; } if (hasDamagedLegActuators) { - const hasDamagedHip = critSlots.some(slot => { - if (!slot.name || !slot.loc || !this.isCritUnavailable(slot)) return false; - if (!LEG_LOCATIONS.has(slot.loc)) return false; - return this.isNamedCrit(slot, 'Hip'); - }); + const hasDamagedHip = committedHipEffectSlots.size > 0; if (hasDamagedHip) { return { kind: PSR_CHECK_KIND.DAMAGED_HIP_MOVEMENT, @@ -1377,9 +1433,13 @@ export class MekRules extends UnitTypeRulesBase { slots.push(slot); slotsByLocation.set(slot.loc!, slots); } + const hipEffectSlots = new Set(this.standardHipEffectSlots( + relevantSlots, + this.isQuadrupedMek(), + )); const modifiers: PSRModifier[] = []; for (const [loc, slots] of slotsByLocation) { - const destroyedHipsCount = slots.filter(slot => this.isNamedCrit(slot, 'Hip')).length; + const destroyedHipsCount = slots.filter(slot => hipEffectSlots.has(slot)).length; const destroyedLegActuatorsCount = slots.filter(slot => this.isNamedCrit(slot, 'Leg')).length; if (destroyedHipsCount > 0) { modifiers.push({ @@ -1961,8 +2021,9 @@ export class MekRules extends UnitTypeRulesBase { walk -= damage.destroyedHipsCount; } } else if (isQuadruped) { - if (damage.destroyedHipsCount !== 0) { - walk -= damage.destroyedHipsCount; + const standardHipHits = this.standardHipEffectHitCount(damage.destroyedHipsCount, true); + if (standardHipHits !== 0) { + walk -= standardHipHits; moveImpaired = true; } if (damage.destroyedLegsCount <= 2) { diff --git a/src/app/models/rules/tw-rules.ts b/src/app/models/rules/tw-rules.ts index 9b6858428..a8f1843de 100644 --- a/src/app/models/rules/tw-rules.ts +++ b/src/app/models/rules/tw-rules.ts @@ -353,6 +353,11 @@ export class TWMekRules extends MekRules { protected override get gyroHitPSRModifier(): number { return 3; } protected override get hipPSRModifier(): number { return 2; } + + /** BMM/TW applies the standard hip effects to a quad starting with its first hit. */ + protected override standardHipEffectHitCount(totalHipHits: number, _isQuadruped: boolean): number { + return totalHipHits; + } protected override get lowerArmFireModifier(): number { return 1; } protected override get footHitsCausePSR(): boolean { return true; } From 3788ade2bf3b66ab3ef64cc8b8f4bc9c4ed19974 Mon Sep 17 00:00:00 2001 From: exeea Date: Thu, 27 Aug 2026 18:21:07 +0200 Subject: [PATCH 36/87] patchwork/hybrid --- .../components/dice-roller/dice-roller.component.scss | 2 +- src/app/models/unit-summary.model.ts | 10 ++++++++++ src/styles.scss | 8 ++++---- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/app/components/dice-roller/dice-roller.component.scss b/src/app/components/dice-roller/dice-roller.component.scss index 3ef2d25fe..eaa9984be 100644 --- a/src/app/components/dice-roller/dice-roller.component.scss +++ b/src/app/components/dice-roller/dice-roller.component.scss @@ -44,7 +44,7 @@ font-weight: bold; color: #fff; white-space: nowrap; - + .value { font-size: 1.5em; } diff --git a/src/app/models/unit-summary.model.ts b/src/app/models/unit-summary.model.ts index 9b1969395..0ac0b8150 100644 --- a/src/app/models/unit-summary.model.ts +++ b/src/app/models/unit-summary.model.ts @@ -73,6 +73,14 @@ export interface UnitComponent { eq?: Equipment; // linked equipment data } +/** Canonical MegaMek material code and technology base at one unit location. */ +export interface UnitMaterialLayoutEntry { + readonly type: number; + readonly clan: boolean; +} + +export type UnitMaterialLayout = Readonly>; + export interface UnitTagEntry { /** Tag display label */ tag: string; @@ -145,6 +153,8 @@ export interface UnitSummary { role: string; armorType: string; structureType: string | null; + patchworkLayout?: UnitMaterialLayout; + hybridLayout?: UnitMaterialLayout; armor: number; armorPer: number; // Armor % internal: number; diff --git a/src/styles.scss b/src/styles.scss index 3e603165d..36a174acb 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -34,7 +34,7 @@ -webkit-tap-highlight-color: transparent; --semantic-color: #17a2b8; --semantic-color-highlight: #8fe7ed; - + --primary-focus-color: rgb(174, 174, 253); --primary-focus-color-highlight: rgb(192, 192, 255); // --primary-focus-color: rgb(154, 154, 255); @@ -686,7 +686,7 @@ hr { &:not(.selected) { --btn-text-color: #d00; --btn-border-color: #a00; - + &:hover { background-color: #300; --btn-text-color: red; @@ -694,7 +694,7 @@ hr { } } } - + &.warning { --btn-text-color: rgb(222, 144, 0); @@ -2940,4 +2940,4 @@ hr.divider { .print-show { visibility: visible !important; } -} \ No newline at end of file +} From 255f0c50cc276e23f9407a7df1bd07699e35a867 Mon Sep 17 00:00:00 2001 From: exeea Date: Thu, 27 Aug 2026 20:02:29 +0200 Subject: [PATCH 37/87] options --- src/app/services/options.service.ts | 48 +++-------------------------- 1 file changed, 5 insertions(+), 43 deletions(-) diff --git a/src/app/services/options.service.ts b/src/app/services/options.service.ts index e5af88acd..34e161c4f 100644 --- a/src/app/services/options.service.ts +++ b/src/app/services/options.service.ts @@ -4,7 +4,7 @@ import { inject, Injectable, signal } from '@angular/core'; import { DbService } from './db.service'; -import { OPTION_VALUES, type AutomationMode, type CBTAutomationKey, type CBTAutomationOptions, type CBTOptionalRules, type ColorScheme, type ForceBudgetOptimizerLastSkills, type ForceGeneratorOptions, type Options } from '../models/options.model'; +import { CBT_AUTOMATION_KEYS, OPTION_VALUES, type AutomationMode, type CBTAutomationKey, type CBTAutomationOptions, type CBTOptionalRules, type ColorScheme, type ForceBudgetOptimizerLastSkills, type ForceGeneratorOptions, type Options } from '../models/options.model'; import { PRINT_OPTION_VALUES, type PrintAllOptions } from '../models/print-options.model'; import { GameSystem, normalizeUnitServerUrl } from '../models/common.model'; @@ -197,48 +197,10 @@ function resolveCBTOptionalRules(saved: Options | null | undefined): CBTOptional function resolveCBTAutomationOptions(saved: Options | null | undefined): CBTAutomationOptions { const defaults = DEFAULT_OPTIONS.cbtAutomationOptions; - return { - pilotSkillCheck: resolveSavedValue( - saved?.cbtAutomationOptions?.pilotSkillCheck, - defaults.pilotSkillCheck, - OPTION_VALUES.automationMode, - ), - heatAndDissipationResolution: resolveSavedValue( - saved?.cbtAutomationOptions?.heatAndDissipationResolution, - defaults.heatAndDissipationResolution, - OPTION_VALUES.automationMode, - ), - heatEffectsCheck: resolveSavedValue( - saved?.cbtAutomationOptions?.heatEffectsCheck, - defaults.heatEffectsCheck, - OPTION_VALUES.automationMode, - ), - pilotHitsAndConsciousnessCheck: resolveSavedValue( - saved?.cbtAutomationOptions?.pilotHitsAndConsciousnessCheck, - defaults.pilotHitsAndConsciousnessCheck, - OPTION_VALUES.automationMode, - ), - internalExplosionsCheck: resolveSavedValue( - saved?.cbtAutomationOptions?.internalExplosionsCheck, - defaults.internalExplosionsCheck, - OPTION_VALUES.automationMode, - ), - criticalHitChanceCheck: resolveSavedValue( - saved?.cbtAutomationOptions?.criticalHitChanceCheck, - defaults.criticalHitChanceCheck, - OPTION_VALUES.automationMode, - ), - breachAndFloodCheck: resolveSavedValue( - saved?.cbtAutomationOptions?.breachAndFloodCheck, - defaults.breachAndFloodCheck, - OPTION_VALUES.automationMode, - ), - fallingCheck: resolveSavedValue( - saved?.cbtAutomationOptions?.fallingCheck, - defaults.fallingCheck, - OPTION_VALUES.automationMode, - ), - }; + return Object.fromEntries(CBT_AUTOMATION_KEYS.map(key => [ + key, + resolveSavedValue(saved?.cbtAutomationOptions?.[key], defaults[key], OPTION_VALUES.automationMode), + ])) as CBTAutomationOptions; } function resolveLastCanvasState(saved: unknown): Options['lastCanvasState'] { From 06d7bfadfb016ebc73f76a7900e9a9eb3c6d44cf Mon Sep 17 00:00:00 2001 From: exeea Date: Thu, 27 Aug 2026 20:02:52 +0200 Subject: [PATCH 38/87] fixed lost selection in chassis view --- .../unit-search/unit-search.component.spec.ts | 24 +++++++++++++++++++ .../unit-search/unit-search.component.ts | 5 ++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/app/components/unit-search/unit-search.component.spec.ts b/src/app/components/unit-search/unit-search.component.spec.ts index d4d49e71d..271075c88 100644 --- a/src/app/components/unit-search/unit-search.component.spec.ts +++ b/src/app/components/unit-search/unit-search.component.spec.ts @@ -905,6 +905,30 @@ describe('UnitSearchComponent card virtualization', () => { expect(scrollToVariantsGroup).toHaveBeenCalledOnceWith('Nova|BM|O'); }); + it('preserves selected units outside a variant group while drilling down and clearing it', () => { + filtersServiceStub.viewMode.set('chassis'); + const fixture = TestBed.createComponent(UnitSearchComponent); + const component = fixture.componentInstance; + const atlas = createUnit('Atlas AS7-D', { chassis: 'Atlas', as: { TP: 'BM' } }); + const nova = createUnit('Nova Prime', { chassis: 'Nova', omni: 1, as: { TP: 'BM' } }); + + filteredUnitsSignal.set([atlas, nova]); + fixture.detectChanges(); + component.selectedUnits.set(new Set([atlas.name, nova.name])); + + const atlasGroup = component.groupedUnits().find(group => group.chassis === 'Atlas'); + expect(atlasGroup).toBeDefined(); + component.onCompactGroupClick(atlasGroup!); + fixture.detectChanges(); + + expect([...component.selectedUnits()]).toEqual([atlas.name, nova.name]); + + component.clearVariantGroupFilter(); + fixture.detectChanges(); + + expect([...component.selectedUnits()]).toEqual([atlas.name, nova.name]); + }); + it('navigates search results with global up and down shortcuts', () => { const fixture = TestBed.createComponent(UnitSearchComponent); const component = fixture.componentInstance; diff --git a/src/app/components/unit-search/unit-search.component.ts b/src/app/components/unit-search/unit-search.component.ts index d4f688126..0fbe0c0a7 100644 --- a/src/app/components/unit-search/unit-search.component.ts +++ b/src/app/components/unit-search/unit-search.component.ts @@ -778,11 +778,12 @@ export class UnitSearchComponent { }); }); effect(() => { + const filteredNames = new Set(this.filtersService.filteredUnits().map(unit => unit.name)); const displayedNames = new Set(this.displayedUnits().map(unit => unit.name)); untracked(() => { const selected = this.selectedUnits(); - if (![...selected].every(name => displayedNames.has(name))) { - this.selectedUnits.set(new Set([...selected].filter(name => displayedNames.has(name)))); + if (![...selected].every(name => filteredNames.has(name))) { + this.selectedUnits.set(new Set([...selected].filter(name => filteredNames.has(name)))); } const inlineUnit = this.inlinePanelUnit(); if (inlineUnit && !displayedNames.has(inlineUnit.name)) { From 5d356e14aa17db9eaecac624e850c28011adc3b4 Mon Sep 17 00:00:00 2001 From: exeea Date: Thu, 27 Aug 2026 20:03:09 +0200 Subject: [PATCH 39/87] options --- src/app/models/options.model.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/app/models/options.model.ts b/src/app/models/options.model.ts index 8e1c96f68..ee7680199 100644 --- a/src/app/models/options.model.ts +++ b/src/app/models/options.model.ts @@ -30,18 +30,19 @@ export type ColorScheme = typeof OPTION_VALUES.colorScheme[number]; export type UnitSearchViewMode = typeof OPTION_VALUES.unitSearchViewMode[number]; export type AutomationMode = typeof OPTION_VALUES.automationMode[number]; -export interface CBTAutomationOptions { - pilotSkillCheck: AutomationMode; - heatAndDissipationResolution: AutomationMode; - heatEffectsCheck: AutomationMode; - pilotHitsAndConsciousnessCheck: AutomationMode; - internalExplosionsCheck: AutomationMode; - criticalHitChanceCheck: AutomationMode; - breachAndFloodCheck: AutomationMode; - fallingCheck: AutomationMode; -} +export const CBT_AUTOMATION_KEYS = [ + 'pilotSkillCheck', + 'heatAndDissipationResolution', + 'heatEffectsCheck', + 'pilotHitsAndConsciousnessCheck', + 'internalExplosionsCheck', + 'criticalHitChanceCheck', + 'breachAndFloodCheck', + 'fallingCheck', +] as const; -export type CBTAutomationKey = keyof CBTAutomationOptions; +export type CBTAutomationKey = typeof CBT_AUTOMATION_KEYS[number]; +export type CBTAutomationOptions = Record; export interface SkillRangeOption { min: number; From c6e1005b54caf0b9c125c9f249f15c1c11c57d08 Mon Sep 17 00:00:00 2001 From: exeea Date: Thu, 27 Aug 2026 20:26:11 +0200 Subject: [PATCH 40/87] patchwork/hybrid --- .../falling-damage-dialog.component.spec.ts | 1 + .../falling-damage-dialog.component.ts | 57 +++-- ...floating-critical-dialog.component.spec.ts | 13 +- .../mek-floating-critical-dialog.component.ts | 42 ++-- src/app/models/cbt-force-unit.model.spec.ts | 63 +++++- src/app/models/cbt-force-unit.model.ts | 177 ++++++++-------- src/app/models/force-serialization.spec.ts | 56 ++++- src/app/models/force-serialization.ts | 90 ++++++-- src/app/models/rules/mek-rules.spec.ts | 200 +++++++++++++++++- src/app/models/rules/mek-rules.ts | 187 +++++++++++++--- src/app/models/rules/tw-rules.ts | 1 + src/app/models/rules/vehicle-rules.spec.ts | 8 + src/app/models/rules/vehicle-rules.ts | 2 +- src/app/models/turn-state.model.spec.ts | 27 ++- src/app/models/turn-state.model.ts | 35 ++- src/app/models/unit-check.model.ts | 1 + src/app/services/cbt-end-turn.service.spec.ts | 53 ++++- src/app/services/cbt-end-turn.service.ts | 39 +++- .../falling-resolution.service.spec.ts | 14 +- .../services/falling-resolution.service.ts | 36 +--- ...ek-critical-hit-automation.service.spec.ts | 2 + .../mek-critical-resolution.service.spec.ts | 26 +-- .../mek-critical-resolution.service.ts | 18 +- src/app/services/unit-svg-mek.service.ts | 14 ++ src/app/utils/mek-critical-hit.util.spec.ts | 49 +++-- src/app/utils/mek-critical-hit.util.ts | 32 +-- src/app/utils/mek-falling.util.spec.ts | 113 +++++++++- src/app/utils/mek-falling.util.ts | 159 +++++++++----- .../utils/mek-structure-damage.util.spec.ts | 67 ++++++ src/app/utils/mek-structure-damage.util.ts | 72 +++++++ src/app/utils/rs-polyfill.util.spec.ts | 25 +++ src/app/utils/rs-polyfill.util.ts | 51 ++--- .../unit-component-metadata-builder.spec.ts | 27 ++- .../utils/unit-component-metadata-builder.ts | 26 +-- src/app/utils/unit-metadata-builder.spec.ts | 42 +++- src/app/utils/unit-metadata-builder.ts | 30 ++- 36 files changed, 1413 insertions(+), 442 deletions(-) create mode 100644 src/app/utils/mek-structure-damage.util.spec.ts create mode 100644 src/app/utils/mek-structure-damage.util.ts diff --git a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.spec.ts b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.spec.ts index 87830e295..488c2db32 100644 --- a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.spec.ts +++ b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.spec.ts @@ -22,6 +22,7 @@ describe('FallingDamageDialogComponent', () => { getPendingFall: () => undefined, setPendingFallRolls: persistRolls, getNotificationDisplayName: () => 'Atlas AS7-D', + hasArmorType: () => false, getUnit: () => ({ type: 'Mek', subtype: 'Biped', diff --git a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.ts b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.ts index 73480ee2d..874fdb355 100644 --- a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.ts +++ b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.ts @@ -10,12 +10,13 @@ import type { CBTUnitAutomationTrigger, } from '../../models/cbt-force-unit.model'; import { - isImpactResistantArmor, isResolvedMekFallHitLocation, mekFallDamage, mekFallDamageGroups, resolveMekFallHitLocation, resolveMekFallOrientation, + twoD6ForTotal, + twoD6Total, type MekFallHitLocationResult, type MekFallOrientation, type ResolvedMekFallDamageGroup, @@ -40,15 +41,14 @@ export type FallingDamageDialogResult = AcceptedFallingDamageDialogResult | { readonly action: 'close' }; interface FallingDamageGroupRoll { - readonly hitLocationRoll: number | null; readonly hitLocationDice: readonly [number, number] | null; readonly tripodLegRoll: number | null; - readonly tripodLegDice: readonly [number] | null; } interface FallingDamageGroupRow extends FallingDamageGroupRoll { readonly index: number; readonly damage: number; + readonly hitLocationRoll: number | null; readonly result: MekFallHitLocationResult | null; } @@ -75,15 +75,12 @@ export class FallingDamageDialogComponent { readonly hitLocationTable: MekHitLocationTable = clusterTableForUnit(this.data.unit.getUnit()).hitLocationTable ?? 'biped'; readonly orientationRoll = signal(this.pending?.orientationRoll ?? null); - readonly orientationDice = signal(this.pending?.orientationDice ?? null); private readonly groupRolls = signal( this.damageGroups.map((_damage, index) => { const pendingRoll = this.pending?.damageRolls[index]; return { - hitLocationRoll: pendingRoll?.hitLocationRoll ?? null, hitLocationDice: pendingRoll?.hitLocationDice ?? null, tripodLegRoll: pendingRoll?.tripodLegRoll ?? null, - tripodLegDice: pendingRoll?.tripodLegDice ?? null, }; }), ); @@ -96,19 +93,23 @@ export class FallingDamageDialogComponent { }); readonly groupRows = computed(() => { const orientation = this.orientation(); - return this.groupRolls().map((roll, index) => ({ - index, - damage: this.damageGroups[index], - ...roll, - result: orientation && roll.hitLocationRoll !== null - ? resolveMekFallHitLocation( - this.hitLocationTable, - orientation.hitArc, - roll.hitLocationRoll, - roll.tripodLegRoll ?? undefined, - ) - : null, - })); + return this.groupRolls().map((roll, index) => { + const hitLocationRoll = roll.hitLocationDice ? twoD6Total(roll.hitLocationDice) : null; + return { + index, + damage: this.damageGroups[index], + ...roll, + hitLocationRoll, + result: orientation && hitLocationRoll !== null + ? resolveMekFallHitLocation( + this.hitLocationTable, + orientation.hitArc, + hitLocationRoll, + roll.tripodLegRoll ?? undefined, + ) + : null, + }; + }); }); readonly allResolved = computed(() => { if (!this.orientation()) return false; @@ -117,27 +118,24 @@ export class FallingDamageDialogComponent { readonly sourceMessage = this.data.trigger.source === 'stand-attempt' ? 'The stand-up attempt failed, so the Mek falls again.' : 'A failed Piloting Skill Roll caused the Mek to fall.'; - readonly armorNote = isImpactResistantArmor(this.data.unit.getUnit().armorType) - ? 'Impact-Resistant Armor halves each group that reaches intact armor, rounding down to a minimum of 1 damage.' + readonly armorNote = this.data.unit.hasArmorType('IMPACT_RESISTANT') + ? 'Impact-Resistant Armor is resolved against the armor in each struck location.' : null; setOrientationRoll(roll: number | null): void { this.orientationRoll.set(validRoll(roll, 1, 6)); - this.orientationDice.set(null); this.persistRolls(); } setHitLocationRoll(index: number, roll: number | null): void { this.updateGroupRoll(index, { - hitLocationRoll: validRoll(roll, 2, 12), - hitLocationDice: null, + hitLocationDice: twoD6ForTotal(roll), }); } setTripodLegRoll(index: number, roll: number | null): void { this.updateGroupRoll(index, { tripodLegRoll: validRoll(roll, 1, 6), - tripodLegDice: null, }); } @@ -145,10 +143,9 @@ export class FallingDamageDialogComponent { const orientationRoll = rollD6(random); const orientation = resolveMekFallOrientation(this.rulesId, orientationRoll); this.orientationRoll.set(orientationRoll); - this.orientationDice.set([orientationRoll]); this.groupRolls.set(this.damageGroups.map(() => { const hitLocationDice = [rollD6(random), rollD6(random)] as const; - const hitLocationRoll = hitLocationDice[0] + hitLocationDice[1]; + const hitLocationRoll = twoD6Total(hitLocationDice); const preliminary = resolveMekFallHitLocation( this.hitLocationTable, orientation.hitArc, @@ -156,12 +153,9 @@ export class FallingDamageDialogComponent { ); const needsTripodLeg = preliminary.location === null && preliminary.tripodLegModifier !== undefined; - const tripodLegDice = needsTripodLeg ? [rollD6(random)] as const : null; return { - hitLocationRoll, hitLocationDice, - tripodLegRoll: tripodLegDice?.[0] ?? null, - tripodLegDice, + tripodLegRoll: needsTripodLeg ? rollD6(random) : null, }; })); this.persistRolls(); @@ -200,7 +194,6 @@ export class FallingDamageDialogComponent { this.data.trigger.id, this.orientationRoll(), this.groupRolls() satisfies readonly CBTMekFallDamageRoll[], - this.orientationDice(), ); } } diff --git a/src/app/components/page-viewer/mek-floating-critical-dialog.component.spec.ts b/src/app/components/page-viewer/mek-floating-critical-dialog.component.spec.ts index 523fdd638..ac0ec6acd 100644 --- a/src/app/components/page-viewer/mek-floating-critical-dialog.component.spec.ts +++ b/src/app/components/page-viewer/mek-floating-critical-dialog.component.spec.ts @@ -40,7 +40,7 @@ describe('MekFloatingCriticalDialogComponent', () => { fixture.componentInstance.selectLocation(row); - expect(onDraftChange).toHaveBeenCalledOnceWith(7, null, null); + expect(onDraftChange).toHaveBeenCalledOnceWith([3, 4], null); expect(fixture.componentInstance.selectedLocation()).toBe('LT'); expect(close).not.toHaveBeenCalled(); @@ -50,9 +50,9 @@ describe('MekFloatingCriticalDialogComponent', () => { }); it('persists exact rolled dice and applies their facing-aware location', () => { - fixture.componentInstance.onFinished({ results: [5, 5], sum: 10 }); + fixture.componentInstance.onFinished({ results: [5, 5], sum: 2 }); - expect(onDraftChange).toHaveBeenCalledOnceWith(10, [5, 5], null); + expect(onDraftChange).toHaveBeenCalledOnceWith([5, 5], null); expect(fixture.componentInstance.selectedLocation()).toBe('RA'); expect(close).not.toHaveBeenCalled(); @@ -77,8 +77,7 @@ describe('MekFloatingCriticalDialogComponent', () => { it('restores a pending draft without persisting it again', () => { fixture.destroy(); Object.assign(data, { - initialLocationRoll: 8, - initialRoll: [2, 6] as const, + initialDice: [2, 6] as const, }); fixture = TestBed.createComponent(MekFloatingCriticalDialogComponent); fixture.detectChanges(); @@ -106,8 +105,8 @@ describe('MekFloatingCriticalDialogComponent', () => { expect(fixture.componentInstance.selectedLocation()).toBe('CL'); expect(onDraftChange.calls.allArgs()).toEqual([ - [5, null, null], - [5, null, 3], + [[2, 3], null], + [[2, 3], 3], ]); fixture.componentInstance.apply(); diff --git a/src/app/components/page-viewer/mek-floating-critical-dialog.component.ts b/src/app/components/page-viewer/mek-floating-critical-dialog.component.ts index de206fa3a..b95f0d7a2 100644 --- a/src/app/components/page-viewer/mek-floating-critical-dialog.component.ts +++ b/src/app/components/page-viewer/mek-floating-critical-dialog.component.ts @@ -6,7 +6,11 @@ import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; import { ChangeDetectionStrategy, Component, computed, inject, signal, viewChild } from '@angular/core'; import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; import type { MekHitArc } from '../../models/force-serialization'; -import { resolveMekFallHitLocation } from '../../utils/mek-falling.util'; +import { + resolveMekFallHitLocation, + twoD6ForTotal, + twoD6Total, +} from '../../utils/mek-falling.util'; import { clusterTableForUnit, hitLocationRows, @@ -17,11 +21,9 @@ import { DiceRollerComponent } from '../dice-roller/dice-roller.component'; export interface MekFloatingCriticalDialogData { readonly unit: CBTForceUnit; readonly hitArc: MekHitArc; - readonly initialLocationRoll?: number; - readonly initialRoll?: readonly [number, number]; + readonly initialDice?: readonly [number, number]; readonly initialTripodLegRoll?: number; readonly onDraftChange?: ( - locationRoll: number | null, dice: readonly [number, number] | null, tripodLegRoll: number | null, ) => void; @@ -140,11 +142,15 @@ export class MekFloatingCriticalDialogComponent { readonly hitLocationTable: MekHitLocationTable = clusterTableForUnit(this.data.unit.getUnit()).hitLocationTable ?? 'biped'; readonly facingLabel = floatingCriticalFacingLabel(this.data.hitArc); - readonly initialDice = validDice(this.data.initialRoll, 2) as readonly [number, number] | null; + readonly initialDice = validDice(this.data.initialDice, 2) as readonly [number, number] | null; readonly d6Rolls = [1, 2, 3, 4, 5, 6] as const; readonly locationRows = hitLocationRows(this.hitLocationTable).map((_row, index) => floatingLocationRow(this.hitLocationTable, index + 2, this.data.hitArc)); - readonly locationRoll = signal(validLocationRoll(this.data.initialLocationRoll)); + private readonly hitLocationDice = signal(this.initialDice); + readonly locationRoll = computed(() => { + const dice = this.hitLocationDice(); + return dice ? twoD6Total(dice) : null; + }); readonly tripodLegRoll = signal(validTripodLegRoll(this.data.initialTripodLegRoll)); readonly isRolling = computed(() => this.roller()?.isRolling() ?? false); readonly selectedResult = computed(() => { @@ -172,23 +178,24 @@ export class MekFloatingCriticalDialogComponent { onFinished(event: { readonly results: readonly number[]; readonly sum: number }): void { if (event.results.length !== 2 || !validDice(event.results, 2)) return; const dice = [event.results[0], event.results[1]] as const; - const row = this.locationRows.find(candidate => candidate.roll === event.sum); + const row = this.locationRows.find(candidate => candidate.roll === twoD6Total(dice)); const tripodRoll = row?.requiresTripodLegRoll ? Math.floor(Math.random() * 6) + 1 : null; - this.setDraft(event.sum, dice, tripodRoll); + this.setDraft(dice, tripodRoll); } selectLocation(row: FloatingCriticalLocationRow): void { if (this.isRolling()) return; - this.setDraft(row.roll, null, null); + const dice = twoD6ForTotal(row.roll); + if (dice) this.setDraft(dice, null); } selectTripodLeg(roll: number): void { if (!Number.isInteger(roll) || roll < 1 || roll > 6) return; - const locationRoll = this.locationRoll(); - if (locationRoll === null) return; - this.setDraft(locationRoll, null, roll); + const dice = this.hitLocationDice(); + if (!dice) return; + this.setDraft(dice, roll); } apply(): void { @@ -206,13 +213,12 @@ export class MekFloatingCriticalDialogComponent { } private setDraft( - locationRoll: number, - dice: readonly [number, number] | null, + dice: readonly [number, number], tripodLegRoll: number | null, ): void { - this.locationRoll.set(locationRoll); + this.hitLocationDice.set(dice); this.tripodLegRoll.set(tripodLegRoll); - this.data.onDraftChange?.(locationRoll, dice, tripodLegRoll); + this.data.onDraftChange?.(dice, tripodLegRoll); } } @@ -236,10 +242,6 @@ function floatingCriticalFacingLabel(hitArc: MekHitArc): string { return 'Front'; } -function validLocationRoll(value: number | undefined): number | null { - return value !== undefined && Number.isInteger(value) && value >= 2 && value <= 12 ? value : null; -} - function validTripodLegRoll(value: number | undefined): number | null { return value !== undefined && Number.isInteger(value) && value >= 1 && value <= 6 ? value : null; } diff --git a/src/app/models/cbt-force-unit.model.spec.ts b/src/app/models/cbt-force-unit.model.spec.ts index 08c464dba..0aa9c93b7 100644 --- a/src/app/models/cbt-force-unit.model.spec.ts +++ b/src/app/models/cbt-force-unit.model.spec.ts @@ -1987,6 +1987,28 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(forceUnit.getHeight()).toBe(2); }); + it('resolves patchwork armor and hybrid structure from typed location layouts', () => { + const forceUnit = createForceUnit({ + ...createMekUnit(), + armorType: 'Patchwork', + structureType: 'Hybrid', + patchworkLayout: { + CT: { type: 0, clan: false }, + LA: { type: 25, clan: false }, + }, + hybridLayout: { + CT: { type: 0, clan: false }, + LA: { type: 5, clan: false }, + }, + }); + + expect(forceUnit.getArmorTypeAt('CT')).toBe('STANDARD'); + expect(forceUnit.getArmorTypeAt('LA')).toBe('IMPACT_RESISTANT'); + expect(forceUnit.hasArmorType('IMPACT_RESISTANT')).toBeTrue(); + expect(forceUnit.getStructureKindAt('CT')).toBe('standard'); + expect(forceUnit.getStructureKindAt('LA')).toBe('composite'); + }); + it('automatically floods armorless submerged locations based on posture', () => { // Keep posture under this test's explicit control; Core's flooded-leg // automatic fall is exercised by the phase-resolution coverage. @@ -2129,6 +2151,25 @@ describe('CBTForceUnit direct inventory ammo bins', () => { .every(chance => !chance.locationDestroyed)).toBeTrue(); }); + it('derives phase damage from the typed structure kind', () => { + const composite = createCriticalHeatSinkForceUnit().forceUnit; + spyOn(composite, 'getStructureKindAt').and.returnValue('composite'); + + composite.addInternalHits('LT', 2); + + expect(composite.turnState().dmgReceived()).toBe(1); + + const reinforced = createCriticalHeatSinkForceUnit().forceUnit; + spyOn(reinforced, 'getStructureKindAt').and.returnValue('reinforced'); + reinforced.locations!.internal.set('LT', { loc: 'LT', points: 10 }); + + reinforced.addInternalHits('LT', 1); + expect(reinforced.turnState().dmgReceived()).toBe(0); + + reinforced.addInternalHits('LT', 1); + expect(reinforced.turnState().dmgReceived()).toBe(1); + }); + it('does not queue automatic critical chances when that automation is no', () => { automationModes.criticalHitChanceCheck = 'no'; const { forceUnit } = createCriticalHeatSinkForceUnit(); @@ -2960,7 +3001,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(forceUnit.pendingFallCount()).toBe(0); }); - it('keeps a fall pending until completion, retains its rolls, then releases seatbelt work', () => { + it('keeps a fall and its exact dice across save/reload until completion', () => { const forceUnit = createForceUnit(); forceUnit.setCondition('prone', true); const triggers: CBTUnitAutomationTrigger[] = []; @@ -2980,21 +3021,33 @@ describe('CBTForceUnit direct inventory ammo bins', () => { const pending = forceUnit.getPendingFall()!; expect(forceUnit.setPendingFallRolls(pending.id, 4, [{ - hitLocationRoll: 7, hitLocationDice: [5, 2], tripodLegRoll: null, - }], [4])).toBeTrue(); + }])).toBeTrue(); expect(forceUnit.getPendingFall(pending.id)).toEqual(jasmine.objectContaining({ orientationRoll: 4, - orientationDice: [4], damageRolls: [{ - hitLocationRoll: 7, hitLocationDice: [5, 2], tripodLegRoll: null, }], })); expect('falling' in forceUnit.serialize().state).toBeFalse(); + const restored = CBTForceUnit.deserialize( + forceUnit.serialize(), + new TestCBTForce('Restored Fall Force', dataService, unitInitializer, injector), + dataService, + unitInitializer, + injector, + ); + expect(restored.getPendingFall(pending.id)).toEqual(jasmine.objectContaining({ + orientationRoll: 4, + damageRolls: [{ + hitLocationDice: [5, 2], + tripodLegRoll: null, + }], + })); + expect(forceUnit.completePendingFall(pending.id)).toBeTrue(); expect(forceUnit.pendingFallCount()).toBe(0); expect(triggers[triggers.length - 1]).toEqual({ kind: 'pending-unit-check' }); diff --git a/src/app/models/cbt-force-unit.model.ts b/src/app/models/cbt-force-unit.model.ts index d8ff085e2..67e99d775 100644 --- a/src/app/models/cbt-force-unit.model.ts +++ b/src/app/models/cbt-force-unit.model.ts @@ -8,7 +8,7 @@ import { DataService } from '../services/data.service'; import { getUnitHeight, type UnitSummary, type UnitHeight } from "./unit-summary.model"; import type { UnitInitializerService } from '../services/unit-initializer.service'; import { MountedAmmo, MountedEquipment, MountedWeapon } from './mounted-equipment.model'; -import { type CriticalSlot, type HeatProfile, type LocationData, type MekHitArc, type ViewportTransform, CRIT_SLOT_SCHEMA, HEAT_SCHEMA, LOCATION_SCHEMA, INVENTORY_SCHEMA, C3_POSITION_SCHEMA, TURN_STATE_SCHEMA, type CBTSerializedState, type CBTSerializedUnit, type CBTMekFallSource, type PendingEventInput, type RuleCheckOutcome, type SerializedCrewMember, type SerializedPendingMekFall, type SerializedPendingUnitCheck, type SerializedRuleCheck, committedConditionData, conditionsForSerialization, conditionsHasActive, conditionsHasCommittedActive, conditionsMapFromSerialization, normalizeConditionData, normalizeConditionKey } from './force-serialization'; +import { type CriticalSlot, type HeatProfile, type LocationData, type MekHitArc, type ViewportTransform, CRIT_SLOT_SCHEMA, HEAT_SCHEMA, LOCATION_SCHEMA, INVENTORY_SCHEMA, C3_POSITION_SCHEMA, TURN_STATE_SCHEMA, type CBTSerializedState, type CBTSerializedUnit, type CBTMekFallSource, type PendingEventInput, type RuleCheckOutcome, type SerializedCrewMember, type SerializedMekFallDamageRoll, type SerializedPendingMekFall, type SerializedPendingUnitCheck, type SerializedRuleCheck, committedConditionData, conditionsForSerialization, conditionsHasActive, conditionsHasCommittedActive, conditionsMapFromSerialization, normalizeConditionData, normalizeConditionKey } from './force-serialization'; import { ForceUnit } from './force-unit.model'; import type { ConditionData } from './force-unit-state.model'; import type { CBTForce } from './cbt-force.model'; @@ -20,7 +20,7 @@ import { UnitSvgAeroService } from '../services/unit-svg-aero.service'; import { UnitSvgInfantryService } from '../services/unit-svg-infantry.service'; import { UnitSvgVehicleService } from '../services/unit-svg-vehicle.service'; import { BVCalculatorUtil } from '../utils/bv-calculator.util'; -import { AmmoEquipment, isTorpedoAmmo } from './equipment.model'; +import { AmmoEquipment, ArmorEquipment, isTorpedoAmmo, StructureEquipment } from './equipment.model'; import type { AmmoEquipment as AmmoEquipmentType } from './equipment.model'; import type { EquipmentFlag } from './equipment-flags.type'; import type { WeaponType } from './weapon-types.model'; @@ -32,7 +32,9 @@ import { Sanitizer } from '../utils/sanitizer.util'; import type { PSRCheck, UnitTypeRules } from './rules/unit-type-rules'; import { type InventoryControlRuntimeAmmoSelection, type InventoryControlRuntimeEntryState, type InventoryControlRuntimeRangeKey, type InventoryControlRuntimeSnapshot, type InventoryControlRuntimeTarget, type InventoryControlRuntimeTargetId } from './inventory-control-runtime-state.model'; import { CBTInventoryControlRuntime } from './cbt-inventory-control-runtime.model'; -import { getMekLegLocations, getMekLocationParent, inferMekConfigFromLocations, MEK_REAR_ARMOR_LOCATIONS } from './entity/types'; +import { getMekLegLocations, getMekLocationParent, inferMekConfigFromLocations, MEK_REAR_ARMOR_LOCATIONS, type ArmorType } from './entity/types'; +import { mekStructurePhaseDamage, MEK_STRUCTURE_TYPE, type MekStructureKind } from '../utils/mek-structure-damage.util'; +import { ARMOR_TYPE_FROM_BLK_CODE } from './entity/parsers/blk-codec'; import { createHandlerQueryContext, EquipmentInteractionRegistry, @@ -101,24 +103,24 @@ export interface CBTInternalDamageContext { } export interface CBTMekFallDamageRoll { - readonly hitLocationRoll: number | null; - readonly hitLocationDice?: readonly [number, number] | null; + readonly hitLocationDice: readonly [number, number] | null; readonly tripodLegRoll: number | null; - readonly tripodLegDice?: readonly [number] | null; } -/** Serialized event facts plus nonserialized dialog choices. */ -export interface CBTPendingMekFall extends SerializedPendingMekFall { +/** Serialized event facts exposed to the dialog with explicit unrolled values. */ +export type CBTPendingMekFall = Omit< + SerializedPendingMekFall, + 'orientationRoll' | 'damageRolls' +> & { readonly orientationRoll: number | null; - readonly orientationDice: readonly [number] | null; readonly damageRolls: readonly CBTMekFallDamageRoll[]; -} +}; -function normalizeD6Faces(faces: readonly number[] | null | undefined, count: number): readonly number[] | null { - return faces?.length === count - && faces.every(face => Number.isInteger(face) && face >= 1 && face <= 6) - ? [...faces] - : null; +function fallDamageRollForDialog(roll: SerializedMekFallDamageRoll): CBTMekFallDamageRoll { + return { + hitLocationDice: roll.hitLocationDice ?? null, + tripodLegRoll: roll.tripodLegRoll ?? null, + }; } export type CBTUnitAutomationTrigger = @@ -158,11 +160,6 @@ export class CBTForceUnit extends ForceUnit { readonly psrOutcomeSelections = signal>>({}); /** Exact virtual PSR dice retained alongside provisional outcomes. */ readonly psrDiceSelections = signal>>({}); - private readonly pendingMekFallRolls = signal>>({}); readonly pendingFallCount = computed(() => this.turnState().pendingFallCount()); readonly gameRules: CBTGameRules; viewState: ViewportTransform; @@ -828,8 +825,15 @@ export class CBTForceUnit extends ForceUnit { return (locData?.armor ?? 0) + (locData?.pendingArmor ?? 0); } - addArmorHits(loc: string, hits: number, rear?: boolean, consolidateImmediately: boolean = false) { + addArmorHits( + loc: string, + hits: number, + rear?: boolean, + consolidateImmediately: boolean = false, + damageReceived?: number, + ) { const locKey = rear ? `${loc}-rear` : loc; + const previousHits = this.getArmorHits(loc, rear); const locations = { ...this.state.locations() }; if (locations[locKey] === undefined) { @@ -841,11 +845,10 @@ export class CBTForceUnit extends ForceUnit { locations[locKey].pendingArmor += hits; this.state.locations.set({ ...this.state.locations(), [locKey]: locations[locKey] }); this.markEquipmentLocationsChanged(); - let hitsForPsr = hits; - if (this.getUnit().armorType === 'Hardened') { - hitsForPsr = Math.ceil(hitsForPsr / 2); - } - this.state.turnState().addDmgReceived(hitsForPsr); + this.state.turnState().addDmgReceived(damageReceived + ?? (this.getArmorTypeAt(loc) === 'HARDENED' + ? Math.floor(this.getArmorHits(loc, rear) / 2) - Math.floor(previousHits / 2) + : hits)); if (consolidateImmediately) this.state.consolidateLocations(); else this.applyUnderwaterBreachAndFlooding(); this.evaluateDestroyed(); @@ -932,14 +935,26 @@ export class CBTForceUnit extends ForceUnit { } this.state.locations.set({ ...this.state.locations(), [loc]: locations[loc] }); this.markEquipmentLocationsChanged(); - this.state.turnState().addDmgReceived(hits); this._rules.evaluateLegDestroyed(loc, hits); this.clearNarcFromCommittedPhysicallyDestroyedLocations(); this.evaluateDestroyed(); this.setModified(); const boundedPreviousHits = Math.min(internalPoints, Math.max(0, previousHits)); const boundedCurrentHits = Math.min(internalPoints, Math.max(0, this.getInternalHits(loc))); - const appliedDamage = Math.max(0, boundedCurrentHits - boundedPreviousHits); + const appliedDamage = boundedCurrentHits - boundedPreviousHits; + const structureKind = this.getStructureKindAt(loc); + const phaseDamage = appliedDamage >= 0 + ? mekStructurePhaseDamage( + appliedDamage, + internalPoints - boundedPreviousHits, + structureKind, + ) + : -mekStructurePhaseDamage( + -appliedDamage, + internalPoints - boundedCurrentHits, + structureKind, + ); + this.state.turnState().addDmgReceived(phaseDamage); // A single assignment is one hit/event, regardless of how many structure pips it marks. if (appliedDamage > 0) this.queueMekCriticalChance(loc, { ...context, @@ -1770,7 +1785,6 @@ export class CBTForceUnit extends ForceUnit { this.inventoryControl.markAmmoSourcesChanged(); this.psrOutcomeSelections.set({}); this.psrDiceSelections.set({}); - this.pendingMekFallRolls.set({}); this.state.resetTurnState(); this.evaluateDestroyed(); this.setModified(); @@ -1956,6 +1970,42 @@ export class CBTForceUnit extends ForceUnit { return count; } + getArmorTypeAt(location: string): ArmorType | null { + const patchworkType = this.getUnit().patchworkLayout?.[location]?.type; + if (patchworkType !== undefined) return ARMOR_TYPE_FROM_BLK_CODE[patchworkType] ?? null; + const armor = this.materialAtLocation(location, (equipment): equipment is ArmorEquipment => + equipment instanceof ArmorEquipment && equipment.armorType !== 'PATCHWORK'); + return armor ? armor.armorType as ArmorType : null; + } + + hasArmorType(type: ArmorType): boolean { + return Object.values(this.getUnit().patchworkLayout ?? {}) + .some(entry => ARMOR_TYPE_FROM_BLK_CODE[entry.type] === type) + || this.getUnit().comp.some(component => + component.eq instanceof ArmorEquipment && component.eq.armorType === type); + } + + getStructureKindAt(location: string): MekStructureKind { + const hybridType = this.getUnit().hybridLayout?.[location]?.type; + if (hybridType === MEK_STRUCTURE_TYPE.COMPOSITE) return 'composite'; + if (hybridType === MEK_STRUCTURE_TYPE.REINFORCED) return 'reinforced'; + const structure = this.materialAtLocation(location, (equipment): equipment is StructureEquipment => + equipment instanceof StructureEquipment); + if (structure?.hasFlag('F_COMPOSITE')) return 'composite'; + if (structure?.hasFlag('F_REINFORCED')) return 'reinforced'; + return 'standard'; + } + + private materialAtLocation( + location: string, + isMaterial: (equipment: unknown) => equipment is T, + ): T | null { + const materials = this.getUnit().comp.filter(component => isMaterial(component.eq)); + const located = materials.find(component => component.l?.split('/').includes(location)); + const material = located?.eq ?? (materials.length === 1 ? materials[0].eq : undefined); + return (material as T | undefined) ?? null; + } + resolvePendingCrewDeaths(): void { const pending = this.getCrewMembers().filter(crew => crew.getHits() >= DEAD_CREW_HIT_THRESHOLD && crew.getState() !== 'dead'); @@ -2090,24 +2140,20 @@ export class CBTForceUnit extends ForceUnit { } getPendingFalls(): readonly CBTPendingMekFall[] { - const drafts = this.pendingMekFallRolls(); return this.turnState().getPendingFalls().map(pending => ({ ...pending, - orientationRoll: drafts[pending.id]?.orientationRoll ?? null, - orientationDice: drafts[pending.id]?.orientationDice ?? null, - damageRolls: drafts[pending.id]?.damageRolls ?? [], + orientationRoll: pending.orientationRoll ?? null, + damageRolls: (pending.damageRolls ?? []).map(fallDamageRollForDialog), })); } getPendingFall(id?: string): CBTPendingMekFall | undefined { const pending = this.turnState().getPendingFall(id); if (!pending) return undefined; - const draft = this.pendingMekFallRolls()[pending.id]; return { ...pending, - orientationRoll: draft?.orientationRoll ?? null, - orientationDice: draft?.orientationDice ?? null, - damageRolls: draft?.damageRolls ?? [], + orientationRoll: pending.orientationRoll ?? null, + damageRolls: (pending.damageRolls ?? []).map(fallDamageRollForDialog), }; } @@ -2115,7 +2161,6 @@ export class CBTForceUnit extends ForceUnit { id: string, orientationRoll: number | null, damageRolls: readonly CBTMekFallDamageRoll[], - orientationDice: readonly number[] | null = null, ): boolean { const pending = this.getPendingFall(id); if (!pending) return false; @@ -2125,18 +2170,10 @@ export class CBTForceUnit extends ForceUnit { && orientationRoll <= 6 ? orientationRoll : null; - const normalizedOrientationDice = normalizedOrientation !== null - ? normalizeD6Faces(orientationDice, 1) - : null; - const matchingOrientationDice = normalizedOrientationDice?.[0] === normalizedOrientation - ? normalizedOrientationDice as readonly [number] - : null; - const normalizedDamageRolls = damageRolls.map(roll => { - const hitLocationRoll = roll.hitLocationRoll !== null - && Number.isInteger(roll.hitLocationRoll) - && roll.hitLocationRoll >= 2 - && roll.hitLocationRoll <= 12 - ? roll.hitLocationRoll + const normalizedDamageRolls = damageRolls.map(roll => { + const hitLocationDice = roll.hitLocationDice?.length === 2 + && roll.hitLocationDice.every(die => Number.isInteger(die) && die >= 1 && die <= 6) + ? [...roll.hitLocationDice] as [number, number] : null; const tripodLegRoll = roll.tripodLegRoll !== null && Number.isInteger(roll.tripodLegRoll) @@ -2144,32 +2181,15 @@ export class CBTForceUnit extends ForceUnit { && roll.tripodLegRoll <= 6 ? roll.tripodLegRoll : null; - const hitLocationDice = hitLocationRoll !== null - ? normalizeD6Faces(roll.hitLocationDice ?? null, 2) - : null; - const tripodLegDice = tripodLegRoll !== null - ? normalizeD6Faces(roll.tripodLegDice ?? null, 1) - : null; return { - hitLocationRoll, - ...(hitLocationDice && hitLocationDice[0] + hitLocationDice[1] === hitLocationRoll - ? { hitLocationDice: hitLocationDice as readonly [number, number] } - : {}), - tripodLegRoll, - ...(tripodLegDice?.[0] === tripodLegRoll - ? { tripodLegDice: tripodLegDice as readonly [number] } - : {}), + ...(hitLocationDice ? { hitLocationDice } : {}), + ...(tripodLegRoll !== null ? { tripodLegRoll } : {}), }; }); - this.pendingMekFallRolls.update(current => ({ - ...current, - [id]: { - orientationRoll: normalizedOrientation, - orientationDice: matchingOrientationDice, - damageRolls: normalizedDamageRolls, - }, - })); - return true; + return this.turnState().setPendingFallRolls(id, { + ...(normalizedOrientation !== null ? { orientationRoll: normalizedOrientation } : {}), + damageRolls: normalizedDamageRolls, + }); } /** @@ -2182,22 +2202,13 @@ export class CBTForceUnit extends ForceUnit { const checks = this.createFallSeatbeltChecks(pending.levelsFallen); const completed = this.turnState().replacePendingFallWithUnitChecks(id, checks); if (!completed) return false; - this.pendingMekFallRolls.update(current => { - const { [id]: _completed, ...remaining } = current; - return remaining; - }); if (checks.length > 0) this.automationTriggers.next({ kind: 'pending-unit-check' }); return true; } /** Removes automation work without treating the fall as resolved. */ skipPendingFall(id: string): boolean { - if (!this.turnState().discardPendingFall(id)) return false; - this.pendingMekFallRolls.update(current => { - const { [id]: _skipped, ...remaining } = current; - return remaining; - }); - return true; + return this.turnState().discardPendingFall(id); } /** diff --git a/src/app/models/force-serialization.spec.ts b/src/app/models/force-serialization.spec.ts index 99b753b80..387137f6c 100644 --- a/src/app/models/force-serialization.spec.ts +++ b/src/app/models/force-serialization.spec.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { AS_SERIALIZED_GROUP_SCHEMA, CBT_SERIALIZED_GROUP_SCHEMA, CBT_SERIALIZED_STATE_SCHEMA, C3_NETWORK_GROUP_SCHEMA, CRIT_SLOT_SCHEMA, FORCE_TAG_MAX_COUNT, HEAT_SCHEMA, sanitizeForceTagLabels, sanitizeForceTags, TURN_STATE_SCHEMA } from './force-serialization'; +import { AS_SERIALIZED_GROUP_SCHEMA, CBT_SERIALIZED_GROUP_SCHEMA, CBT_SERIALIZED_STATE_SCHEMA, C3_NETWORK_GROUP_SCHEMA, CRIT_SLOT_SCHEMA, FORCE_TAG_MAX_COUNT, HEAT_SCHEMA, LOCATION_SCHEMA, sanitizeForceTagLabels, sanitizeForceTags, TURN_STATE_SCHEMA } from './force-serialization'; import { Sanitizer } from '../utils/sanitizer.util'; import { C3NetworkType } from './c3-network.model'; @@ -222,6 +222,11 @@ describe('heat state sanitization', () => { id: 'fall:1', source: 'stand-attempt', levelsFallen: 1, + orientationRoll: 4, + damageRolls: [ + { hitLocationDice: [5, 2] }, + {}, + ], }, { type: 'mek-critical-chance', @@ -265,8 +270,7 @@ describe('heat state sanitization', () => { chanceOrigin: { throughArmorHitArc: 'right' }, floatingLocation: { hitArc: 'right', - locationRoll: 9, - dice: [4, 5], + hitLocationDice: [4, 5], }, }, { type: 'unit-check', id: 'check:1', kind: 'heat-shutdown', target: 5 }, @@ -297,7 +301,17 @@ describe('heat state sanitization', () => { target: 6, result: { kind: 'roll', dice: [3, 4] }, }, - { type: 'mek-fall', id: 'fall:1', source: 'stand-attempt', levelsFallen: 1 }, + { + type: 'mek-fall', + id: 'fall:1', + source: 'stand-attempt', + levelsFallen: 1, + orientationRoll: 4, + damageRolls: [ + { hitLocationDice: [5, 2] }, + {}, + ], + }, { type: 'mek-critical-chance', id: 'chance:1', @@ -340,8 +354,7 @@ describe('heat state sanitization', () => { chanceOrigin: { throughArmorHitArc: 'right' }, floatingLocation: { hitArc: 'right', - locationRoll: 9, - dice: [4, 5], + hitLocationDice: [4, 5], }, }, ], @@ -368,10 +381,14 @@ describe('heat state sanitization', () => { location: 'RT', targetLocation: 'RT', remainingHits: 1, - floatingLocation: { hitArc: 'right', locationRoll: 9, dice: [6, 6] }, + floatingLocation: { hitArc: 'right', hitLocationDice: [6, 7] }, }, { type: 'mek-critical-chance', id: 'bad:4', location: 'CT', result: 5 }, { type: 'mek-fall', id: 'bad:5', source: 'manual', levelsFallen: 0 }, + { + type: 'mek-fall', id: 'bad:roll', source: 'psr', levelsFallen: 0, + orientationRoll: 7, + }, ], pendingUnitChecks: [{ id: 'legacy:1' }], pendingCriticals: [{ id: 'legacy:2' }], @@ -379,6 +396,15 @@ describe('heat state sanitization', () => { }, TURN_STATE_SCHEMA)).toEqual({}); }); + it('retains only an open typed combat pilot-damage group', () => { + expect(Sanitizer.sanitize({ pilotDamageGroup: ' combat:test ' }, TURN_STATE_SCHEMA)) + .toEqual({ pilotDamageGroup: 'combat:test' }); + expect(Sanitizer.sanitize({ pilotDamageGroup: 'phase-closed:combat:test' }, TURN_STATE_SCHEMA)) + .toEqual({}); + expect(Sanitizer.sanitize({ pilotDamageGroup: 'heat:test' }, TURN_STATE_SCHEMA)) + .toEqual({}); + }); + it('preserves an empty critical-chance origin because its presence is the undo marker', () => { expect(Sanitizer.sanitize({ pendingEvents: [{ @@ -402,6 +428,22 @@ describe('heat state sanitization', () => { }); }); +describe('location damage serialization', () => { + it('preserves committed and pending material damage in the existing location counters', () => { + expect(Sanitizer.sanitize({ + armor: 2, + pendingArmor: 1, + internal: 1, + pendingInternal: 2, + }, LOCATION_SCHEMA)).toEqual({ + armor: 2, + pendingArmor: 1, + internal: 1, + pendingInternal: 2, + }); + }); +}); + describe('rule check sanitization', () => { it('preserves valid records and rejects malformed records', () => { const sanitized = Sanitizer.sanitize({ diff --git a/src/app/models/force-serialization.ts b/src/app/models/force-serialization.ts index eaa2c4576..d29195993 100644 --- a/src/app/models/force-serialization.ts +++ b/src/app/models/force-serialization.ts @@ -18,6 +18,7 @@ import { type PendingUnitCheckKind, type UnitCheckCause, } from './unit-check.model'; +import { isOpenCombatPilotDamageGroup } from '../utils/pilot-damage-group.util'; export { PENDING_UNIT_CHECK_KINDS, @@ -131,12 +132,14 @@ export interface SerializedPendingMekCriticalChanceOrigin { readonly throughArmorHitArc?: MekHitArc; } +export interface SerializedMekHitLocationRoll { + readonly hitLocationDice?: readonly [number, number]; + readonly tripodLegRoll?: number; +} + /** Persisted location-table choice awaiting confirmation before slot resolution. */ -export interface SerializedPendingMekFloatingCriticalLocation { +export interface SerializedPendingMekFloatingCriticalLocation extends SerializedMekHitLocationRoll { readonly hitArc: MekHitArc; - readonly locationRoll?: number; - readonly dice?: readonly [number, number]; - readonly tripodLegRoll?: number; } export type SerializedPendingMekCriticalCaseII = @@ -163,11 +166,15 @@ export interface SerializedPendingMekCritical extends SerializedPendingMekCritic export type CBTMekFallSource = 'psr' | 'stand-attempt'; +export type SerializedMekFallDamageRoll = SerializedMekHitLocationRoll; + /** A fall remains queued until its damage is accepted or explicitly ignored. */ export interface SerializedPendingMekFall extends SerializedPendingEventBase { readonly type: 'mek-fall'; readonly source: CBTMekFallSource; readonly levelsFallen: number; + readonly orientationRoll?: number; + readonly damageRolls?: readonly SerializedMekFallDamageRoll[]; } export type SerializedPendingCheckResult = @@ -261,6 +268,8 @@ export type SerializedEndTurnCheckpoint = 'phase-ended' | 'heat-staged'; export interface SerializedTurnState { turnCounter?: number; + /** Open combat-phase pilot damage event retained across save/reload. */ + pilotDamageGroup?: string; endTurnCheckpoint?: SerializedEndTurnCheckpoint; airborne?: boolean; moveMode?: MotiveModes; @@ -682,32 +691,36 @@ function sanitizePendingCriticalChanceFacts( }; } -function sanitizePendingFloatingCriticalLocation( - value: unknown, -): SerializedPendingMekFloatingCriticalLocation | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null; - const record = value as Record; - const hitArc = record['hitArc']; - if (hitArc !== 'front' && hitArc !== 'rear' && hitArc !== 'left' && hitArc !== 'right') return null; - const locationRoll = record['locationRoll'] === undefined +function sanitizeMekHitLocationRoll( + record: Record, +): SerializedMekHitLocationRoll | null { + const hitLocationDice = record['hitLocationDice'] === undefined ? undefined - : sanitizePendingInteger(record['locationRoll'], 2, 12); - if (record['locationRoll'] !== undefined && locationRoll === undefined) return null; - const dice = record['dice'] === undefined ? undefined : sanitizeD6Roll(record['dice'], [2]); - if (record['dice'] !== undefined && !dice) return null; - if (dice && locationRoll !== dice[0] + dice[1]) return null; + : sanitizeD6Roll(record['hitLocationDice'], [2]); + if (record['hitLocationDice'] !== undefined && !hitLocationDice) return null; const tripodLegRoll = record['tripodLegRoll'] === undefined ? undefined : sanitizePendingInteger(record['tripodLegRoll'], 1, 6); if (record['tripodLegRoll'] !== undefined && tripodLegRoll === undefined) return null; return { - hitArc, - ...(locationRoll !== undefined ? { locationRoll } : {}), - ...(dice ? { dice: dice as readonly [number, number] } : {}), + ...(hitLocationDice + ? { hitLocationDice: hitLocationDice as readonly [number, number] } + : {}), ...(tripodLegRoll !== undefined ? { tripodLegRoll } : {}), }; } +function sanitizePendingFloatingCriticalLocation( + value: unknown, +): SerializedPendingMekFloatingCriticalLocation | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record; + const hitArc = record['hitArc']; + if (hitArc !== 'front' && hitArc !== 'rear' && hitArc !== 'left' && hitArc !== 'right') return null; + const roll = sanitizeMekHitLocationRoll(record); + return roll ? { hitArc, ...roll } : null; +} + function sanitizePendingCheckResolution(record: Record): SerializedPendingCheckResolution | null { const target = sanitizePendingInteger(record['target'], 2, 12); const rawResult = record['result']; @@ -873,9 +886,23 @@ function sanitizePendingEvent(record: Record): SerializedPendin const id = sanitizePendingString(record['id'], 256); const levelsFallen = sanitizePendingInteger(record['levelsFallen'], 0, 100); const source = record['source']; - return id && levelsFallen !== undefined && (source === 'psr' || source === 'stand-attempt') - ? { type: 'mek-fall', id, source, levelsFallen } - : null; + const orientationRoll = record['orientationRoll'] === undefined + ? undefined + : sanitizePendingInteger(record['orientationRoll'], 1, 6); + if (!id || levelsFallen === undefined || (source !== 'psr' && source !== 'stand-attempt') + || (record['orientationRoll'] !== undefined && orientationRoll === undefined)) return null; + const damageRolls = record['damageRolls'] === undefined + ? undefined + : sanitizePendingMekFallDamageRolls(record['damageRolls']); + if (record['damageRolls'] !== undefined && !damageRolls) return null; + return { + type: 'mek-fall', + id, + source, + levelsFallen, + ...(orientationRoll !== undefined ? { orientationRoll } : {}), + ...(damageRolls ? { damageRolls } : {}), + }; } case 'unit-check': return sanitizePendingUnitCheck(record); @@ -884,6 +911,19 @@ function sanitizePendingEvent(record: Record): SerializedPendin } } +function sanitizePendingMekFallDamageRolls(value: unknown): SerializedMekFallDamageRoll[] | undefined { + if (!Array.isArray(value) || value.length > 1024) return undefined; + const rolls: SerializedMekFallDamageRoll[] = []; + for (const candidate of value) { + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return undefined; + const record = candidate as Record; + const roll = sanitizeMekHitLocationRoll(record); + if (!roll) return undefined; + rolls.push(roll); + } + return rolls; +} + function sanitizePendingEvents(value: unknown): SerializedPendingEvent[] | undefined { if (!Array.isArray(value)) return undefined; const seenIds = new Set(); @@ -900,6 +940,10 @@ function sanitizePendingEvents(value: unknown): SerializedPendingEvent[] | undef export const TURN_STATE_SCHEMA = Sanitizer.schema() .custom('turnCounter', sanitizeOptionalNonNegativeInteger) + .custom('pilotDamageGroup', (value: unknown) => { + const group = sanitizePendingString(value, 80); + return isOpenCombatPilotDamageGroup(group) ? group : undefined; + }) .custom('endTurnCheckpoint', (value: unknown) => value === 'phase-ended' || value === 'heat-staged' ? value : undefined) .custom('airborne', (value: unknown) => typeof value === 'boolean' ? value : undefined) diff --git a/src/app/models/rules/mek-rules.spec.ts b/src/app/models/rules/mek-rules.spec.ts index 72083e66f..adf0227a9 100644 --- a/src/app/models/rules/mek-rules.spec.ts +++ b/src/app/models/rules/mek-rules.spec.ts @@ -611,6 +611,30 @@ describe('MekRules', () => { expect(createRulesHarness({ rulesId: 'tw' }).standingUpPSRModifier).toBe(0); }); + it('waives the Patchwork Hardened Running MP penalty when no leg uses it', () => { + const torsoOnly = createForceUnitHarness({ walk: 6, run: 9 }); + torsoOnly.getUnit().armorType = 'Patchwork'; + torsoOnly.getUnit().patchworkLayout = { + CT: { type: 4, clan: false }, + LL: { type: 0, clan: false }, + RL: { type: 0, clan: false }, + }; + const hardenedLeg = createForceUnitHarness({ walk: 6, run: 8 }); + hardenedLeg.getUnit().armorType = 'Patchwork'; + hardenedLeg.getUnit().patchworkLayout = { + CT: { type: 0, clan: false }, + LL: { type: 4, clan: false }, + RL: { type: 0, clan: false }, + }; + + expect((torsoOnly.rules as MekRules).movementState()?.run).toBe(9); + expect((hardenedLeg.rules as MekRules).movementState()?.run).toBe(8); + expect(torsoOnly.rules.PSRModifiers().modifiers) + .toContain(jasmine.objectContaining({ reason: 'Mounts Hardened Armor', pilotCheck: 1 })); + expect(hardenedLeg.rules.PSRModifiers().modifiers) + .toContain(jasmine.objectContaining({ reason: 'Mounts Hardened Armor', pilotCheck: 1 })); + }); + it('removes a broken targeting computer modifier from direct-fire weapons at every range', () => { const activeForceUnit = createForceUnitHarness({ critSlots: [crit('Targeting Computer', false)] }); const destroyedForceUnit = createForceUnitHarness({ critSlots: [crit('Targeting Computer')] }); @@ -1360,6 +1384,175 @@ describe('MekRules', () => { ]); }); + it('displays different CORE quad kick values in front/rear order', () => { + const forceUnit = createForceUnitHarness({ + subtype: 'Quad BattleMek', + internalLocations: ['FLL', 'FRL', 'RLL', 'RRL'], + critSlots: [{ ...crit('Upper Leg Actuator'), loc: 'RLL' }], + }); + const rules = forceUnit.rules as MekRules; + const kick = new MountedEquipment({ + owner: forceUnit, + id: 'kick', + name: 'kick', + intrinsicPhysicalAttack: true, + }); + const display = rules.applyInventoryControlDisplayEffects(kick, { + name: 'Kick', location: '—', heat: '—', damage: '10', hit: '+1', + min: '—', short: '—', medium: '—', long: '—', + }); + + expect(display.damage).toBe('F:10 | R:5'); + expect(display.hit).toBe('-1/+1'); + expect(rules.resolveKickArcHitDisplay(kick)).toEqual({ text: '-1/+1', weakened: true }); + expect(rules.canPerformEquipmentAction(kick, 'physical-attack')).toBeTrue(); + }); + + it('applies CORE quad foot damage to both kick arcs while keeping leg actuator damage local', () => { + const forceUnit = createForceUnitHarness({ + subtype: 'Quad BattleMek', + internalLocations: ['FLL', 'FRL', 'RLL', 'RRL'], + critSlots: [ + { ...crit('Foot Actuator'), loc: 'FLL' }, + { ...crit('Lower Leg Actuator'), loc: 'RLL' }, + ], + }); + const rules = forceUnit.rules as MekRules; + const kick = new MountedEquipment({ + owner: forceUnit, + id: 'kick', + name: 'kick', + intrinsicPhysicalAttack: true, + }); + const display = rules.applyInventoryControlDisplayEffects(kick, { + name: 'Kick', location: '—', heat: '—', damage: '10', hit: '+2', + min: '—', short: '—', medium: '—', long: '—', + }); + + expect(display.damage).toBe('F:10 | R:5'); + expect(display.hit).toBe('+0/+2'); + }); + + it('uses a dash for a CORE quad kick arc disabled by a hip hit', () => { + const forceUnit = createForceUnitHarness({ + subtype: 'Quad BattleMek', + internalLocations: ['FLL', 'FRL', 'RLL', 'RRL'], + critSlots: [{ ...crit('Hip'), loc: 'FLL' }], + }); + const rules = forceUnit.rules as MekRules; + const kick = new MountedEquipment({ + owner: forceUnit, + id: 'kick', + name: 'kick', + intrinsicPhysicalAttack: true, + }); + const display = rules.applyInventoryControlDisplayEffects(kick, { + name: 'Kick', location: '—', heat: '—', damage: '10', hit: '-1', + min: '—', short: '—', medium: '—', long: '—', + }); + + expect(display.damage).toBe('F:— | R:10'); + expect(display.hit).toBe('—/-1'); + expect(rules.canPerformEquipmentAction(kick, 'physical-attack')).toBeTrue(); + }); + + it('labels a disabled rear CORE quad kick arc', () => { + const forceUnit = createForceUnitHarness({ + subtype: 'Quad BattleMek', + internalLocations: ['FLL', 'FRL', 'RLL', 'RRL'], + critSlots: [{ ...crit('Hip'), loc: 'RLL' }], + }); + const rules = forceUnit.rules as MekRules; + const kick = new MountedEquipment({ + owner: forceUnit, + id: 'kick', + name: 'kick', + intrinsicPhysicalAttack: true, + }); + const display = rules.applyInventoryControlDisplayEffects(kick, { + name: 'Kick', location: '—', heat: '—', damage: '10', hit: '-1', + min: '—', short: '—', medium: '—', long: '—', + }); + + expect(display.damage).toBe('F:10 | R:—'); + expect(rules.canPerformEquipmentAction(kick, 'physical-attack')).toBeTrue(); + }); + + it('disables CORE quad kicks once a second hip hit adds the standard hip effect', () => { + const forceUnit = createForceUnitHarness({ + subtype: 'Quad BattleMek', + internalLocations: ['FLL', 'FRL', 'RLL', 'RRL'], + critSlots: [ + { ...crit('Hip'), id: 'FLL-hip', loc: 'FLL' }, + { ...crit('Hip'), id: 'FRL-hip', loc: 'FRL' }, + ], + }); + const rules = forceUnit.rules as MekRules; + const kick = new MountedEquipment({ + owner: forceUnit, + id: 'kick', + name: 'kick', + intrinsicPhysicalAttack: true, + }); + const display = rules.applyInventoryControlDisplayEffects(kick, { + name: 'Kick', location: '—', heat: '—', damage: '10', hit: '+3', + min: '—', short: '—', medium: '—', long: '—', + }); + + expect(display.damage).toBe('—'); + expect(display.hit).toBe('—'); + expect(rules.canPerformEquipmentAction(kick, 'physical-attack')).toBeFalse(); + }); + + it('collapses equal CORE quad kick arc values to one value', () => { + const forceUnit = createForceUnitHarness({ + subtype: 'Quad BattleMek', + internalLocations: ['FLL', 'FRL', 'RLL', 'RRL'], + critSlots: [ + { ...crit('Lower Leg Actuator'), loc: 'FLL' }, + { ...crit('Upper Leg Actuator'), loc: 'RLL' }, + ], + }); + const rules = forceUnit.rules as MekRules; + const kick = new MountedEquipment({ + owner: forceUnit, + id: 'kick', + name: 'kick', + intrinsicPhysicalAttack: true, + }); + const display = rules.applyInventoryControlDisplayEffects(kick, { + name: 'Kick', location: '—', heat: '—', damage: '10', hit: '+3', + min: '—', short: '—', medium: '—', long: '—', + }); + + expect(display.damage).toBe('5'); + expect(display.hit).toBe('+1'); + }); + + it('keeps TW quad kick actuator effects global and single-valued', () => { + const forceUnit = createForceUnitHarness({ + rulesId: 'tw', + subtype: 'Quad BattleMek', + internalLocations: ['FLL', 'FRL', 'RLL', 'RRL'], + critSlots: [{ ...crit('Upper Leg Actuator'), loc: 'RLL' }], + }); + const rules = forceUnit.rules as MekRules; + const kick = new MountedEquipment({ + owner: forceUnit, + id: 'kick', + name: 'kick', + intrinsicPhysicalAttack: true, + }); + const display = rules.applyInventoryControlDisplayEffects(kick, { + name: 'Kick', location: '—', heat: '—', damage: '10', hit: '+0', + min: '—', short: '—', medium: '—', long: '—', + }); + + expect(display.damage).toBe('5'); + expect(display.hit).toBe('+0'); + expect(rules.resolveKickArcHitDisplay(kick)).toBeNull(); + }); + it('identifies mounted physical weapon actuator modifiers without a generic fallback', () => { const forceUnit = createForceUnitHarness({ critSlots: [ @@ -3455,9 +3648,14 @@ describe('MekRules', () => { run: 6, }); const turnState = forceUnit.turnState(); + const rules = forceUnit.rules as MekRules; turnState.moveMode.set('run'); turnState.moveDistance.set(0); + expect(rules.systemsStatus().destroyedHipsCount).toBe(0); + expect(rules.movementState()?.walk).toBe(2); + expect(rules.PSRModifiers().modifier).toBe(2); + const runOption = forceUnit.getAvailableMotiveModes(false) .find(option => option.mode === 'run'); @@ -3631,7 +3829,7 @@ describe('MekRules', () => { }); forceUnit.getCritSlots().forEach(slot => forceUnit.rules.evaluateCritSlotHit(slot)); const hipChecks = forceUnit.turnState().getPSRChecks() - .filter(check => check.reason === 'Hip hit'); + .filter(check => check.kind === PSR_CHECK_KIND.LEG_DAMAGE); expect(hipChecks.length).withContext(`${hipHits} current hip hits`).toBe(hipHits - 1); expect(hipChecks.reduce((total, check) => total + (check.pilotCheck ?? 0), 0)) diff --git a/src/app/models/rules/mek-rules.ts b/src/app/models/rules/mek-rules.ts index 35055446d..33acdb146 100644 --- a/src/app/models/rules/mek-rules.ts +++ b/src/app/models/rules/mek-rules.ts @@ -35,6 +35,13 @@ import { uuidv7 } from '../../utils/uuid.util'; type ArmLocation = 'LA' | 'RA'; +interface QuadKickArcState { + readonly canKick: boolean; + readonly destroyedLegActuatorsCount: number; +} + +type QuadKickArcStates = readonly [front: QuadKickArcState, rear: QuadKickArcState]; + const LEG_DAMAGE_MOVEMENT_CRITICAL_NAMES: Partial> = { [PSR_CHECK_KIND.DAMAGED_LEG_ACTUATOR_MOVEMENT]: ['Leg', 'Foot', 'Hip'], [PSR_CHECK_KIND.DAMAGED_HIP_MOVEMENT]: ['Hip'], @@ -100,6 +107,7 @@ export class MekRules extends UnitTypeRulesBase { override readonly standingUpPSRModifier: number = -1; protected get gyroHitPSRModifier(): number { return 2; } protected get hipPSRModifier(): number { return 1; } + protected get usesArcSpecificQuadKickEffects(): boolean { return true; } /** The first hip hit on a CORE quad has only the quad-specific effects. */ protected standardHipEffectHitCount(totalHipHits: number, isQuadruped: boolean): number { @@ -1344,7 +1352,7 @@ export class MekRules extends UnitTypeRulesBase { reason: "Mounts AES in its legs" }); } - const hardenedArmor = this.unit.getUnit().armorType === 'Hardened'; + const hardenedArmor = this.unit.hasArmorType('HARDENED'); if (hardenedArmor) { preExisting += 1; // Hardened armor gives +1 modifier modifiers.push({ @@ -1584,7 +1592,7 @@ export class MekRules extends UnitTypeRulesBase { if (movement.run === 0) return this.getRunningMinimumMovementDistance(); const runValueCoeff = 1.5 + this.unit.getRunMovementMultiplierBonus(turnState); - const armorModifierOnRun = (this.unit.getUnit().armorType === 'Hardened') ? -1 : 0; + const armorModifierOnRun = this.hardenedArmorRunModifier(); return Math.max(0, Math.round(movement.walk * runValueCoeff) + armorModifierOnRun); } @@ -1837,7 +1845,7 @@ export class MekRules extends UnitTypeRulesBase { const restoredWalk = unit.walk + this.restoredEquipmentWalkMP(mobilityEquipment); const restoredRun = Math.max( 0, - Math.round(restoredWalk * 1.5) + (unit.armorType === 'Hardened' ? -1 : 0), + Math.round(restoredWalk * 1.5) + this.hardenedArmorRunModifier(), ); const baselineJump = this.equipmentAdjustedJumpBaseline( unit.jump, @@ -2091,7 +2099,7 @@ export class MekRules extends UnitTypeRulesBase { // Run MP const hasWorkingMASC = systemsStatus.hasMASC && !systemsStatus.destroyedMASC; const hasWorkingSupercharger = systemsStatus.hasSupercharger && !systemsStatus.destroyedSupercharger; - const armorModifierOnRun = (unit.armorType === 'Hardened') ? -1 : 0; + const armorModifierOnRun = this.hardenedArmorRunModifier(); let runValue: number; let maxRunValue: number; if (walkValue === 0 || baseMovement.runDisabled) { @@ -2142,6 +2150,7 @@ export class MekRules extends UnitTypeRulesBase { if (!this.unit.isLoaded()) return null; const systemsStatus = this.systemsStatus(); + const quadKickArcStates = this.quadKickArcStates(); const destroyedLA = this.unit.isInternalLocCommittedDestroyed('LA'); const destroyedRA = this.unit.isInternalLocCommittedDestroyed('RA'); const locationModifiers = systemsStatus.locationModifiers; @@ -2149,7 +2158,9 @@ export class MekRules extends UnitTypeRulesBase { const chargeDamage = this.chargeDamage(); return { - canKick: systemsStatus.destroyedLegsCount === 0 && systemsStatus.destroyedHipsCount === 0, + canKick: quadKickArcStates + ? quadKickArcStates.some(state => state.canKick) + : systemsStatus.destroyedLegsCount === 0 && systemsStatus.destroyedHipsCount === 0, kickMod: (systemsStatus.destroyedLegActuatorsCount * 2) + systemsStatus.destroyedFeetCount - (systemsStatus.hasFunctionalLegAES ? 1 : 0), canPunch: { @@ -2178,6 +2189,55 @@ export class MekRules extends UnitTypeRulesBase { }; }); + private hardenedArmorRunModifier(): number { + const config = inferMekConfigFromLocations(this.unit.locations?.internal.keys() ?? []); + return getMekLegLocations(config).some(location => this.unit.getArmorTypeAt(location) === 'HARDENED') + ? -1 + : 0; + } + + /** CORE quad kicks resolve upper/lower actuator damage and hip availability by arc. */ + private quadKickArcStates(): QuadKickArcStates | null { + if (!this.usesArcSpecificQuadKickEffects || !this.isQuadrupedMek()) return null; + + const frontLegs = ['FLL', 'FRL'] as const; + const rearLegs = ['RLL', 'RRL'] as const; + const allLegs = [...frontLegs, ...rearLegs]; + const critSlots = this.unit.getCritSlots(); + const hasDestroyedLeg = (locations: readonly string[]) => locations.some(loc => this.isLegDestroyed(loc, true)); + const hasUnavailableHip = (locations: readonly string[]) => critSlots.some(slot => + locations.some(loc => slot.loc === loc) + && this.isNamedCrit(slot, 'Hip') + && this.isCritUnavailable(slot)); + const destroyedLegActuatorsCount = (locations: readonly string[]) => critSlots.filter(slot => + !!slot.loc + && locations.some(loc => slot.loc === loc) + && !this.isLegDestroyed(slot.loc, true) + && (this.isNamedCrit(slot, 'Upper Leg') || this.isNamedCrit(slot, 'Lower Leg')) + && this.isCritUnavailable(slot)).length; + + const frontLegDestroyed = hasDestroyedLeg(frontLegs); + const destroyedLegsCount = allLegs.filter(loc => this.isLegDestroyed(loc, true)).length; + const hipHitsCount = critSlots.filter(slot => slot.loc + && allLegs.some(loc => slot.loc === loc) + && !this.isLegDestroyed(slot.loc, true) + && this.isNamedCrit(slot, 'Hip') + && this.isCritUnavailable(slot)).length; + const standardHipEffectPreventsKicking = this.standardHipEffectHitCount(hipHitsCount, true) > 0; + const quadLegLossPreventsKicking = destroyedLegsCount >= 2; + const cannotKick = standardHipEffectPreventsKicking || quadLegLossPreventsKicking; + return [ + { + canKick: !cannotKick && !frontLegDestroyed && !hasUnavailableHip(frontLegs), + destroyedLegActuatorsCount: destroyedLegActuatorsCount(frontLegs), + }, + { + canKick: !cannotKick && !frontLegDestroyed && !hasDestroyedLeg(rearLegs) && !hasUnavailableHip(rearLegs), + destroyedLegActuatorsCount: destroyedLegActuatorsCount(rearLegs), + }, + ]; + } + override chargeDamage(): ChargeDamage { const critSlots = this.unit.getCritSlots(); const totalSpikes = critSlots.filter(slot => this.isNamedCrit(slot, 'Spikes')).length; @@ -2221,7 +2281,13 @@ export class MekRules extends UnitTypeRulesBase { location, ignoreMyomer, ); - return resolved ? { ...display, damage: resolved.text } : display; + const kickHitDisplay = attackType === 'kick' ? this.resolveKickArcHitDisplay(entry) : null; + if (!resolved && !kickHitDisplay) return display; + return { + ...display, + ...(resolved && { damage: resolved.text }), + ...(kickHitDisplay && { hit: kickHitDisplay.text }), + }; } resolveInventoryMeleeDamageDisplay( @@ -2260,6 +2326,30 @@ export class MekRules extends UnitTypeRulesBase { const designBaselineDamage = attackType === 'punch' ? this.getPunchDesignBaselineDamage(effect.baseDamage, location) : effect.baseDamage; + const quadKickArcStates = attackType === 'kick' ? this.quadKickArcStates() : null; + if (quadKickArcStates) { + const arcResults = quadKickArcStates.map(state => state.canKick + ? this.computeMeleeDamage( + effect.baseDamage, + attackType, + location, + effect.ignoreMyomer, + state.destroyedLegActuatorsCount, + ) + : null); + const arcTexts = arcResults.map(result => result + ? (result.damage === result.maxDamage ? `${result.damage}` : `${result.damage} [${result.maxDamage}]`) + : '—'); + const firstAvailableResult = arcResults.find(result => result !== null); + return { + damage: firstAvailableResult?.damage ?? 0, + text: arcTexts[0] === arcTexts[1] + ? arcTexts[0] + : `F:${arcTexts[0]} | R:${arcTexts[1]}`, + weakened: quadKickArcStates.some((state, index) => + !state.canKick || (arcResults[index]?.damage ?? designBaselineDamage) < designBaselineDamage), + }; + } const { damage, maxDamage } = this.computeMeleeDamage( effect.baseDamage, attackType, @@ -2273,6 +2363,38 @@ export class MekRules extends UnitTypeRulesBase { }; } + /** Front/rear CORE quad kick hit modifiers, front first. Identical values collapse to one. */ + resolveKickArcHitDisplay(entry: MountedEquipment): { text: string; weakened: boolean } | null { + if (!entry.isIntrinsicPhysicalAttack() + || (entry.name.toLowerCase() !== 'kick' && entry.name.toLowerCase() !== 'kick [talons]')) return null; + const arcStates = this.quadKickArcStates(); + if (!arcStates) return null; + + const arcDisplays = arcStates.map(state => { + if (!state.canKick) return { text: '—', weakened: true }; + const resolution = this.unit.gameRules.resolveToHit({ + subject: entry, + stateModifiers: [ + ...this.getKickToHitModifierBreakdown(state.destroyedLegActuatorsCount), + ...this.getUnitEquipmentToHitModifiers(entry), + ], + }); + const value = resolution.value; + const text = value === null + ? '—' + : typeof value === 'number' + ? (value >= 0 ? `+${value}` : value.toString()) + : value; + return { text, weakened: resolution.weakened }; + }); + return { + text: arcDisplays[0].text === arcDisplays[1].text + ? arcDisplays[0].text + : `${arcDisplays[0].text}/${arcDisplays[1].text}`, + weakened: arcDisplays.some(display => display.weakened), + }; + } + resolveShieldDamageDisplay(entry: MountedEquipment): { damage: number | null; text: string; weakened: boolean } { const profile = resolveShieldProfile(entry.equipment); if (!profile) { @@ -2433,25 +2555,9 @@ export class MekRules extends UnitTypeRulesBase { break; case 'kick [talons]': case 'kick': - if (systemsStatus.destroyedLegActuatorsCount > 0) { - hitModifierBreakdown.push({ - label: this.countedDestroyedLabel('Leg Actuator', systemsStatus.destroyedLegActuatorsCount), - modifier: systemsStatus.destroyedLegActuatorsCount * 2, - weakened: true - }); - } - if (systemsStatus.destroyedFeetCount > 0) { - hitModifierBreakdown.push({ - label: this.countedDestroyedLabel('Foot Actuator', systemsStatus.destroyedFeetCount), - modifier: systemsStatus.destroyedFeetCount, - weakened: true - }); - } - if (systemsStatus.hasFunctionalLegAES) { - hitModifierBreakdown.push({ label: 'Leg AES', modifier: -1 }); - } else if (systemsStatus.hasLegAES) { - hitModifierBreakdown.push({ label: 'Leg AES Destroyed', modifier: 0, weakened: true }); - } + hitModifierBreakdown.push(...this.getKickToHitModifierBreakdown( + systemsStatus.destroyedLegActuatorsCount, + )); break; } } else if (entry.isPhysicalWeapon()) { @@ -2501,6 +2607,31 @@ export class MekRules extends UnitTypeRulesBase { return [...hitModifierBreakdown, ...this.getUnitEquipmentToHitModifiers(entry)]; } + private getKickToHitModifierBreakdown(destroyedLegActuatorsCount: number): ToHitModifierBreakdownEntry[] { + const systemsStatus = this.systemsStatus(); + const breakdown: ToHitModifierBreakdownEntry[] = []; + if (destroyedLegActuatorsCount > 0) { + breakdown.push({ + label: this.countedDestroyedLabel('Leg Actuator', destroyedLegActuatorsCount), + modifier: destroyedLegActuatorsCount * 2, + weakened: true, + }); + } + if (systemsStatus.destroyedFeetCount > 0) { + breakdown.push({ + label: this.countedDestroyedLabel('Foot Actuator', systemsStatus.destroyedFeetCount), + modifier: systemsStatus.destroyedFeetCount, + weakened: true, + }); + } + if (systemsStatus.hasFunctionalLegAES) { + breakdown.push({ label: 'Leg AES', modifier: -1 }); + } else if (systemsStatus.hasLegAES) { + breakdown.push({ label: 'Leg AES Destroyed', modifier: 0, weakened: true }); + } + return breakdown; + } + private addArmActuatorBreakdown( breakdown: ToHitModifierBreakdownEntry[], armStatus: ReturnType['locationModifiers'][string], @@ -2617,12 +2748,14 @@ export class MekRules extends UnitTypeRulesBase { * @param attackType - which melee attack (determines which actuators matter) * @param loc - arm location (for punch/claw) * @param ignoreMyomer - true for weapons immune to TSM bonus (e.g. flails) + * @param kickActuatorHits - optional arc-specific upper/lower leg actuator count */ computeMeleeDamage( baseDamage: number, attackType: 'punch' | 'kick' | 'club' | 'physWeapon' | 'claw', loc?: string, - ignoreMyomer?: boolean + ignoreMyomer?: boolean, + kickActuatorHits?: number, ): { damage: number; maxDamage: number } { const ss = this.systemsStatus(); let damage = baseDamage; @@ -2642,7 +2775,7 @@ export class MekRules extends UnitTypeRulesBase { if (damage < 1) damage = 1; } } else if (attackType === 'kick') { - for (let i = 0; i < ss.destroyedLegActuatorsCount; i++) { + for (let i = 0; i < (kickActuatorHits ?? ss.destroyedLegActuatorsCount); i++) { damage = Math.floor(damage * 0.5); if (damage < 1) damage = 1; } diff --git a/src/app/models/rules/tw-rules.ts b/src/app/models/rules/tw-rules.ts index a8f1843de..48510ac64 100644 --- a/src/app/models/rules/tw-rules.ts +++ b/src/app/models/rules/tw-rules.ts @@ -353,6 +353,7 @@ export class TWMekRules extends MekRules { protected override get gyroHitPSRModifier(): number { return 3; } protected override get hipPSRModifier(): number { return 2; } + protected override get usesArcSpecificQuadKickEffects(): boolean { return false; } /** BMM/TW applies the standard hip effects to a quad starting with its first hit. */ protected override standardHipEffectHitCount(totalHipHits: number, _isQuadruped: boolean): number { diff --git a/src/app/models/rules/vehicle-rules.spec.ts b/src/app/models/rules/vehicle-rules.spec.ts index 8dc73e0b7..0f593610f 100644 --- a/src/app/models/rules/vehicle-rules.spec.ts +++ b/src/app/models/rules/vehicle-rules.spec.ts @@ -19,6 +19,7 @@ import { EquipmentFlag } from '../equipment-flags.type'; import { combineEquipmentStatuses, type EquipmentStatus, type EquipmentStatusFacts } from '../equipment-status.model'; import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE } from './unit-type-rules'; import { createHandlerQueryContext } from '../../services/equipment-interaction-registry.service'; +import type { ArmorType } from '../entity/types'; const mascHandler = new MascHandler(); @@ -86,6 +87,7 @@ function createRulesHarness(options: { moveDistance?: number; selectedAmmo?: AmmoEquipment | null; gunnery?: number; + armorType?: ArmorType; } = {}): VehicleRules { const baseUnit = createEmptyUnit({ type: options.type ?? 'Tank', @@ -147,6 +149,7 @@ function createRulesHarness(options: { .flatMap(entry => entry.equipment ? [[entry.equipment.internalName, entry.equipment]] : []))), getInventoryControlSelectedAmmo: () => options.selectedAmmo ?? null, getEffectiveWeaponTypes: (entry: MountedWeapon) => new Set(entry.getWeaponTypes(options.selectedAmmo ?? null)), + hasArmorType: (type: ArmorType) => options.armorType === type, getUnit: () => baseUnit, getCondition: (state: string) => { if (state === 'shutdown') return options.shutdown ?? false; @@ -196,6 +199,11 @@ describe('VehicleRules', () => { expect(rules.getAttackMovementModifier('jump')).toBe(3); }); + it('applies the Hardened Armor control-roll modifier through the typed armor query', () => { + expect(createRulesHarness({ armorType: 'HARDENED' }).PSRModifiers()) + .toEqual(jasmine.objectContaining({ modifier: 1 })); + }); + it('applies a mounted targeting computer to eligible direct-fire weapons', () => { const directFire = new MountedWeapon({ owner: undefined as unknown as CBTForceUnit, diff --git a/src/app/models/rules/vehicle-rules.ts b/src/app/models/rules/vehicle-rules.ts index 3d18bc6aa..ebf03f7f1 100644 --- a/src/app/models/rules/vehicle-rules.ts +++ b/src/app/models/rules/vehicle-rules.ts @@ -131,7 +131,7 @@ export class VehicleRules extends UnitTypeRulesBase { protected override buildRuleModifiers(): UnitRuleModifier[] { const status = this.systemsStatus(); const modifiers: UnitRuleModifier[] = []; - if (this.unit.getUnit().armorType === 'Hardened') { + if (this.unit.hasArmorType('HARDENED')) { modifiers.push({ label: 'Mounts Hardened Armor', values: { psr: 1 } }); } if (status.commanderHit) { diff --git a/src/app/models/turn-state.model.spec.ts b/src/app/models/turn-state.model.spec.ts index 6dc2b53ed..4cb8abb0f 100644 --- a/src/app/models/turn-state.model.spec.ts +++ b/src/app/models/turn-state.model.spec.ts @@ -154,6 +154,8 @@ function createTurnStateHarness(options: TurnStateHarnessOptions = {}): TurnStat setCondition, queueFall: jasmine.createSpy('queueFall'), getUnit: () => ({ type: 'Mek', comp: [], ...options.unit } as UnitSummary), + hasArmorType: () => false, + getArmorTypeAt: () => 'STANDARD', getAvailableMotiveModes: () => [ { mode: 'stationary' as const, label: 'Stationary' }, { mode: 'walk' as const, label: 'Walk' }, @@ -904,7 +906,6 @@ describe('TurnState', () => { expect(turnState.setPendingCriticalRoll('chance:floating', [2, 3])).toBeFalse(); expect(turnState.setPendingFloatingCriticalLocation( 'chance:floating', - 10, [4, 6], )).toBeTrue(); @@ -920,8 +921,7 @@ describe('TurnState', () => { chanceOrigin: { throughArmorHitArc: 'right' }, floatingLocation: { hitArc: 'right', - locationRoll: 10, - dice: [4, 6], + hitLocationDice: [4, 6], }, }); expect(restored.resolvePendingCriticalHit('chance:floating')).toBeFalse(); @@ -1166,6 +1166,27 @@ describe('TurnState', () => { .toBe(group); }); + it('restores the active combat pilot-damage group with its pending workflow', () => { + const { turnState } = createTurnStateHarness(); + turnState.moveMode.set('stationary'); + const group = turnState.currentPilotDamageGroup(); + expect(turnState.queuePendingUnitCheck({ + id: 'consciousness:reload', + kind: 'consciousness', + crewId: 0, + pilotDamageGroup: group, + target: 5, + })).toBeTrue(); + + const serialized = turnState.serialize(); + const { turnState: restored } = createTurnStateHarness(); + restored.update(serialized); + + expect(serialized?.pilotDamageGroup).toBe(group); + expect(restored.currentPilotDamageGroup()).toBe(group); + expect(restored.getPendingUnitCheck('consciousness:reload')?.pilotDamageGroup).toBe(group); + }); + it('uses immediately actionable consciousness checks without a tracked phase boundary', () => { const { turnState } = createTurnStateHarness({ phaseTracking: false }); turnState.moveMode.set('stationary'); diff --git a/src/app/models/turn-state.model.ts b/src/app/models/turn-state.model.ts index 565d704fd..25c25a95b 100644 --- a/src/app/models/turn-state.model.ts +++ b/src/app/models/turn-state.model.ts @@ -557,12 +557,18 @@ export class TurnState { if (this.spotting()) turnState.spotting = true; if (this.equipmentStateChanged()) turnState.equipmentStateChanged = true; + if (this.pendingEvents().some(event => + 'pilotDamageGroup' in event && event.pilotDamageGroup === this.pilotDamageGroup)) { + turnState.pilotDamageGroup = this.pilotDamageGroup; + } + return Object.keys(turnState).length > 0 ? turnState : undefined; } update(data: SerializedTurnState | undefined) { this.withSuppressedModified(() => { this.turnCounter = data?.turnCounter ?? this.turnCounter; + this.pilotDamageGroup = data?.pilotDamageGroup ?? createPilotDamageGroup('combat'); this.endTurnCheckpoint.set(data?.endTurnCheckpoint); this.airborne.set(data?.airborne ?? null); this.moveMode.set(data?.moveMode ?? null); @@ -999,24 +1005,19 @@ export class TurnState { setPendingFloatingCriticalLocation( id: string, - locationRoll: number | null, - dice: readonly number[] | null = null, + dice: readonly number[] | null, tripodLegRoll: number | null = null, ): boolean { const pending = this.getPendingCriticalHit(id); const floating = pending?.floatingLocation; if (!pending || !floating) return false; - if (locationRoll !== null - && (!Number.isInteger(locationRoll) || locationRoll < 2 || locationRoll > 12)) return false; if (dice !== null && (dice.length !== 2 - || dice.some(die => !Number.isInteger(die) || die < 1 || die > 6) - || locationRoll !== dice[0] + dice[1])) return false; + || dice.some(die => !Number.isInteger(die) || die < 1 || die > 6))) return false; if (tripodLegRoll !== null && (!Number.isInteger(tripodLegRoll) || tripodLegRoll < 1 || tripodLegRoll > 6)) return false; const next: SerializedPendingMekFloatingCriticalLocation = { hitArc: floating.hitArc, - ...(locationRoll !== null ? { locationRoll } : {}), - ...(dice !== null ? { dice: [dice[0], dice[1]] as const } : {}), + ...(dice !== null ? { hitLocationDice: [dice[0], dice[1]] as const } : {}), ...(tripodLegRoll !== null ? { tripodLegRoll } : {}), }; this.pendingEvents.update(current => current.map(event => @@ -1142,6 +1143,24 @@ export class TurnState { return this.queuePendingEvent({ type: 'mek-fall', ...pending }); } + setPendingFallRolls( + id: string, + rolls: Pick, + ): boolean { + const pending = this.getPendingFall(id); + if (!pending) return false; + this.pendingEvents.update(current => current.map(event => { + if (event.id !== id || event.type !== 'mek-fall') return event; + const { + orientationRoll: _orientationRoll, + damageRolls: _damageRolls, + ...facts + } = event; + return { ...facts, ...rolls }; + })); + return true; + } + discardPendingFall(id: string): boolean { return this.discardPendingEvent(id, 'mek-fall'); } diff --git a/src/app/models/unit-check.model.ts b/src/app/models/unit-check.model.ts index a41882297..89a8e8827 100644 --- a/src/app/models/unit-check.model.ts +++ b/src/app/models/unit-check.model.ts @@ -59,6 +59,7 @@ interface UnitCheckDefinition { readonly usesPilotAutomation: boolean | ((context: UnitCheckContext) => boolean); readonly description: (context: UnitCheckContext) => string; readonly reviewDescription?: (context: UnitCheckContext) => string; + /** Presentation only; check behavior is selected by the typed registry key. */ readonly failureOutcome: (context: UnitCheckContext) => string; readonly successLabel?: string; readonly failedLabel?: string; diff --git a/src/app/services/cbt-end-turn.service.spec.ts b/src/app/services/cbt-end-turn.service.spec.ts index e0bbf481e..72f6c9b99 100644 --- a/src/app/services/cbt-end-turn.service.spec.ts +++ b/src/app/services/cbt-end-turn.service.spec.ts @@ -41,7 +41,8 @@ describe('CBTEndTurnService', () => { let activePilotCrewId = effects.activePilotCrewId === undefined ? 0 : effects.activePilotCrewId; let pendingFalls = 0; let checkpoint: 'phase-ended' | 'heat-staged' | undefined; - const endTurn = jasmine.createSpy('endTurn'); + let turnCounter = 0; + const endTurn = jasmine.createSpy('endTurn').and.callFake(() => { turnCounter++; }); const resolveEndTurnHeat = jasmine.createSpy('resolveEndTurnHeat'); const endPhase = jasmine.createSpy('endPhase').and.callFake(() => { if (!automaticFall) return; @@ -64,6 +65,7 @@ describe('CBTEndTurnService', () => { heatSources: () => heatSources, heatDissipationBalance: () => consumedDissipation, getEndTurnCheckpoint: () => checkpoint, + getTurnCounter: () => turnCounter, markEndTurnPhaseEnded: () => { checkpoint ??= 'phase-ended'; }, markEndTurnHeatStaged: () => { checkpoint = 'heat-staged'; }, advanceDeferredUnitChecks, @@ -162,6 +164,7 @@ describe('CBTEndTurnService', () => { const completion = service.endTurn([first.unit, second.unit]); const duplicateCompletion = service.endTurn([first.unit, second.unit]); await Promise.resolve(); + await Promise.resolve(); expect(first.endTurn).not.toHaveBeenCalled(); expect(second.endTurn).not.toHaveBeenCalled(); @@ -182,6 +185,54 @@ describe('CBTEndTurnService', () => { ); }); + it('queues concurrent requests for different unit snapshots without retargeting them', async () => { + const first = createUnit('first', 4, 8); + const second = createUnit('second', 2, 6); + let finishFirstPhase!: (result: boolean) => void; + resolvePhase.and.callFake((units: readonly CBTForceUnit[]) => units[0] === first.unit + ? new Promise(resolve => finishFirstPhase = resolve) + : Promise.resolve(true)); + resolveAutomation.and.callFake((_key: string, events: readonly AutomationReviewEvent[]) => + Promise.resolve(new Set(events.map(event => event.id)))); + + const firstCompletion = service.endTurn([first.unit]); + const secondCompletion = service.endTurn([second.unit]); + await Promise.resolve(); + + expect(resolvePhase).toHaveBeenCalledOnceWith([first.unit]); + expect(first.endTurn).not.toHaveBeenCalled(); + expect(second.endTurn).not.toHaveBeenCalled(); + + finishFirstPhase(true); + expect(await firstCompletion).toBeTrue(); + expect(await secondCompletion).toBeTrue(); + expect(resolvePhase.calls.allArgs()).toEqual([[[first.unit]], [[second.unit]]]); + expect(first.endTurn).toHaveBeenCalledTimes(1); + expect(second.endTurn).toHaveBeenCalledTimes(1); + }); + + it('skips units already committed by an overlapping queued request', async () => { + const first = createUnit('first', 4, 8); + const second = createUnit('second', 2, 6); + let finishFirstPhase!: (result: boolean) => void; + resolvePhase.and.callFake((units: readonly CBTForceUnit[]) => units[0] === first.unit + ? new Promise(resolve => finishFirstPhase = resolve) + : Promise.resolve(true)); + resolveAutomation.and.callFake((_key: string, events: readonly AutomationReviewEvent[]) => + Promise.resolve(new Set(events.map(event => event.id)))); + + const firstCompletion = service.endTurn([first.unit]); + const overlapCompletion = service.endTurn([first.unit, second.unit]); + await Promise.resolve(); + finishFirstPhase(true); + + expect(await firstCompletion).toBeTrue(); + expect(await overlapCompletion).toBeTrue(); + expect(resolvePhase.calls.allArgs()).toEqual([[[first.unit]], [[second.unit]]]); + expect(first.endTurn).toHaveBeenCalledTimes(1); + expect(second.endTurn).toHaveBeenCalledTimes(1); + }); + it('leaves the ended phase resumable when the heat review is cancelled', async () => { const first = createUnit('first', 4, 8); const second = createUnit('second', 2, 6); diff --git a/src/app/services/cbt-end-turn.service.ts b/src/app/services/cbt-end-turn.service.ts index dfb1f4a08..0d5d66faf 100644 --- a/src/app/services/cbt-end-turn.service.ts +++ b/src/app/services/cbt-end-turn.service.ts @@ -61,19 +61,36 @@ export class CBTEndTurnService { private readonly automationToasts = inject(CBTAutomationToastService); private readonly phaseResolution = inject(CBTPhaseResolutionService); private readonly options = inject(OptionsService); - private pendingEndTurn: Promise | null = null; + private endTurnQueue: Promise = Promise.resolve(); + private readonly pendingEndTurns = new Map>(); /** Commits a turn only after its resumable phase, heat, and consequence sequence is complete. */ - async endTurn(units: readonly CBTForceUnit[]): Promise { - if (this.pendingEndTurn) return this.pendingEndTurn; - - const operation = this.performEndTurn(units); - this.pendingEndTurn = operation; - try { - return await operation; - } finally { - if (this.pendingEndTurn === operation) this.pendingEndTurn = null; - } + endTurn(units: readonly CBTForceUnit[]): Promise { + const snapshot = Array.from(new Map(units.map(unit => [unit.id, unit])).values()) + .map(unit => ({ unit, turn: unit.turnState().getTurnCounter() })); + if (snapshot.length === 0) return Promise.resolve(false); + + const key = JSON.stringify(snapshot + .map(({ unit, turn }) => [unit.id, turn] as const) + .sort(([left], [right]) => left.localeCompare(right))); + const pending = this.pendingEndTurns.get(key); + if (pending) return pending; + + const queuedAfter = this.endTurnQueue; + let operation!: Promise; + operation = queuedAfter + .then(() => { + const currentUnits = snapshot + .filter(({ unit, turn }) => unit.turnState().getTurnCounter() === turn) + .map(({ unit }) => unit); + return currentUnits.length > 0 ? this.performEndTurn(currentUnits) : true; + }) + .finally(() => { + if (this.pendingEndTurns.get(key) === operation) this.pendingEndTurns.delete(key); + }); + this.pendingEndTurns.set(key, operation); + this.endTurnQueue = operation.then(() => undefined, () => undefined); + return operation; } private async performEndTurn(units: readonly CBTForceUnit[]): Promise { diff --git a/src/app/services/falling-resolution.service.spec.ts b/src/app/services/falling-resolution.service.spec.ts index 76b249380..0cd6c3a6e 100644 --- a/src/app/services/falling-resolution.service.spec.ts +++ b/src/app/services/falling-resolution.service.spec.ts @@ -66,7 +66,7 @@ describe('FallingResolutionService', () => { }, }, ); - expect(harness.addArmorHits).toHaveBeenCalledWith('HD', 5, false, false); + expect(harness.addArmorHits).toHaveBeenCalledWith('HD', 5, false, false, 5); expect(harness.applyHeadHitCrewHits).toHaveBeenCalledTimes(1); expect(harness.completePendingFall).toHaveBeenCalledOnceWith('fall:1'); expect(showToast).toHaveBeenCalledWith( @@ -136,7 +136,7 @@ describe('FallingResolutionService', () => { closed.complete(); await operation; - expect(harness.addArmorHits).toHaveBeenCalledOnceWith('HD', 5, false, false); + expect(harness.addArmorHits).toHaveBeenCalledOnceWith('HD', 5, false, false, 5); expect(resolveAutomation.calls.allArgs().map(args => args[0])).toEqual([ 'pilotHitsAndConsciousnessCheck', ]); @@ -184,7 +184,7 @@ describe('FallingResolutionService', () => { closed.complete(); await operation; - expect(harness.addArmorHits).toHaveBeenCalledOnceWith('HD', 5, false, false); + expect(harness.addArmorHits).toHaveBeenCalledOnceWith('HD', 5, false, false, 5); expect(harness.applyHeadHitCrewHits).not.toHaveBeenCalled(); expect(harness.completePendingFall).toHaveBeenCalledOnceWith('fall:1'); }); @@ -300,14 +300,12 @@ function createUnit( source: 'psr' | 'stand-attempt'; levelsFallen: number; orientationRoll: number | null; - orientationDice: readonly [number] | null; damageRolls: CBTMekFallDamageRoll[]; }> = [{ id: 'fall:1', source: 'psr' as const, levelsFallen: 0, orientationRoll: null, - orientationDice: null, damageRolls: [], }]; const completePendingFall = jasmine.createSpy('completePendingFall').and.callFake((id: string) => { @@ -345,12 +343,10 @@ function createUnit( id: string, orientationRoll: number, damageRolls: readonly CBTMekFallDamageRoll[], - orientationDice: readonly [number] | null, ) => { const pending = pendingFalls.find(candidate => candidate.id === id); if (!pending) return false; pending.orientationRoll = orientationRoll; - pending.orientationDice = orientationDice; pending.damageRolls = [...damageRolls]; return true; }, @@ -358,10 +354,14 @@ function createUnit( skipPendingFall, getArmorPoints: (location: string) => location === 'HD' ? 9 : 10, getArmorHits: (location: string) => armorHits.get(location) ?? 0, + getArmorTypeAt: () => 'STANDARD', + hasArmorType: () => false, addArmorHits, getInternalPoints: () => 10, getInternalHits: () => 0, + getStructureKindAt: () => 'standard', addInternalHits: jasmine.createSpy('addInternalHits'), + queueMekCriticalChance: jasmine.createSpy('queueMekCriticalChance'), applyHeadHitCrewHits, } as unknown as CBTForceUnit; return { unit, addArmorHits, applyHeadHitCrewHits, completePendingFall, skipPendingFall }; diff --git a/src/app/services/falling-resolution.service.ts b/src/app/services/falling-resolution.service.ts index 30f6d690a..687beeffb 100644 --- a/src/app/services/falling-resolution.service.ts +++ b/src/app/services/falling-resolution.service.ts @@ -25,6 +25,7 @@ import { mekFallDamageGroups, resolveMekFallHitLocation, resolveMekFallOrientation, + twoD6Total, type ResolvedMekFallDamageGroup, } from '../utils/mek-falling.util'; import { clusterTableForUnit } from '../utils/record-sheet-reference-table'; @@ -138,13 +139,7 @@ export class FallingResolutionService { trigger: FallingAutomationTrigger, ): AcceptedFallingDamageDialogResult { const pending = unit.getPendingFall(trigger.id); - const generatedOrientation = !pending - || pending.orientationRoll === null - || pending.orientationRoll < 1 - || pending.orientationRoll > 6; - const orientationRoll = generatedOrientation - ? this.rollD6() - : pending.orientationRoll; + const orientationRoll = pending?.orientationRoll ?? this.rollD6(); const orientation = resolveMekFallOrientation(unit.gameRules.id, orientationRoll); const damageGroups = mekFallDamageGroups(mekFallDamage( unit.getUnit().tons, @@ -156,16 +151,9 @@ export class FallingResolutionService { damageGroups.forEach((damage, index) => { const saved = pending?.damageRolls[index]; - const generatedHitLocation = !saved - || saved.hitLocationRoll === null - || saved.hitLocationRoll < 2 - || saved.hitLocationRoll > 12; - const hitLocationDice = generatedHitLocation - ? [this.rollD6(), this.rollD6()] as const - : saved.hitLocationDice ?? null; - const hitLocationRoll = generatedHitLocation - ? hitLocationDice![0] + hitLocationDice![1] - : saved.hitLocationRoll; + const hitLocationDice = saved?.hitLocationDice + ?? [this.rollD6(), this.rollD6()] as const; + const hitLocationRoll = twoD6Total(hitLocationDice); const preliminary = resolveMekFallHitLocation( hitLocationTable, orientation.hitArc, @@ -173,14 +161,9 @@ export class FallingResolutionService { ); const needsTripodLeg = preliminary.location === null && preliminary.tripodLegModifier !== undefined; - const generatedTripodLeg = needsTripodLeg - && (!saved || saved.tripodLegRoll === null - || saved.tripodLegRoll < 1 || saved.tripodLegRoll > 6); const tripodLegRoll = !needsTripodLeg ? null - : generatedTripodLeg - ? this.rollD6() - : saved!.tripodLegRoll; + : saved?.tripodLegRoll ?? this.rollD6(); const result = resolveMekFallHitLocation( hitLocationTable, orientation.hitArc, @@ -192,10 +175,8 @@ export class FallingResolutionService { } damageRolls.push({ - hitLocationRoll, hitLocationDice, tripodLegRoll, - tripodLegDice: generatedTripodLeg ? [tripodLegRoll!] : saved?.tripodLegDice ?? null, }); groups.push({ ...result, damage }); }); @@ -204,7 +185,6 @@ export class FallingResolutionService { trigger.id, orientationRoll, damageRolls, - generatedOrientation ? [orientationRoll] : pending?.orientationDice ?? null, ); return { action: 'accept', orientation, groups }; } @@ -230,9 +210,7 @@ export class FallingResolutionService { for (const location of applied.locations) { damageByLocation.set( location.location, - (damageByLocation.get(location.location) ?? 0) - + location.armorDamage - + location.internalDamage, + (damageByLocation.get(location.location) ?? 0) + location.appliedDamage, ); } const locations = Array.from(damageByLocation, ([location, damage]) => diff --git a/src/app/services/mek-critical-hit-automation.service.spec.ts b/src/app/services/mek-critical-hit-automation.service.spec.ts index d99b62576..56dde815e 100644 --- a/src/app/services/mek-critical-hit-automation.service.spec.ts +++ b/src/app/services/mek-critical-hit-automation.service.spec.ts @@ -134,6 +134,8 @@ function explodingAmmoUnit(automationMode: 'yes' | 'ask' = 'ask'): { getEquipmentRegistry: () => EMPTY_EQUIPMENT_REGISTRY, getInventoryControlRules: () => ({}), getUnit: () => ({ structureType: '', armorType: 'Standard', features: [], comp: [] }), + getArmorTypeAt: () => 'STANDARD', + getStructureKindAt: () => 'standard', getCrewMember: () => ({ getHits: () => pilotHits, setHits: (hits: number) => { pilotHits = hits; }, diff --git a/src/app/services/mek-critical-resolution.service.spec.ts b/src/app/services/mek-critical-resolution.service.spec.ts index f9f6f5ef0..fdf3b4985 100644 --- a/src/app/services/mek-critical-resolution.service.spec.ts +++ b/src/app/services/mek-critical-resolution.service.spec.ts @@ -196,15 +196,13 @@ describe('MekCriticalResolutionService', () => { }, setPendingFloatingCriticalLocation: ( id: string, - locationRoll: number | null, dice: readonly [number, number] | null, tripodLegRoll: number | null, ) => updateHit(id, pending => ({ ...pending, floatingLocation: { hitArc: pending.floatingLocation!.hitArc, - ...(locationRoll !== null ? { locationRoll } : {}), - ...(dice !== null ? { dice } : {}), + ...(dice !== null ? { hitLocationDice: dice } : {}), ...(tripodLegRoll !== null ? { tripodLegRoll } : {}), }, })), @@ -232,6 +230,8 @@ describe('MekCriticalResolutionService', () => { turnState: () => turnState, getNotificationDisplayName: () => 'Atlas AS7-D', getUnit: () => ({ structureType: '', armorType: '', features: [], comp: [] }), + getArmorTypeAt: () => 'STANDARD', + getStructureKindAt: () => 'standard', getCritSlots: () => [], getCritSlot: () => null, usesFloatingCriticals: () => false, @@ -603,12 +603,7 @@ describe('MekCriticalResolutionService', () => { }); it('persists an exact Hardened Armor facing decision and restores its modifier', async () => { - (unit as unknown as { getUnit: () => object }).getUnit = () => ({ - structureType: '', - armorType: 'Hardened', - features: [], - comp: [], - }); + (unit as unknown as { getArmorTypeAt: () => string }).getArmorTypeAt = () => 'HARDENED'; const operation = service.queueChance(unit, { id: 'chance:hardened', location: 'CT', @@ -717,11 +712,10 @@ describe('MekCriticalResolutionService', () => { })); const floatingData = createDialog.calls.argsFor(1)[1].data; - floatingData.onDraftChange(7, [3, 4], null); + floatingData.onDraftChange([3, 4], null); expect(pendingHits[0].floatingLocation).toEqual({ hitArc: 'front', - locationRoll: 7, - dice: [3, 4], + hitLocationDice: [3, 4], }); closeDialog(1, { action: 'apply', location: 'CT' }); @@ -746,8 +740,7 @@ describe('MekCriticalResolutionService', () => { remainingHits: 1, floatingLocation: { hitArc: 'rear', - locationRoll: 8, - dice: [2, 6], + hitLocationDice: [2, 6], }, }); @@ -755,15 +748,14 @@ describe('MekCriticalResolutionService', () => { expect(createDialog.calls.argsFor(0)[0]).toBe(MekFloatingCriticalDialogComponent); expect(createDialog.calls.argsFor(0)[1].data).toEqual(jasmine.objectContaining({ hitArc: 'rear', - initialLocationRoll: 8, - initialRoll: [2, 6], + initialDice: [2, 6], })); closeDialog(0, undefined); await first; const reopened = service.resume(unit); expect(createDialog.calls.argsFor(1)[0]).toBe(MekFloatingCriticalDialogComponent); - expect(createDialog.calls.argsFor(1)[1].data.initialRoll).toEqual([2, 6]); + expect(createDialog.calls.argsFor(1)[1].data.initialDice).toEqual([2, 6]); closeDialog(1, undefined); await reopened; }); diff --git a/src/app/services/mek-critical-resolution.service.ts b/src/app/services/mek-critical-resolution.service.ts index 7163c1545..53fe712f6 100644 --- a/src/app/services/mek-critical-resolution.service.ts +++ b/src/app/services/mek-critical-resolution.service.ts @@ -38,7 +38,7 @@ import { type MekCriticalChanceResult, type MekCriticalHitOptions, } from '../utils/mek-critical-hit.util'; -import { resolveMekFallHitLocation } from '../utils/mek-falling.util'; +import { resolveMekFallHitLocation, twoD6Total } from '../utils/mek-falling.util'; import { clusterTableForUnit } from '../utils/record-sheet-reference-table'; import { isConsciousnessCheck } from '../utils/unit-check.util'; import { uuidv7 } from '../utils/uuid.util'; @@ -334,13 +334,11 @@ export class MekCriticalResolutionService { data: { unit, hitArc: floating.hitArc, - initialLocationRoll: floating.locationRoll, - initialRoll: floating.dice, + initialDice: floating.hitLocationDice, initialTripodLegRoll: floating.tripodLegRoll, - onDraftChange: (locationRoll, dice, tripodLegRoll) => + onDraftChange: (dice, tripodLegRoll) => turnState.setPendingFloatingCriticalLocation( pending.id, - locationRoll, dice, tripodLegRoll, ), @@ -520,11 +518,8 @@ export class MekCriticalResolutionService { ): boolean { const floating = pending.floatingLocation; if (!floating) return false; - const generatedDice = floating.locationRoll === undefined - ? [this.rollD6(), this.rollD6()] as const - : null; - const locationRoll = floating.locationRoll - ?? generatedDice![0] + generatedDice![1]; + const dice = floating.hitLocationDice ?? [this.rollD6(), this.rollD6()] as const; + const locationRoll = twoD6Total(dice); const table = clusterTableForUnit(unit.getUnit()).hitLocationTable ?? 'biped'; const preliminary = resolveMekFallHitLocation(table, floating.hitArc, locationRoll); const needsTripodLeg = preliminary.location === null @@ -542,8 +537,7 @@ export class MekCriticalResolutionService { unit.turnState().setPendingFloatingCriticalLocation( pending.id, - locationRoll, - generatedDice ?? floating.dice ?? null, + dice, tripodLegRoll, ); const targetLocation = mekCriticalRollLocation(unit, result.location); diff --git a/src/app/services/unit-svg-mek.service.ts b/src/app/services/unit-svg-mek.service.ts index c992fe63b..5b80b7128 100644 --- a/src/app/services/unit-svg-mek.service.ts +++ b/src/app/services/unit-svg-mek.service.ts @@ -279,6 +279,7 @@ export class UnitSvgMekService extends UnitSvgService { } this.renderInventoryEntryState(entry); + this.renderKickArcHitModifier(entry); }); this.renderInventoryControlSelection(); } @@ -368,6 +369,19 @@ export class UnitSvgMekService extends UnitSvgService { this.renderRulesAdjustedDamage(entry, damageEl, resolved.weakened, originalText); } + private renderKickArcHitModifier(entry: MountedEquipment): void { + const display = this.mekRules.resolveKickArcHitDisplay(entry); + if (!display || !entry.el) return; + const hitModRect = entry.el.querySelector(':scope > .hitMod-rect'); + const hitModText = entry.el.querySelector(':scope > .hitMod-text'); + if (!hitModRect || !hitModText) return; + + hitModRect.setAttribute('display', 'block'); + hitModText.setAttribute('display', 'block'); + hitModText.textContent = display.text; + entry.el.classList.toggle('weakenedHitMod', display.weakened); + } + /** Render rules-specific shield damage without applying physical-weapon or TSM modifiers. */ private renderShieldDamage(entry: MountedEquipment) { const damageEl = entry.el!.querySelector(`:scope > .damage > text`); diff --git a/src/app/utils/mek-critical-hit.util.spec.ts b/src/app/utils/mek-critical-hit.util.spec.ts index 46e47423f..8542e354e 100644 --- a/src/app/utils/mek-critical-hit.util.spec.ts +++ b/src/app/utils/mek-critical-hit.util.spec.ts @@ -11,6 +11,7 @@ import type { CriticalSlot } from '../models/force-serialization'; import { MountedEquipment, MountedWeapon } from '../models/mounted-equipment.model'; import { CORE_2026_GAME_RULES, TW_GAME_RULES, type CBTGameRules } from '../models/rules/game-rules'; import type { WeaponType } from '../models/weapon-types.model'; +import type { ArmorType } from '../models/entity/types'; import { BOMBAST_LASER_CHARGED_STATE, BOMBAST_LASER_CHARGE_STATE_KEY, @@ -29,6 +30,7 @@ import { isGaussPoweredDown, } from './gauss-power-state.util'; import type { InventoryControlRules } from './inventory-control.util'; +import type { MekStructureKind } from './mek-structure-damage.util'; import { createHandlerQueryContext, EquipmentInteractionRegistry, @@ -375,7 +377,7 @@ describe('Mek critical-hit workflow', () => { }); it('marks two composite structure pips per point of explosion damage after the Core cap', () => { - const { unit, internalHits, armorHits } = explodingAmmoUnit(CORE_2026_GAME_RULES, 'Composite'); + const { unit, internalHits, armorHits } = explodingAmmoUnit(CORE_2026_GAME_RULES, 'composite'); const outcome = applyMekCriticalRoll(unit, 'LT', [1, 1], true); @@ -402,7 +404,7 @@ describe('Mek critical-hit workflow', () => { TW_GAME_RULES, ['F_GAUSS'], 'Gauss Rifle', - 'Composite', + 'composite', ); const outcome = applyMekCriticalRoll(unit, 'LT', [1, 1], true); @@ -412,6 +414,23 @@ describe('Mek critical-hit workflow', () => { expect(internalHits.get('CT')).toBe(18); }); + it('drops the odd composite remainder instead of transferring fractional explosion damage', () => { + const fixture = explodingWeaponUnit(TW_GAME_RULES, ['F_GAUSS'], 'Gauss Rifle', 'composite'); + fixture.internalHits.set('LT', 1); + + const outcome = applyMekCriticalRoll(fixture.unit, 'LT', [1, 1], true); + + expect(outcome?.explosion?.locations.map(location => ({ + location: location.location, + internalDamage: location.internalDamage, + }))).toEqual([ + { location: 'LT', internalDamage: 11 }, + { location: 'CT', internalDamage: 18 }, + ]); + expect(fixture.internalHits.get('LT')).toBe(12); + expect(fixture.internalHits.get('CT')).toBe(18); + }); + it('lets a legal armored component absorb one hit and take the next critical', () => { const equipment = new MiscEquipment({ id: 'ArmoredComponent', @@ -515,10 +534,10 @@ describe('Mek critical-hit workflow', () => { CORE_2026_GAME_RULES, [], [], - 'Reinforced', + 'reinforced', undefined, {}, - { armorType: 'Hardened' }, + { armorType: 'HARDENED' }, ); expect(mekCriticalChanceModifiers(core.unit, 'LT')).toEqual([ { label: 'Reinforced structure', value: -1 }, @@ -534,10 +553,10 @@ describe('Mek critical-hit workflow', () => { TW_GAME_RULES, [], [], - 'Reinforced', + 'reinforced', undefined, {}, - { armorType: 'Hardened', subtype: 'Industrial Mek' }, + { armorType: 'HARDENED', subtype: 'Industrial Mek' }, ); expect(mekCriticalChanceModifiers(tw.unit, 'LT')).toEqual([ { label: 'Reinforced structure', value: -1 }, @@ -847,7 +866,7 @@ describe('Mek critical-hit workflow', () => { }); }); -function explodingAmmoUnit(gameRules: CBTGameRules, structureType: string | null = null) { +function explodingAmmoUnit(gameRules: CBTGameRules, structureKind: MekStructureKind | null = null) { const ammo = new AmmoEquipment({ id: 'TestAC10Ammo', name: 'AC/10 Ammo', @@ -866,7 +885,7 @@ function explodingAmmoUnit(gameRules: CBTGameRules, structureType: string | null eq: ammo, }; const slots = [slot]; - return { ...criticalUnit(gameRules, slots, [], structureType), slot, slots }; + return { ...criticalUnit(gameRules, slots, [], structureKind), slot, slots }; } function riscPulseModuleUnit() { @@ -923,7 +942,7 @@ function explodingWeaponUnit( gameRules: CBTGameRules, flags: EquipmentFlag[] = ['F_GAUSS'], name = 'Gauss Rifle', - structureType: string | null = null, + structureKind: MekStructureKind | null = null, ): { readonly unit: CBTForceUnit; readonly entry: MountedEquipment; @@ -953,7 +972,7 @@ function explodingWeaponUnit( critSlots: slots, states: new Map(), }); - const harness = criticalUnit(gameRules, slots, [entry], structureType); + const harness = criticalUnit(gameRules, slots, [entry], structureKind); entry.owner = harness.unit; return { ...harness, entry }; } @@ -1174,11 +1193,11 @@ function criticalUnit( gameRules: CBTGameRules, slots: CriticalSlot[], inventory: MountedEquipment[] = [], - structureType: string | null = null, + structureKind: MekStructureKind | null = null, effectiveWeaponTypes?: (entry: MountedWeapon) => ReadonlySet, inventoryControlRules: InventoryControlRules = {}, unitData: { - readonly armorType?: string; + readonly armorType?: ArmorType; readonly features?: readonly string[]; readonly subtype?: 'BattleMek' | 'Industrial Mek' | 'Quad Industrial Mek'; } = {}, @@ -1238,11 +1257,13 @@ function criticalUnit( slots.find(candidate => candidate.loc === snapshot.loc && candidate.slot === snapshot.slot) ?? null, getUnit: () => ({ comp: [], - structureType, - armorType: unitData.armorType ?? 'Standard', + structureType: structureKind, + armorType: unitData.armorType ?? 'STANDARD', features: unitData.features ?? [], subtype: unitData.subtype ?? 'BattleMek', }), + getStructureKindAt: () => structureKind ?? 'standard', + getArmorTypeAt: () => unitData.armorType ?? 'STANDARD', getCrewMember: () => ({ getHits: () => crewHits, setHits: (hits: number) => { crewHits = hits; }, diff --git a/src/app/utils/mek-critical-hit.util.ts b/src/app/utils/mek-critical-hit.util.ts index ea8a66e08..714a302da 100644 --- a/src/app/utils/mek-critical-hit.util.ts +++ b/src/app/utils/mek-critical-hit.util.ts @@ -12,6 +12,7 @@ import { getTopologyFor, LEG_LOCATIONS, MEK_TORSO_LOCATIONS } from '../models/en import type { CriticalDelayedExplosion } from '../services/equipment-interaction-registry.service'; import { resolveInventoryControlWeaponDamage } from './inventory-control-damage.util'; import { getInventoryControlModeAmmoSummary } from './inventory-control.util'; +import { mekStructureDamageCapacity, resolveMekStructureDamage } from './mek-structure-damage.util'; export type MekCriticalChanceResult = | { readonly kind: 'none' } @@ -252,8 +253,7 @@ export function mekCriticalChanceModifiers( ): MekCriticalChanceModifier[] { const modifiers: MekCriticalChanceModifier[] = []; const unitData = unit.getUnit(); - const structureType = unitData.structureType?.trim().toLowerCase() ?? ''; - if (structureType.includes('reinforced')) { + if (unit.getStructureKindAt(location) === 'reinforced') { modifiers.push({ label: 'Reinforced structure', value: -1 }); } if (usesIndustrialMekCriticalChanceTable(unit)) { @@ -266,7 +266,7 @@ export function mekCriticalChanceModifiers( modifiers.push({ label: 'CASE II internal explosion', value: -1 }); } if (context.hardenedArmorApplies !== false - && unitData.armorType.trim().toLowerCase().includes('hardened')) { + && unit.getArmorTypeAt(location) === 'HARDENED') { const facingUnknown = context.hardenedArmorApplies === undefined; modifiers.push(facingUnknown ? { @@ -961,8 +961,6 @@ function resolveMekExplosionLocationDamage( plan: MekImmediateCriticalExplosion, ): MekExplosionLocationDamage[] { const topology = getTopologyFor(unit.locations?.internal.keys() ?? []); - // Explosion rules resolve damage points; composite structure marks two pips per point. - const internalDamageMultiplier = isCompositeStructure(unit) ? 2 : 1; const locations: MekExplosionLocationDamage[] = []; const visited = new Set(); let location: string | null = sourceLocation; @@ -976,28 +974,34 @@ function resolveMekExplosionLocationDamage( armorBlowoutPending = true; } const remainingInternal = Math.max(0, unit.getInternalPoints(location) - unit.getInternalHits(location)); - const remainingInternalCapacity = remainingInternal / internalDamageMultiplier; + const structureKind = unit.getStructureKindAt(location); const torso = MEK_TORSO_LOCATIONS.has(location); const remainingArmor = Math.max(0, unit.getArmorPoints(location, torso) - unit.getArmorHits(location, torso)); const resolution = unit.gameRules.resolveMekExplosionDamage({ damage, protection, - remainingInternal: remainingInternalCapacity, + remainingInternal: mekStructureDamageCapacity(remainingInternal, structureKind), remainingArmor, originalArmor: unit.getArmorPoints(location, torso), torso, armorBlowoutPending, }); const armorDamage = Math.min(remainingArmor, resolution.armorDamage); - const internalDamage = Math.min( + const structureDamage = resolveMekStructureDamage( + resolution.internalDamage, remainingInternal, - resolution.internalDamage * internalDamageMultiplier, + structureKind, ); - locations.push({ location, internalDamage, armorDamage, armorRear: resolution.armorRear, protection }); + locations.push({ + location, + internalDamage: structureDamage.internalDamage, + armorDamage, + armorRear: resolution.armorRear, + protection, + }); - const appliedInternalDamage = internalDamage / internalDamageMultiplier; - const overflow = Math.max(0, resolution.internalDamage - appliedInternalDamage); + const overflow = structureDamage.overflowDamage; if (overflow === 0 || resolution.stopsTransfer) break; location = topology[location as keyof typeof topology]?.transfersTo ?? null; damage = overflow; @@ -1073,10 +1077,6 @@ function applyAutomaticMekCritical( }; } -function isCompositeStructure(unit: CBTForceUnit): boolean { - return unit.getUnit().structureType?.trim().toLowerCase() === 'composite'; -} - export function getMekExplosionProtection(unit: CBTForceUnit, location: string): MekExplosionProtection { if (hasOperationalProtection(unit, location, ['F_CASE_II'])) return 'case-ii'; if (hasOperationalProtection(unit, location, ['F_CASE', 'F_CASE_P'])) return 'case'; diff --git a/src/app/utils/mek-falling.util.spec.ts b/src/app/utils/mek-falling.util.spec.ts index 5d435bc9b..10a5284e7 100644 --- a/src/app/utils/mek-falling.util.spec.ts +++ b/src/app/utils/mek-falling.util.spec.ts @@ -3,14 +3,18 @@ // Author: Drake import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import type { ArmorType } from '../models/entity/types'; import { applyMekFallDamage, mekFallDamage, mekFallDamageGroups, resolveMekFallHitLocation, resolveMekFallOrientation, + twoD6ForTotal, + twoD6Total, type ResolvedMekFallDamageGroup, } from './mek-falling.util'; +import type { MekStructureKind } from './mek-structure-damage.util'; describe('Mek falling rules', () => { it('keeps Core facing while selecting rear only on an orientation roll of 1', () => { @@ -44,6 +48,13 @@ describe('Mek falling rules', () => { expect(mekFallDamageGroups(18)).toEqual([5, 5, 5, 3]); }); + it('derives a 2D6 total from the two persisted dice', () => { + expect(twoD6Total([1, 6])).toBe(7); + expect(twoD6ForTotal(7)).toEqual([3, 4]); + expect(twoD6ForTotal(1)).toBeNull(); + expect(twoD6ForTotal(13)).toBeNull(); + }); + it('uses the selected arc and identifies rear torso armor and table criticals', () => { expect(resolveMekFallHitLocation('biped', 'rear', 2)).toEqual(jasmine.objectContaining({ rawTableResult: 'CT(C)', @@ -152,7 +163,7 @@ describe('Mek falling rules', () => { it('halves a group that reaches intact Impact-Resistant Armor, rounding down', () => { const harness = createDamageHarness({ - armorType: 'Impact-Resistant', + armorType: 'IMPACT_RESISTANT', armor: { CT: 10 }, internal: { CT: 10 }, }); @@ -165,7 +176,7 @@ describe('Mek falling rules', () => { it('keeps the minimum one point when Impact-Resistant Armor halves a one-point group', () => { const harness = createDamageHarness({ - armorType: 'Impact_Resistant', + armorType: 'IMPACT_RESISTANT', armor: { CT: 10 }, internal: { CT: 10 }, }); @@ -176,6 +187,87 @@ describe('Mek falling rules', () => { expect(result.appliedDamage).toBe(1); }); + it('re-evaluates patchwork armor when damage transfers to another location', () => { + const harness = createDamageHarness({ + armorTypes: { LA: 'STANDARD', LT: 'IMPACT_RESISTANT' }, + armor: { LA: 1, LT: 10 }, + internal: { LA: 0, LT: 10, CT: 10 }, + }); + + const result = applyMekFallDamage(harness.unit, [group('LA', 5)], false); + + expect(harness.armorHits).toEqual(new Map([['LA', 1], ['LT', 2]])); + expect(result.appliedDamage).toBe(3); + }); + + it('applies Ferro-Lamellor and Reflective Armor using physical non-attack rules', () => { + const ferro = createDamageHarness({ + armorType: 'FERRO_LAMELLOR', armor: { CT: 10 }, internal: { CT: 10 }, + }); + const reflective = createDamageHarness({ + armorType: 'REFLECTIVE', armor: { CT: 10 }, internal: { CT: 10 }, + }); + + expect(applyMekFallDamage(ferro.unit, [group('CT', 5)], false).appliedDamage).toBe(4); + expect(ferro.armorHits.get('CT')).toBe(4); + expect(applyMekFallDamage(reflective.unit, [group('CT', 5)], false).appliedDamage).toBe(10); + expect(reflective.armorHits.get('CT')).toBe(10); + }); + + it('doubles a physical hit even when only one point of Reflective Armor remains', () => { + const harness = createDamageHarness({ + armorType: 'REFLECTIVE', armor: { CT: 1 }, internal: { CT: 10 }, + }); + + const result = applyMekFallDamage(harness.unit, [group('CT', 1)], false); + + expect(harness.armorHits.get('CT')).toBe(1); + expect(harness.internalHits.get('CT')).toBeUndefined(); + expect(result.appliedDamage).toBe(2); + }); + + it('stores Hardened Armor half-pips as integer armor damage', () => { + const harness = createDamageHarness({ + armorType: 'HARDENED', armor: { CT: 20 }, internal: { CT: 10 }, + }); + + const first = applyMekFallDamage(harness.unit, [group('CT', 1)], false); + expect(harness.armorHits.get('CT')).toBe(1); + expect(first.appliedDamage).toBe(0); + + const second = applyMekFallDamage(harness.unit, [group('CT', 1)], false); + expect(harness.armorHits.get('CT')).toBe(2); + expect(second.appliedDamage).toBe(1); + }); + + it('transfers only whole incoming damage after Hardened Armor is exhausted', () => { + const harness = createDamageHarness({ + armorType: 'HARDENED', armor: { CT: 20 }, internal: { CT: 10 }, + }); + + applyMekFallDamage(harness.unit, [group('CT', 19)], false); + const result = applyMekFallDamage(harness.unit, [group('CT', 2)], false); + + expect(harness.armorHits.get('CT')).toBe(20); + expect(harness.internalHits.get('CT')).toBe(1); + expect(result.appliedDamage).toBe(2); + }); + + it('destroys odd composite structure without fractional transfer damage', () => { + const harness = createDamageHarness({ + armor: { LA: 0, LT: 10 }, + internal: { LA: 3, LT: 10, CT: 10 }, + structureKinds: { LA: 'composite' }, + }); + + const result = applyMekFallDamage(harness.unit, [group('LA', 2)], false); + + expect(harness.internalHits.get('LA')).toBe(3); + expect(harness.armorHits.get('LT')).toBeUndefined(); + expect(result.appliedDamage).toBe(3); + expect(result.locations).toHaveSize(1); + }); + it('queues a table critical in addition to applying internal damage', () => { const harness = createDamageHarness({ armor: { CT: 0 }, @@ -218,7 +310,7 @@ describe('Mek falling rules', () => { it('lets remaining Anti-Penetrative Ablation Armor suppress a table critical', () => { const harness = createDamageHarness({ - armorType: 'Anti_Penetrative_Ablation', + armorType: 'ANTI_PENETRATIVE_ABLATION', armor: { CT: 10 }, internal: { CT: 10 }, }); @@ -247,7 +339,9 @@ function createDamageHarness(options: { armor: Readonly>; internal: Readonly>; initialInternalHits?: Readonly>; - armorType?: string; + armorType?: ArmorType; + armorTypes?: Readonly>; + structureKinds?: Readonly>; }): { unit: CBTForceUnit; armorHits: Map; @@ -264,12 +358,11 @@ function createDamageHarness(options: { const queueMekCriticalChance = jasmine.createSpy('queueMekCriticalChance').and.returnValue(true); const unit = { locations: { internal: new Map(Object.keys(options.internal).map(location => [location, { loc: location }])) }, - getUnit: () => ({ - type: 'Mek', - subtype: 'BattleMek', - armorType: options.armorType ?? 'Standard Armor', - structureType: 'Standard', - }), + getUnit: () => ({ type: 'Mek', subtype: 'BattleMek' }), + getArmorTypeAt: (location: string) => options.armorTypes?.[location] + ?? options.armorType + ?? 'STANDARD', + getStructureKindAt: (location: string) => options.structureKinds?.[location] ?? 'standard', getArmorPoints: (location: string, rear = false) => options.armor[armorKey(location, rear)] ?? 0, getArmorHits: (location: string, rear = false) => armorHits.get(armorKey(location, rear)) ?? 0, addArmorHits: (location: string, hits: number, rear = false) => { diff --git a/src/app/utils/mek-falling.util.ts b/src/app/utils/mek-falling.util.ts index c9053668b..b281fc21a 100644 --- a/src/app/utils/mek-falling.util.ts +++ b/src/app/utils/mek-falling.util.ts @@ -3,8 +3,9 @@ // Author: Drake import type { CBTForceUnit } from '../models/cbt-force-unit.model'; -import { getMekLocationLabel, getTopologyFor, MEK_TORSO_LOCATIONS } from '../models/entity/types'; +import { getMekLocationLabel, getTopologyFor, MEK_TORSO_LOCATIONS, type ArmorType } from '../models/entity/types'; import type { MekHitArc } from '../models/force-serialization'; +import { fullDoubleDamagePipsRemoved, resolveMekStructureDamage } from './mek-structure-damage.util'; import { hitLocationCellDefinition, type MekHitLocationTable, @@ -47,6 +48,8 @@ export interface AppliedMekFallLocationDamage { readonly rear: boolean; readonly armorDamage: number; readonly internalDamage: number; + /** Rule-adjusted damage points consumed at this location. */ + readonly appliedDamage: number; } export interface AppliedMekFallDamage { @@ -140,6 +143,16 @@ export function mekFallDamageGroups(damage: number): readonly number[] { return groups; } +export function twoD6Total(dice: readonly [number, number]): number { + return dice[0] + dice[1]; +} + +export function twoD6ForTotal(total: number | null): readonly [number, number] | null { + return total !== null && Number.isInteger(total) && total >= 2 && total <= 12 + ? [Math.floor(total / 2), Math.ceil(total / 2)] + : null; +} + /** Resolves one 2D6 hit-location roll, including the extra tripod leg roll. */ export function resolveMekFallHitLocation( table: MekHitLocationTable, @@ -183,6 +196,12 @@ export function resolveMekFallHitLocation( }; } +export interface MekFallArmorDamageResolution { + readonly armorDamage: number; + readonly remainingDamage: number; + readonly appliedDamage: number; +} + export function isResolvedMekFallHitLocation( result: MekFallHitLocationResult, ): result is MekFallHitLocationResult & { readonly location: string; readonly locationLabel: string } { @@ -196,10 +215,6 @@ export function applyMekFallDamage( consolidateImmediately: boolean, ): AppliedMekFallDamage { const topology = getTopologyFor(unit.locations?.internal.keys() ?? []); - const compositeMultiplier = unit.getUnit().structureType?.trim().toLowerCase() === 'composite' ? 2 : 1; - const armorType = unit.getUnit().armorType; - const impactResistant = isImpactResistantArmor(armorType); - const antiPenetrativeAblation = isAntiPenetrativeAblationArmor(armorType); const locations: AppliedMekFallLocationDamage[] = []; let appliedDamage = 0; let headHits = 0; @@ -213,59 +228,71 @@ export function applyMekFallDamage( unit.getArmorPoints(group.location, originalRear) - unit.getArmorHits(group.location, originalRear), ); - let impactReductionApplied = false; + const originalArmorType = unit.getArmorTypeAt(group.location); const visited = new Set(); - let groupAppliedDamage = 0; + let groupDamaged = false; while (location && damage > 0 && !visited.has(location)) { visited.add(location); const rear = group.rear && MEK_TORSO_LOCATIONS.has(location); const remainingArmor = Math.max(0, unit.getArmorPoints(location, rear) - unit.getArmorHits(location, rear)); - if (impactResistant && remainingArmor > 0 && !impactReductionApplied) { - damage = Math.max(1, Math.floor(damage / 2)); - impactReductionApplied = true; - } - - const armorDamage = Math.min(damage, remainingArmor); + const armorType = unit.getArmorTypeAt(location); + const armor = resolveMekFallArmorDamage( + damage, + remainingArmor, + armorType, + ); + const armorDamage = armor.armorDamage; if (armorDamage > 0) { - unit.addArmorHits(location, armorDamage, rear, consolidateImmediately); - damage -= armorDamage; - appliedDamage += armorDamage; - groupAppliedDamage += armorDamage; + unit.addArmorHits( + location, + armorDamage, + rear, + consolidateImmediately, + armor.appliedDamage, + ); } + damage = armor.remainingDamage; + appliedDamage += armor.appliedDamage; + groupDamaged ||= armorDamage > 0; const remainingInternal = Math.max( 0, unit.getInternalPoints(location) - unit.getInternalHits(location), ); - const internalDamage = Math.min(remainingInternal, damage * compositeMultiplier); + const structure = resolveMekStructureDamage( + damage, + remainingInternal, + unit.getStructureKindAt(location), + ); + const internalDamage = structure.internalDamage; if (internalDamage > 0) { unit.addInternalHits(location, internalDamage, consolidateImmediately, { - hardenedArmorApplies: remainingArmor > 0, + hardenedArmorApplies: armorType === 'HARDENED' && remainingArmor > 0, }); - const damagePoints = internalDamage / compositeMultiplier; - damage = Math.max(0, damage - damagePoints); - appliedDamage += damagePoints; - groupAppliedDamage += damagePoints; } + damage = structure.overflowDamage; + appliedDamage += structure.phaseDamage; + groupDamaged ||= internalDamage > 0; locations.push({ location, rear, armorDamage, internalDamage, + appliedDamage: armor.appliedDamage + structure.phaseDamage, }); if (damage <= 0) break; location = topology[location as keyof typeof topology]?.transfersTo ?? null; } - if (group.location === 'HD' && groupAppliedDamage > 0) headHits++; - if (group.critical && groupAppliedDamage > 0 - && !(antiPenetrativeAblation && originalArmor > 0)) { + if (group.location === 'HD' && groupDamaged) headHits++; + if (group.critical && groupDamaged + && !(originalArmorType === 'ANTI_PENETRATIVE_ABLATION' && originalArmor > 0)) { unit.queueMekCriticalChance(group.location, { consolidateImmediately, - hardenedArmorApplies: originalArmor > 0, + hardenedArmorApplies: originalArmorType === 'HARDENED' && originalArmor > 0, throughArmorHitArc: throughArmorHitArc(group), }); } @@ -274,33 +301,69 @@ export function applyMekFallDamage( return { appliedDamage, headHits, locations }; } +/** Applies the armor rule for physical non-attack damage at one exact location. */ +export function resolveMekFallArmorDamage( + damage: number, + remainingArmor: number, + armorType: ArmorType | null, +): MekFallArmorDamageResolution { + const incoming = normalizedInteger(damage); + const armor = normalizedInteger(remainingArmor); + if (incoming === 0 || armor === 0) { + return { + armorDamage: 0, + remainingDamage: incoming, + appliedDamage: 0, + }; + } + + if (armorType === 'REFLECTIVE') { + const modifiedDamage = incoming * 2; + if (armor >= modifiedDamage) { + return { + armorDamage: modifiedDamage, + remainingDamage: 0, + appliedDamage: modifiedDamage, + }; + } + const absorbedDamage = Math.ceil(armor / 2); + return { + armorDamage: armor, + remainingDamage: Math.max(0, incoming - absorbedDamage), + appliedDamage: absorbedDamage * 2, + }; + } + + if (armorType === 'HARDENED') { + const absorbedDamage = Math.min(incoming, armor); + return { + armorDamage: absorbedDamage, + remainingDamage: incoming - absorbedDamage, + appliedDamage: fullDoubleDamagePipsRemoved(armor, absorbedDamage), + }; + } + + const modifiedDamage = armorType === 'FERRO_LAMELLOR' + ? Math.floor(incoming * 4 / 5) + : armorType === 'IMPACT_RESISTANT' + ? Math.max(1, Math.floor(incoming / 2)) + : incoming; + const armorDamage = Math.min(armor, modifiedDamage); + return { + armorDamage, + remainingDamage: modifiedDamage - armorDamage, + appliedDamage: armorDamage, + }; +} + function throughArmorHitArc(group: ResolvedMekFallDamageGroup): MekFallHitArc { if (group.location === 'LT') return 'left'; if (group.location === 'RT') return 'right'; return group.rear ? 'rear' : 'front'; } -const IMPACT_RESISTANT_ARMOR_NAMES = new Set([ - 'impact-resistant', - 'impact resistant', - 'impact_resistant', -]); - -const ANTI_PENETRATIVE_ABLATION_ARMOR_NAMES = new Set([ - 'anti-penetrative-ablation', - 'anti penetrative ablation', - 'anti_penetrative_ablation', - 'anti-penetrative-ablative', - 'anti penetrative ablative', - 'anti_penetrative_ablative', -]); - -export function isImpactResistantArmor(armorType: string): boolean { - return IMPACT_RESISTANT_ARMOR_NAMES.has(armorType.trim().toLowerCase()); -} - -function isAntiPenetrativeAblationArmor(armorType: string): boolean { - return ANTI_PENETRATIVE_ABLATION_ARMOR_NAMES.has(armorType.trim().toLowerCase()); +function normalizedInteger(value: number): number { + return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0; } function assertIntegerInRange(value: number, min: number, max: number, label: string): void { diff --git a/src/app/utils/mek-structure-damage.util.spec.ts b/src/app/utils/mek-structure-damage.util.spec.ts new file mode 100644 index 000000000..24ee90419 --- /dev/null +++ b/src/app/utils/mek-structure-damage.util.spec.ts @@ -0,0 +1,67 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { + mekStructureDamageCapacity, + mekStructurePhaseDamage, + resolveMekStructureDamage, +} from './mek-structure-damage.util'; + +describe('Mek structure damage', () => { + it('reports the integer damage threshold for alternate structure', () => { + expect(mekStructureDamageCapacity(5, 'composite')).toBe(3); + expect(mekStructureDamageCapacity(5, 'reinforced')).toBe(5); + expect(mekStructureDamageCapacity(5, 'standard')).toBe(5); + }); + + it('drops the unusable half point when odd composite structure is destroyed', () => { + expect(resolveMekStructureDamage(2, 3, 'composite')).toEqual({ + internalDamage: 3, + phaseDamage: 3, + overflowDamage: 0, + }); + expect(resolveMekStructureDamage(2, 1, 'composite')).toEqual({ + internalDamage: 1, + phaseDamage: 1, + overflowDamage: 1, + }); + }); + + it('keeps incoming phase damage while composite structure survives', () => { + expect(resolveMekStructureDamage(1, 3, 'composite')).toEqual({ + internalDamage: 2, + phaseDamage: 1, + overflowDamage: 0, + }); + }); + + it('derives phase damage from applied pips, remaining structure, and structure kind', () => { + expect(mekStructurePhaseDamage(2, 6, 'composite')).toBe(1); + expect(mekStructurePhaseDamage(3, 3, 'composite')).toBe(3); + expect(mekStructurePhaseDamage(1, 6, 'reinforced')).toBe(0); + expect(mekStructurePhaseDamage(1, 5, 'reinforced')).toBe(1); + expect(mekStructurePhaseDamage(2, 6, 'standard')).toBe(2); + }); + + it('records Reinforced Structure as integer half-pips and counts only completed circles', () => { + expect(resolveMekStructureDamage(3, 6, 'reinforced')).toEqual({ + internalDamage: 3, + phaseDamage: 1, + overflowDamage: 0, + }); + expect(resolveMekStructureDamage(1, 3, 'reinforced')).toEqual({ + internalDamage: 1, + phaseDamage: 1, + overflowDamage: 0, + }); + }); + + it('uses reinforced structure pips as two incoming damage points', () => { + expect(resolveMekStructureDamage(5, 2, 'reinforced')).toEqual({ + internalDamage: 2, + phaseDamage: 1, + overflowDamage: 3, + }); + }); +}); diff --git a/src/app/utils/mek-structure-damage.util.ts b/src/app/utils/mek-structure-damage.util.ts new file mode 100644 index 000000000..f22c9b6eb --- /dev/null +++ b/src/app/utils/mek-structure-damage.util.ts @@ -0,0 +1,72 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +export type MekStructureKind = 'standard' | 'composite' | 'reinforced'; + +export const MEK_STRUCTURE_TYPE = { + REINFORCED: 4, + COMPOSITE: 5, +} as const; + +export interface MekStructureDamageResolution { + readonly internalDamage: number; + readonly overflowDamage: number; + /** MegaMek-compatible amount added to damage received this phase. */ + readonly phaseDamage: number; +} + +/** Incoming damage required to destroy the remaining structure. */ +export function mekStructureDamageCapacity( + remainingInternal: number, + kind: MekStructureKind, +): number { + const capacity = normalizedInteger(remainingInternal); + if (capacity === 0) return 0; + if (kind === 'composite') return Math.ceil(capacity / 2); + return capacity; +} + +/** MegaMek-compatible integer structure absorption and transfer. */ +export function resolveMekStructureDamage( + damage: number, + remainingInternal: number, + kind: MekStructureKind, +): MekStructureDamageResolution { + const incoming = normalizedInteger(damage); + const capacity = normalizedInteger(remainingInternal); + const possibleInternal = kind === 'composite' + ? incoming * 2 + : incoming; + const internalDamage = Math.min(capacity, possibleInternal); + const absorbedDamage = Math.min(incoming, kind === 'composite' + ? Math.ceil(internalDamage / 2) + : internalDamage); + return { + internalDamage, + phaseDamage: mekStructurePhaseDamage(internalDamage, capacity, kind), + overflowDamage: incoming - absorbedDamage, + }; +} + +/** Damage contributed by an applied structure-pip delta to the phase's 20+ damage PSR. */ +export function mekStructurePhaseDamage( + internalDamage: number, + remainingInternal: number, + kind: MekStructureKind, +): number { + const capacity = normalizedInteger(remainingInternal); + const applied = Math.min(capacity, normalizedInteger(internalDamage)); + if (kind === 'reinforced') return fullDoubleDamagePipsRemoved(capacity, applied); + if (kind === 'composite' && applied < capacity) return Math.ceil(applied / 2); + return applied; +} + +/** Full printed pips removed when each pip is represented by two ordered damage pips. */ +export function fullDoubleDamagePipsRemoved(remaining: number, damage: number): number { + return Math.ceil(remaining / 2) - Math.ceil((remaining - damage) / 2); +} + +function normalizedInteger(value: number): number { + return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0; +} diff --git a/src/app/utils/rs-polyfill.util.spec.ts b/src/app/utils/rs-polyfill.util.spec.ts index 78ff0167c..6dbec5c04 100644 --- a/src/app/utils/rs-polyfill.util.spec.ts +++ b/src/app/utils/rs-polyfill.util.spec.ts @@ -5,6 +5,31 @@ import { RsPolyfillUtil } from './rs-polyfill.util'; describe('RsPolyfillUtil', () => { + it('doubles record capacity only at typed Hardened Armor and Reinforced Structure locations', () => { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.innerHTML = ` + + + + + `; + const forceUnit = { + getArmorTypeAt: (location: string) => location === 'CT' ? 'HARDENED' : 'STANDARD', + getStructureKindAt: (location: string) => location === 'CT' ? 'reinforced' : 'composite', + }; + const adjust = (RsPolyfillUtil as unknown as { + adjustArmorPips: (unit: typeof forceUnit, svg: SVGSVGElement) => void; + }).adjustArmorPips.bind(RsPolyfillUtil); + + adjust(forceUnit, svg); + adjust(forceUnit, svg); + + expect(svg.querySelectorAll('.pip.armor[loc="CT"]').length).toBe(2); + expect(svg.querySelectorAll('.pip.armor[loc="LA"]').length).toBe(1); + expect(svg.querySelectorAll('.pip.structure[loc="CT"]').length).toBe(2); + expect(svg.querySelectorAll('.pip.structure[loc="LA"]').length).toBe(1); + }); + it('adds an idempotent native SVG inversion filter for iOS night mode', () => { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); const addFilter = (RsPolyfillUtil as unknown as { diff --git a/src/app/utils/rs-polyfill.util.ts b/src/app/utils/rs-polyfill.util.ts index 715f05b8e..004745204 100644 --- a/src/app/utils/rs-polyfill.util.ts +++ b/src/app/utils/rs-polyfill.util.ts @@ -104,7 +104,7 @@ export class RsPolyfillUtil { this.addCrewDamageClasses(unit, svg); this.addCrewNamesButtons(svg, forceUnit); this.addInventoryLines(svg); - this.adjustArmorPips(unit, svg); + this.adjustArmorPips(forceUnit, svg); this.addPipHitAreas(svg); this.addHitMod(svg); this.injectFluffImage(unit, svg); @@ -1336,35 +1336,26 @@ export class RsPolyfillUtil { return value.length > 0 && value !== '—'; } - private static adjustArmorPips(unit: UnitSummary, svg: SVGSVGElement): void { - if (unit.armorType === 'Hardened') { - const armorPips = svg.querySelectorAll('.pip.armor'); - armorPips.forEach(pip => { - pip.classList.add('hardened'); - const clone = pip.cloneNode(true) as SVGElement; - clone.classList.add('half'); - if (pip.parentNode && pip.nextSibling) { - pip.parentNode.insertBefore(clone, pip.nextSibling); - } else if (pip.parentNode) { - pip.parentNode.appendChild(clone); - } - }); - } - const structureType = svg.getElementById('structureType')?.textContent || ''; - if (structureType.includes('Reinforced')) { - const structurePips = svg.querySelectorAll('.pip.structure'); - structurePips.forEach(pip => { - pip.classList.add('hardened'); - const clone = pip.cloneNode(true) as SVGElement; - clone.classList.add('half'); - if (pip.parentNode && pip.nextSibling) { - pip.parentNode.insertBefore(clone, pip.nextSibling); - } else if (pip.parentNode) { - pip.parentNode.appendChild(clone); - } - }); - } - }; + private static adjustArmorPips(unit: CBTForceUnit, svg: SVGSVGElement): void { + this.doubleDamagePips(svg, '.pip.armor', location => unit.getArmorTypeAt(location) === 'HARDENED'); + this.doubleDamagePips(svg, '.pip.structure', location => unit.getStructureKindAt(location) === 'reinforced'); + } + + /** Represents materials that take two damage per printed point as two ordered record pips. */ + private static doubleDamagePips( + svg: SVGSVGElement, + selector: string, + appliesAt: (location: string) => boolean, + ): void { + svg.querySelectorAll(selector).forEach(pip => { + const location = pip.getAttribute('loc'); + if (!location || pip.classList.contains('hardened') || !appliesAt(location)) return; + pip.classList.add('hardened'); + const clone = pip.cloneNode(true) as SVGElement; + clone.classList.add('half'); + pip.parentNode?.insertBefore(clone, pip.nextSibling); + }); + } /** * Adds larger transparent hit areas to armor and structure pips. diff --git a/src/app/utils/unit-component-metadata-builder.spec.ts b/src/app/utils/unit-component-metadata-builder.spec.ts index dd9cafc52..73756c5cf 100644 --- a/src/app/utils/unit-component-metadata-builder.spec.ts +++ b/src/app/utils/unit-component-metadata-builder.spec.ts @@ -170,14 +170,10 @@ describe('buildUnitComponentMetadata', () => { const components = buildUnitComponentMetadata(entity)!; expect(components.filter(component => component.id === 'Patchwork Armor')).toHaveSize(1); - expect(components.filter(component => component.id === 'Standard Armor')).toHaveSize(1); - expect(components.filter(component => component.id === 'IS Reactive')).toHaveSize(1); + expect(components.filter(component => component.id === 'Standard Armor')).toHaveSize(0); + expect(components.filter(component => component.id === 'IS Reactive')).toHaveSize(0); expect(components.find(component => component.id === 'Patchwork Armor')?.n).toBe('Patchwork Armor'); - expect(components.find(component => component.id === 'Standard Armor')?.n).toBe('Standard Armor'); - expect(components.find(component => component.id === 'IS Reactive')?.n).toBe('Reactive Armor'); - expect(components.filter(component => [ - 'Patchwork Armor', 'Standard Armor', 'IS Reactive', - ].includes(component.id)).every(component => component.p === -1)).toBeTrue(); + expect(components.find(component => component.id === 'Patchwork Armor')?.p).toBe(-1); }); it('exports intrinsic ammo damage for a special one-shot weapon', () => { @@ -323,6 +319,23 @@ describe('buildUnitComponentMetadata', () => { expect(buildUnitComponentMetadata(entity)!.find(component => component.id === 'Endo Steel')?.n) .toBe('Endo Steel Structure'); }); + + it('keeps hybrid Mek structure materials out of component inventory', () => { + const entity = new BipedMekEntity(); + const standard = new StructureEquipment({ + id: 'Standard', name: 'Standard', type: 'structure', structure: { typeId: 0 }, + }); + const composite = new StructureEquipment({ + id: 'Composite', name: 'Composite', type: 'structure', structure: { typeId: 5 }, + flags: ['F_COMPOSITE'], + }); + entity.setUniformStructure(new MountedStructure({ structure: standard, tonnage: entity.tonnage() })); + entity.setStructureAt('LA', new MountedStructure({ structure: composite, tonnage: entity.tonnage() })); + + const components = buildUnitComponentMetadata(entity)!; + expect(components.some(component => component.id === 'Standard')).toBeFalse(); + expect(components.some(component => component.id === 'Composite')).toBeFalse(); + }); }); function weapon( diff --git a/src/app/utils/unit-component-metadata-builder.ts b/src/app/utils/unit-component-metadata-builder.ts index 89e62d575..8a0c29b04 100644 --- a/src/app/utils/unit-component-metadata-builder.ts +++ b/src/app/utils/unit-component-metadata-builder.ts @@ -161,12 +161,10 @@ function addMekSystem( }); } -/** Exports the entity-selected internal structure once, independently of critical-slot mounts. */ +/** Exports one uniform structure independently of critical-slot mounts. */ function addSyntheticStructure(components: Map, entity: BaseEntity): void { - const structure = entity.uniformStructureMaterial()?.structure - ?? entity.structureByLocation().get(entity.locationOrder[0])?.structure; + const structure = entity.uniformStructureMaterial()?.structure; if (!structure) return; - components.set(`${structure.id}__structure`, { ...baseComponent(structure, 1, -1, undefined, 'S', criticals(structure, entity)), n: withMaterialSuffix(structure.shortName, 'Structure'), @@ -187,19 +185,15 @@ function addSyntheticArmor(components: Map, entity: Bas ...baseComponent(patchwork, 1, -1, undefined, 'S', criticals(patchwork, entity)), n: withMaterialSuffix(patchwork.shortName, 'Armor'), }); + return; } - const materials = new Map(); - for (const mountedArmor of armorByLocation.values()) { - const key = `${mountedArmor.armor.id}:${mountedArmor.techBase}`; - materials.set(key, mountedArmor.armor); - } - for (const [key, armor] of materials) { - components.set(`${armor.id}__armor_${key}`, { - ...baseComponent(armor, 1, -1, undefined, 'S', criticals(armor, entity)), - n: withMaterialSuffix(armor.shortName, 'Armor'), - }); - } + const armor = entity.uniformArmor()?.armor; + if (!armor) return; + components.set(`${armor.id}__armor`, { + ...baseComponent(armor, 1, -1, undefined, 'S', criticals(armor, entity)), + n: withMaterialSuffix(armor.shortName, 'Armor'), + }); } function withMaterialSuffix(name: string, suffix: 'Armor' | 'Structure'): string { @@ -494,4 +488,4 @@ function activeAeroValues(equipment: WeaponEquipment): number[] { function formatDecimal(value: number): string { return Number.isInteger(value) ? value.toFixed(1) : String(value); -} \ No newline at end of file +} diff --git a/src/app/utils/unit-metadata-builder.spec.ts b/src/app/utils/unit-metadata-builder.spec.ts index 9a5243361..861768712 100644 --- a/src/app/utils/unit-metadata-builder.spec.ts +++ b/src/app/utils/unit-metadata-builder.spec.ts @@ -208,14 +208,14 @@ describe('UnitMetadataBuilder', () => { expect(builder.build(entity).structureType).toBe('Standard'); }); - it('exports an effective Mek structure as one synthetic component', () => { + it('exports hybrid Mek structure distribution outside component inventory', () => { const entity = new BipedMekEntity(); entity.setTonnage(60); const endo = new StructureEquipment({ id: 'IS Endo Steel', name: 'Endo Steel', type: 'structure', - structure: { typeId: 1 }, + structure: { typeId: 2 }, tech: { base: 'IS' }, }); entity.setUniformStructure(new MountedStructure({ @@ -225,9 +225,19 @@ describe('UnitMetadataBuilder', () => { entity.setStructureAt('LA', new MountedStructure({ tonnage: 60, structure: endo })); const metadata = builder.build(entity); - expect(metadata.structureType).toBe('Standard'); + expect(metadata.structureType).toBe('Hybrid'); + expect(metadata.hybridLayout).toEqual({ + HD: { type: 0, clan: false }, + CT: { type: 0, clan: false }, + RT: { type: 0, clan: false }, + LT: { type: 0, clan: false }, + RA: { type: 0, clan: false }, + LA: { type: 2, clan: false }, + RL: { type: 0, clan: false }, + LL: { type: 0, clan: false }, + }); const componentIds = metadata.comp?.filter(component => component.t === 'S').map(component => component.id) ?? []; - expect(componentIds).toContain(STANDARD_STRUCTURE_EQUIPMENT.id); + expect(componentIds).not.toContain(STANDARD_STRUCTURE_EQUIPMENT.id); expect(componentIds).not.toContain(endo.id); entity.setEquipment([ @@ -240,10 +250,32 @@ describe('UnitMetadataBuilder', () => { const mountedComponentIds = builder.build(entity).comp ?.filter(component => component.t === 'S').map(component => component.id) ?? []; - expect(mountedComponentIds).toContain(STANDARD_STRUCTURE_EQUIPMENT.id); + expect(mountedComponentIds).not.toContain(STANDARD_STRUCTURE_EQUIPMENT.id); expect(mountedComponentIds).not.toContain(endo.id); }); + it('exports patchwork armor distribution outside component inventory', () => { + const entity = new BipedMekEntity(); + const standard = new ArmorEquipment({ + id: 'Standard Armor', name: 'Standard', type: 'armor', armor: { type: 'STANDARD' }, + }); + const impactResistant = new ArmorEquipment({ + id: 'Impact-Resistant Armor', name: 'Impact-Resistant', type: 'armor', + armor: { type: 'IMPACT_RESISTANT' }, + }); + entity.setUniformArmor(new MountedArmor({ armor: standard, techBase: 'IS' })); + entity.setArmorEquipmentAt('LA', impactResistant); + + const metadata = builder.build(entity); + expect(metadata.armorType).toBe('Patchwork'); + expect(metadata.patchworkLayout?.['LA']).toEqual({ type: 25, clan: false }); + expect(metadata.patchworkLayout?.['CT']).toEqual({ type: 0, clan: false }); + const componentIds = metadata.comp?.map(component => component.id) ?? []; + expect(componentIds).toContain('Patchwork Armor'); + expect(componentIds).not.toContain('Standard Armor'); + expect(componentIds).not.toContain('Impact-Resistant Armor'); + }); + it('exports Java weight class display names without changing canonical categories', () => { const conventionalFighter = new ConvFighterEntity(); conventionalFighter.setTonnage(50); diff --git a/src/app/utils/unit-metadata-builder.ts b/src/app/utils/unit-metadata-builder.ts index 73d25881c..5d75b586c 100644 --- a/src/app/utils/unit-metadata-builder.ts +++ b/src/app/utils/unit-metadata-builder.ts @@ -6,7 +6,7 @@ import { BaseEntity } from '../models/entity/base-entity'; import { InfantryBaseEntity } from '../models/entity/entities/infantry/infantry-base-entity'; import { InfantryEntity } from '../models/entity/entities/infantry/infantry-entity'; import { JumpShipEntity } from '../models/entity/entities/largecraft/jumpship-entity'; -import { UnitSummary } from '../models/unit-summary.model'; +import { type UnitMaterialLayout, UnitSummary } from '../models/unit-summary.model'; import { EntityType, MoveType } from '../models/entity/types'; import { buildUnitCargoMetadata } from './unit-cargo-metadata-builder'; import { buildUnitComponentMetadata } from './unit-component-metadata-builder'; @@ -14,6 +14,7 @@ import { EquipmentFlag } from '../models/equipment-flags.type'; import { convertEntityToAlphaStrike } from '../models/entity/utils/alpha-strike/alpha-strike-converter'; import { alphaStrikeUnitType } from '../models/entity/utils/alpha-strike/foundation/unit-classification'; import type { UnitIconResolver } from './unit-sprite-resolver'; +import { encodeBlkArmorType } from '../models/entity/parsers/blk-codec'; /** * Builds a `Partial` metadata object from a parsed entity. @@ -63,7 +64,9 @@ export class UnitMetadataBuilder { engineRating: this.exportsEngine(entity) ? me.rating : 0, armorType: this.buildArmorType(entity), structureType: entity.uniformStructureMaterial()?.structure.name - ?? (entity.structureByLocation().size > 0 ? 'Standard' : null), + ?? (entity.structureByLocation().size > 0 ? 'Hybrid' : null), + patchworkLayout: this.buildPatchworkLayout(entity), + hybridLayout: this.buildHybridLayout(entity), armor: entity.totalArmorPoints(), internal: entity.totalInternalPoints(), armorPer: entity.maximumArmorPoints() > 0 @@ -273,6 +276,29 @@ export class UnitMetadataBuilder { const armorType = entity.uniformArmor()?.type ?? 'STANDARD'; return ARMOR_TYPE_DISPLAY_NAME[armorType] ?? armorType; } + + private buildPatchworkLayout(entity: BaseEntity): UnitMaterialLayout | undefined { + if (!entity.hasPatchworkArmor()) return undefined; + return Object.fromEntries([...entity.armorByLocation()].map(([location, armor]) => [ + entity.componentLocationLabel(location), + { type: encodeBlkArmorType(armor), clan: this.isClanMaterial(entity, armor.techBase) }, + ])); + } + + private buildHybridLayout(entity: BaseEntity): UnitMaterialLayout | undefined { + if (entity.uniformStructureMaterial() || entity.structureByLocation().size === 0) return undefined; + return Object.fromEntries([...entity.structureByLocation()].map(([location, structure]) => [ + entity.componentLocationLabel(location), + { + type: structure.structure.structureTypeId, + clan: this.isClanMaterial(entity, structure.techBase), + }, + ])); + } + + private isClanMaterial(entity: BaseEntity, techBase: 'IS' | 'Clan' | 'All'): boolean { + return techBase === 'Clan' || (techBase === 'All' && entity.techBase() === 'Clan'); + } } // ═══════════════════════════════════════════════════════════════════════════ From bf7ff4d0dc85de8034173b34b7ef96341779133f Mon Sep 17 00:00:00 2001 From: exeea Date: Thu, 27 Aug 2026 21:22:55 +0200 Subject: [PATCH 41/87] toasts stack --- .../components/toasts/toasts.component.css | 2 +- src/app/components/toasts/toasts.component.ts | 2 +- src/app/services/toast.service.ts | 60 +++++++++++-------- 3 files changed, 38 insertions(+), 26 deletions(-) diff --git a/src/app/components/toasts/toasts.component.css b/src/app/components/toasts/toasts.component.css index d7dd772bb..575ab004c 100644 --- a/src/app/components/toasts/toasts.component.css +++ b/src/app/components/toasts/toasts.component.css @@ -19,7 +19,7 @@ font-size: 1rem; cursor: pointer; pointer-events: auto; - opacity: 0.95; + opacity: 1; text-align: left; width: auto; max-width: 400px; diff --git a/src/app/components/toasts/toasts.component.ts b/src/app/components/toasts/toasts.component.ts index d8e762571..7f52fb162 100644 --- a/src/app/components/toasts/toasts.component.ts +++ b/src/app/components/toasts/toasts.component.ts @@ -14,7 +14,7 @@ import { ToastService } from '../../services/toast.service'; imports: [CommonModule], template: `
- @for (toast of toastService.toasts(); let i = $index; track i) { + @for (toast of toastService.visibleToasts(); track toast.id) {
{{ toast.message }}
diff --git a/src/app/services/toast.service.ts b/src/app/services/toast.service.ts index e2783d32e..15e581de2 100644 --- a/src/app/services/toast.service.ts +++ b/src/app/services/toast.service.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { Injectable, signal } from '@angular/core'; +import { computed, Injectable, signal } from '@angular/core'; import { uuidv7 } from '../utils/uuid.util'; @@ -13,14 +13,16 @@ export interface Toast { data?: Record; } -const TOAST_DURATION_MS = 4000; -const MAX_TOASTS = 4; +const TOAST_DURATION_MS = 3000; +const MAX_TOASTS = 10; +const MAX_VISIBLE_TOASTS = 3; @Injectable({ providedIn: 'root' }) export class ToastService { private toastsSignal = signal([]); public toasts = this.toastsSignal.asReadonly(); - private timeouts = new Map(); + public visibleToasts = computed(() => this.toastsSignal().slice(0, MAX_VISIBLE_TOASTS)); + private timeout?: ReturnType; showToast(message: string, type: Toast['type'], id?: string, data?: Toast['data']): string { const toastId = id || uuidv7(); @@ -31,9 +33,6 @@ export class ToastService { const existingToastIndex = toasts.findIndex(t => t.id === id); if (existingToastIndex !== -1) { - // Clear existing timeout - this.clearTimeout(id); - // Update existing toast const updatedToasts = [...toasts]; updatedToasts[existingToastIndex] = { @@ -43,41 +42,54 @@ export class ToastService { data }; this.toastsSignal.set(updatedToasts); - - // Set new timeout - const timeout = setTimeout(() => this.dismiss(toastId), TOAST_DURATION_MS); - this.timeouts.set(toastId, timeout); + if (existingToastIndex === 0) this.restartTimer(); return toastId; } } // Create new toast + let activeToastRemoved = false; if (toasts.length >= MAX_TOASTS) { - const removedToast = toasts[0]; - this.clearTimeout(removedToast.id); toasts = toasts.slice(1); // Remove oldest + activeToastRemoved = true; } const toast: Toast = { id: toastId, message, type, data }; this.toastsSignal.set([...toasts, toast]); - - const timeout = setTimeout(() => this.dismiss(toastId), TOAST_DURATION_MS); - this.timeouts.set(toastId, timeout); + if (activeToastRemoved) { + this.restartTimer(); + } else { + this.startTimer(); + } return toastId; } dismiss(id: string) { - this.clearTimeout(id); + const activeToastRemoved = this.toastsSignal()[0]?.id === id; this.toastsSignal.update(toasts => toasts.filter(t => t.id !== id)); + if (activeToastRemoved) this.restartTimer(); } - private clearTimeout(id: string) { - const timeout = this.timeouts.get(id); - if (timeout) { - clearTimeout(timeout); - this.timeouts.delete(id); - } + private startTimer() { + if (this.timeout !== undefined || this.toastsSignal().length === 0) return; + + this.timeout = setTimeout(() => { + this.timeout = undefined; + const activeToast = this.toastsSignal()[0]; + if (activeToast) this.dismiss(activeToast.id); + }, TOAST_DURATION_MS); } -} \ No newline at end of file + + private restartTimer() { + this.stopTimer(); + this.startTimer(); + } + + private stopTimer() { + if (this.timeout === undefined) return; + clearTimeout(this.timeout); + this.timeout = undefined; + } +} From 89e68f537844b0cc9003a3ca66af70980c7bdae6 Mon Sep 17 00:00:00 2001 From: exeea Date: Thu, 27 Aug 2026 22:29:05 +0200 Subject: [PATCH 42/87] hex slider threshold --- .../hex-slider/hex-slider.component.spec.ts | 14 +++++++++++-- .../hex-slider/hex-slider.component.ts | 21 +++++++++++++------ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/app/components/hex-slider/hex-slider.component.spec.ts b/src/app/components/hex-slider/hex-slider.component.spec.ts index 042f949de..b7af1bc58 100644 --- a/src/app/components/hex-slider/hex-slider.component.spec.ts +++ b/src/app/components/hex-slider/hex-slider.component.spec.ts @@ -87,7 +87,7 @@ describe('HexSliderComponent', () => { const slider = fixture.nativeElement.querySelector('.hex-slider') as HTMLDivElement; expect(component.effectiveMaxValue()).toBe(6); - expect(component.blockedMaxPercent()).toBe(40); + expect(component.blockedMaxPercent()).toBe(35); expect(slider.getAttribute('aria-valuemax')).toBe('6'); expect(fixture.nativeElement.querySelector('.blocked-max-track')).not.toBeNull(); @@ -98,6 +98,16 @@ describe('HexSliderComponent', () => { expect(valueCommits).toEqual([6]); }); + it('keeps the maximum usable tick on the usable track', () => { + fixture.componentRef.setInput('max', 3); + fixture.componentRef.setInput('blockedMax', 1); + fixture.detectChanges(); + const blockedTrack = fixture.nativeElement.querySelector('.blocked-max-track') as HTMLDivElement; + + expect(component.blockedMaxPercent()).toBe(50); + expect(blockedTrack.style.width).toBe('50%'); + }); + it('uses tick label overrides without replacing other generated tick labels', () => { fixture.componentRef.setInput('tickLabelOverrides', { 8: 'RUN', 10: 'MASC' }); fixture.detectChanges(); @@ -118,4 +128,4 @@ function pointerEvent(type: string, pointerId: number, clientX: number): Pointer clientX, clientY: 0, }); -} \ No newline at end of file +} diff --git a/src/app/components/hex-slider/hex-slider.component.ts b/src/app/components/hex-slider/hex-slider.component.ts index e29b2ca79..05ace972a 100644 --- a/src/app/components/hex-slider/hex-slider.component.ts +++ b/src/app/components/hex-slider/hex-slider.component.ts @@ -70,11 +70,20 @@ export class HexSliderComponent { readonly clampedValue = computed(() => this.alignToStep(this.value())); readonly valueLabel = computed(() => this.label() ?? `${this.clampedValue()}`); readonly valuePercent = computed(() => this.percentForValue(this.clampedValue())); - readonly blockedMinPercent = computed(() => this.effectiveMinValue() > this.minValue() ? this.percentForValue(this.effectiveMinValue()) : 0); - readonly blockedMaxPercent = computed(() => this.effectiveMaxValue() < this.maxValue() - ? 100 - this.percentForValue(this.effectiveMaxValue()) - : 0 - ); + readonly blockedMinPercent = computed(() => { + const min = this.minValue(); + const effectiveMin = this.effectiveMinValue(); + if (effectiveMin <= min) return 0; + const previousValue = Math.max(min, effectiveMin - this.stepValue()); + return this.percentForValue((previousValue + effectiveMin) / 2); + }); + readonly blockedMaxPercent = computed(() => { + const max = this.maxValue(); + const effectiveMax = this.effectiveMaxValue(); + if (effectiveMax >= max) return 0; + const nextValue = Math.min(max, effectiveMax + this.stepValue()); + return 100 - this.percentForValue((effectiveMax + nextValue) / 2); + }); readonly displayTicks = computed(() => { const explicitTicks = this.ticks(); if (explicitTicks !== null) { @@ -235,4 +244,4 @@ export class HexSliderComponent { private roundValue(value: number): number { return Number(value.toFixed(6)); } -} \ No newline at end of file +} From 54575fd98f29ce4374e37a9c80d5e99120398ab9 Mon Sep 17 00:00:00 2001 From: exeea Date: Thu, 27 Aug 2026 23:10:22 +0200 Subject: [PATCH 43/87] cleanup --- .../falling-damage-dialog.component.html | 2 +- .../falling-damage-dialog.component.spec.ts | 1 + .../falling-damage-dialog.component.ts | 15 +- .../svg-interaction.service.spec.ts | 2 + .../page-viewer/svg-interaction.service.ts | 47 ++--- src/app/models/cbt-force-unit.model.spec.ts | 50 ++++- src/app/models/cbt-force-unit.model.ts | 128 ++++++++++-- src/app/models/unit-cover.model.spec.ts | 10 +- src/app/models/unit-cover.model.ts | 4 + .../falling-resolution.service.spec.ts | 30 ++- .../services/falling-resolution.service.ts | 9 +- src/app/utils/mek-critical-hit.util.spec.ts | 18 ++ src/app/utils/mek-critical-hit.util.ts | 31 ++- src/app/utils/mek-falling.util.spec.ts | 182 +++++++++++++++++- src/app/utils/mek-falling.util.ts | 149 +++++++++++--- .../utils/mek-structure-damage.util.spec.ts | 22 +-- src/app/utils/mek-structure-damage.util.ts | 28 +-- 17 files changed, 592 insertions(+), 136 deletions(-) diff --git a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.html b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.html index 929c2f270..7aa7e6112 100644 --- a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.html +++ b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.html @@ -60,7 +60,7 @@
{{ row.result!.locationLabel }} - @if (row.result!.rear) { Rear armor } + @if (row.result!.rear) { Rear arc } @if (row.result!.adjustedTripodLegRoll !== undefined) { Adjusted leg roll {{ row.result!.adjustedTripodLegRoll }} } diff --git a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.spec.ts b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.spec.ts index 488c2db32..f17d5bbfb 100644 --- a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.spec.ts +++ b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.spec.ts @@ -19,6 +19,7 @@ describe('FallingDamageDialogComponent', () => { persistRolls = jasmine.createSpy('setPendingFallRolls'); const unit = { gameRules: { id: 'core2026' }, + turnState: () => ({ cover: () => undefined }), getPendingFall: () => undefined, setPendingFallRolls: persistRolls, getNotificationDisplayName: () => 'Atlas AS7-D', diff --git a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.ts b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.ts index 874fdb355..f75841635 100644 --- a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.ts +++ b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.ts @@ -9,10 +9,11 @@ import type { CBTMekFallDamageRoll, CBTUnitAutomationTrigger, } from '../../models/cbt-force-unit.model'; +import { unitCoverWaterDepth } from '../../models/unit-cover.model'; import { isResolvedMekFallHitLocation, - mekFallDamage, - mekFallDamageGroups, + resolvedMekFallDamageGroups, + resolveMekFallDamage, resolveMekFallHitLocation, resolveMekFallOrientation, twoD6ForTotal, @@ -70,8 +71,14 @@ export class FallingDamageDialogComponent { readonly rulesId = this.data.unit.gameRules.id; readonly tons = this.data.unit.getUnit().tons; readonly levelsFallen = this.data.trigger.levelsFallen; - readonly totalDamage = mekFallDamage(this.tons, this.levelsFallen); - readonly damageGroups = mekFallDamageGroups(this.totalDamage); + readonly fallDamage = resolveMekFallDamage( + this.rulesId, + this.tons, + this.levelsFallen, + unitCoverWaterDepth(this.data.unit.turnState().cover()), + ); + readonly totalDamage = this.fallDamage.totalDamage; + readonly damageGroups = resolvedMekFallDamageGroups(this.fallDamage); readonly hitLocationTable: MekHitLocationTable = clusterTableForUnit(this.data.unit.getUnit()).hitLocationTable ?? 'biped'; readonly orientationRoll = signal(this.pending?.orientationRoll ?? null); 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 98cd9a63c..fd6f16de8 100644 --- a/src/app/components/page-viewer/svg-interaction.service.spec.ts +++ b/src/app/components/page-viewer/svg-interaction.service.spec.ts @@ -77,6 +77,8 @@ function createSvgInteractionUnit(overrides: T): T & { getInve }), getInventory: () => [], getCritSlot: () => null, + getModularArmorState: () => ({ hits: 0, points: 0, remaining: 0 }), + addModularArmorHits: () => 0, getEquipmentStatus: () => 'available', isEquipmentOperational: () => true, canPerformEquipmentAction: () => true, diff --git a/src/app/components/page-viewer/svg-interaction.service.ts b/src/app/components/page-viewer/svg-interaction.service.ts index dcb8174bc..2ce889836 100644 --- a/src/app/components/page-viewer/svg-interaction.service.ts +++ b/src/app/components/page-viewer/svg-interaction.service.ts @@ -656,6 +656,11 @@ export class SvgInteractionService { const rear = !!svgEl.getAttribute('rear'); let consumedModularArmorPoints = 0; let availableModularArmorPoints = 0; + const refreshModularArmor = () => { + const modularArmor = this.unit()?.getModularArmorState(loc); + consumedModularArmorPoints = modularArmor?.hits ?? 0; + availableModularArmorPoints = modularArmor?.remaining ?? 0; + }; let pipsCount = isStructure ? this.unit()?.getInternalPoints(loc) : this.unit()?.getArmorPoints(loc, rear); if (!pipsCount) { pipsCount = 0; @@ -699,14 +704,7 @@ export class SvgInteractionService { if (!isStructure && !isShield) { // We recalculate modular armor status, in case we added/removed some crits (destroyed or repaired) - consumedModularArmorPoints = 0; - availableModularArmorPoints = 0; - this.unit()?.getCritSlotsAsMatrix()[loc]?.forEach(critSlot => { - if (!critSlot.eq?.flags?.has('F_MODULAR_ARMOR')) return; - if (critSlot.destroyed) return; - consumedModularArmorPoints += critSlot.consumed || 0; - availableModularArmorPoints += 10 - consumedModularArmorPoints; - }); + refreshModularArmor(); } const allowedValues = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, -1, -2, -3, -4, -5, -10, -20]; @@ -776,32 +774,14 @@ export class SvgInteractionService { let valueToApply = value; // Apply damage to modular armor first, and restore it last when repairing. if (availableModularArmorPoints > 0 && valueToApply > 0) { - unit.getCritSlotsAsMatrix()[loc]?.forEach(critSlot => { - if (valueToApply == 0) return; - if (!critSlot.eq?.flags?.has('F_MODULAR_ARMOR')) return; - if (critSlot.destroyed) return; - const canApply = Math.min(valueToApply, 10 - (critSlot.consumed || 0)); - critSlot.consumed = (critSlot.consumed || 0) + canApply; - valueToApply -= canApply; - availableModularArmorPoints -= canApply; - consumedModularArmorPoints += canApply; - unit.setCritSlot(critSlot); - }); + valueToApply -= unit.addModularArmorHits(loc, valueToApply); } else if (consumedModularArmorPoints > 0 && valueToApply < 0) { - unit.getCritSlotsAsMatrix()[loc]?.forEach(critSlot => { - const armorPointsToRepair = Math.min(-valueToApply, unit.getArmorHits(loc, rear)); - unit.addArmorHits(loc, -armorPointsToRepair, rear, this.consolidateImmediately); - valueToApply += armorPointsToRepair; - if (valueToApply == 0) return; - if (!critSlot.eq?.flags?.has('F_MODULAR_ARMOR')) return; - if (critSlot.destroyed) return; - const canApply = Math.min(-valueToApply, critSlot.consumed || 0); - critSlot.consumed = (critSlot.consumed || 0) - canApply; - valueToApply += canApply; - availableModularArmorPoints += canApply; - consumedModularArmorPoints -= canApply; - unit.setCritSlot(critSlot); - }); + const armorPointsToRepair = Math.min(-valueToApply, unit.getArmorHits(loc, rear)); + unit.addArmorHits(loc, -armorPointsToRepair, rear, this.consolidateImmediately); + valueToApply += armorPointsToRepair; + if (valueToApply < 0) { + valueToApply -= unit.addModularArmorHits(loc, valueToApply); + } } if (valueToApply != 0) { if (valueToApply > 0 && !isShield && internalPoints > 0) { @@ -820,6 +800,7 @@ export class SvgInteractionService { unit.addArmorHits(loc, valueToApply, rear, this.consolidateImmediately); } } + refreshModularArmor(); } if (loc === 'RO' && value !== 0) { this.applyVtolRotorHitDelta(unit, value > 0 ? 1 : -1, svg.getElementById('rotor_hits_group') as SVGElement | null); diff --git a/src/app/models/cbt-force-unit.model.spec.ts b/src/app/models/cbt-force-unit.model.spec.ts index 0aa9c93b7..aaa15aff7 100644 --- a/src/app/models/cbt-force-unit.model.spec.ts +++ b/src/app/models/cbt-force-unit.model.spec.ts @@ -1994,19 +1994,59 @@ describe('CBTForceUnit direct inventory ammo bins', () => { structureType: 'Hybrid', patchworkLayout: { CT: { type: 0, clan: false }, - LA: { type: 25, clan: false }, + LA: { type: 4, clan: false }, + LT: { type: 25, clan: false }, }, hybridLayout: { CT: { type: 0, clan: false }, LA: { type: 5, clan: false }, }, }); + forceUnit.locations = { + armor: new Map([['LA', { loc: 'LA', rear: false, points: 20 }]]), + internal: new Map([['LA', { loc: 'LA', points: 5 }]]), + }; expect(forceUnit.getArmorTypeAt('CT')).toBe('STANDARD'); - expect(forceUnit.getArmorTypeAt('LA')).toBe('IMPACT_RESISTANT'); + expect(forceUnit.getArmorTypeAt('LA')).toBe('HARDENED'); + expect(forceUnit.getArmorTypeAt('LT')).toBe('IMPACT_RESISTANT'); + expect(forceUnit.hasArmorType('HARDENED')).toBeTrue(); expect(forceUnit.hasArmorType('IMPACT_RESISTANT')).toBeTrue(); expect(forceUnit.getStructureKindAt('CT')).toBe('standard'); expect(forceUnit.getStructureKindAt('LA')).toBe('composite'); + + forceUnit.addArmorHits('LA', 1); + expect(forceUnit.turnState().dmgReceived()).toBe(0); + forceUnit.addArmorHits('LA', 1); + expect(forceUnit.turnState().dmgReceived()).toBe(1); + }); + + it('owns modular armor state and damage accounting across multiple slots', () => { + const { forceUnit } = createCriticalHeatSinkForceUnit(); + const modularArmor = new MiscEquipment({ + id: 'test-modular-armor', + name: 'Modular Armor', + type: 'misc', + flags: ['F_MODULAR_ARMOR'], + }); + forceUnit.setCritSlots([0, 1].map(slot => ({ + id: `modular-armor-${slot}`, + name: modularArmor.name, + loc: 'LT', + slot, + hits: 0, + eq: modularArmor, + })), true); + + expect(forceUnit.addModularArmorHits('LT', 15)).toBe(15); + expect(forceUnit.getModularArmorState('LT')).toEqual({ hits: 15, points: 20, remaining: 5 }); + expect(forceUnit.getCritSlots().map(slot => slot.consumed)).toEqual([10, 5]); + expect(forceUnit.turnState().dmgReceived()).toBe(15); + + expect(forceUnit.addModularArmorHits('LT', -12)).toBe(-12); + expect(forceUnit.getModularArmorState('LT')).toEqual({ hits: 3, points: 20, remaining: 17 }); + expect(forceUnit.getCritSlots().map(slot => slot.consumed)).toEqual([0, 3]); + expect(forceUnit.turnState().dmgReceived()).toBe(3); }); it('automatically floods armorless submerged locations based on posture', () => { @@ -2159,6 +2199,12 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(composite.turnState().dmgReceived()).toBe(1); + const sequentialComposite = createCriticalHeatSinkForceUnit().forceUnit; + spyOn(sequentialComposite, 'getStructureKindAt').and.returnValue('composite'); + sequentialComposite.addInternalHits('LT', 1); + sequentialComposite.addInternalHits('LT', 1); + expect(sequentialComposite.turnState().dmgReceived()).toBe(1); + const reinforced = createCriticalHeatSinkForceUnit().forceUnit; spyOn(reinforced, 'getStructureKindAt').and.returnValue('reinforced'); reinforced.locations!.internal.set('LT', { loc: 'LT', points: 10 }); diff --git a/src/app/models/cbt-force-unit.model.ts b/src/app/models/cbt-force-unit.model.ts index 67e99d775..f9ecef56f 100644 --- a/src/app/models/cbt-force-unit.model.ts +++ b/src/app/models/cbt-force-unit.model.ts @@ -33,7 +33,8 @@ import type { PSRCheck, UnitTypeRules } from './rules/unit-type-rules'; import { type InventoryControlRuntimeAmmoSelection, type InventoryControlRuntimeEntryState, type InventoryControlRuntimeRangeKey, type InventoryControlRuntimeSnapshot, type InventoryControlRuntimeTarget, type InventoryControlRuntimeTargetId } from './inventory-control-runtime-state.model'; import { CBTInventoryControlRuntime } from './cbt-inventory-control-runtime.model'; import { getMekLegLocations, getMekLocationParent, inferMekConfigFromLocations, MEK_REAR_ARMOR_LOCATIONS, type ArmorType } from './entity/types'; -import { mekStructurePhaseDamage, MEK_STRUCTURE_TYPE, type MekStructureKind } from '../utils/mek-structure-damage.util'; +import { mekStructureDamageReceived, MEK_STRUCTURE_TYPE, type MekStructureKind } from '../utils/mek-structure-damage.util'; +import { resolveMekFallArmorDamage, type MekFallArmorDamageResolution } from '../utils/mek-falling.util'; import { ARMOR_TYPE_FROM_BLK_CODE } from './entity/parsers/blk-codec'; import { createHandlerQueryContext, @@ -98,10 +99,18 @@ export interface CBTInternalDamageContext { readonly hardenedArmorApplies?: boolean; /** Critical explosions retain the pilot-damage event that produced the internal hit. */ readonly pilotDamageGroup?: string; + /** The first composite pip shares a damage point already counted in the previous location. */ + readonly sharedCompositePip?: boolean; /** Hit-table arc for a possible through-armor critical. */ readonly throughArmorHitArc?: MekHitArc; } +export interface CBTModularArmorState { + readonly hits: number; + readonly points: number; + readonly remaining: number; +} + export interface CBTMekFallDamageRoll { readonly hitLocationDice: readonly [number, number] | null; readonly tripodLegRoll: number | null; @@ -830,10 +839,51 @@ export class CBTForceUnit extends ForceUnit { hits: number, rear?: boolean, consolidateImmediately: boolean = false, - damageReceived?: number, - ) { + ): number { + const armorPoints = this.getArmorPoints(loc, rear); + const previousHits = Math.min(armorPoints, Math.max(0, this.getArmorHits(loc, rear))); + const currentHits = Math.min(armorPoints, Math.max(0, previousHits + hits)); + const damageReceived = this.getArmorTypeAt(loc) === 'HARDENED' + ? Math.floor(currentHits / 2) - Math.floor(previousHits / 2) + : currentHits - previousHits; + this.recordArmorHits(loc, hits, rear, consolidateImmediately, damageReceived); + return damageReceived; + } + + /** Applies one physical non-attack hit using the selected ruleset and records its exact phase damage. */ + applyMekFallArmorDamage( + loc: string, + damage: number, + rear: boolean, + consolidateImmediately: boolean, + ): MekFallArmorDamageResolution { + const remainingArmor = Math.max(0, this.getArmorPoints(loc, rear) - this.getArmorHits(loc, rear)); + const resolution = resolveMekFallArmorDamage( + this.gameRules.id, + damage, + remainingArmor, + this.getArmorTypeAt(loc), + ); + if (resolution.armorDamage > 0) { + this.recordArmorHits( + loc, + resolution.armorDamage, + rear, + consolidateImmediately, + resolution.appliedDamage, + ); + } + return resolution; + } + + private recordArmorHits( + loc: string, + hits: number, + rear: boolean | undefined, + consolidateImmediately: boolean, + damageReceived: number, + ): void { const locKey = rear ? `${loc}-rear` : loc; - const previousHits = this.getArmorHits(loc, rear); const locations = { ...this.state.locations() }; if (locations[locKey] === undefined) { @@ -845,10 +895,7 @@ export class CBTForceUnit extends ForceUnit { locations[locKey].pendingArmor += hits; this.state.locations.set({ ...this.state.locations(), [locKey]: locations[locKey] }); this.markEquipmentLocationsChanged(); - this.state.turnState().addDmgReceived(damageReceived - ?? (this.getArmorTypeAt(loc) === 'HARDENED' - ? Math.floor(this.getArmorHits(loc, rear) / 2) - Math.floor(previousHits / 2) - : hits)); + this.state.turnState().addDmgReceived(damageReceived); if (consolidateImmediately) this.state.consolidateLocations(); else this.applyUnderwaterBreachAndFlooding(); this.evaluateDestroyed(); @@ -870,6 +917,43 @@ export class CBTForceUnit extends ForceUnit { this.setModified(); } + getModularArmorState(loc: string): CBTModularArmorState { + let hits = 0; + let points = 0; + for (const slot of this.getCritSlots()) { + if (slot.loc !== loc || slot.destroyed || !slot.eq?.flags?.has('F_MODULAR_ARMOR')) continue; + points += 10; + hits += Math.min(10, Math.max(0, slot.consumed ?? 0)); + } + return { hits, points, remaining: points - hits }; + } + + /** Applies or repairs modular armor and returns the signed record change actually made. */ + addModularArmorHits(loc: string, hits: number): number { + if (!Number.isFinite(hits)) return 0; + let remaining = Math.abs(Math.trunc(hits)); + if (remaining === 0) return 0; + const applyingDamage = hits > 0; + const crits = [...this.getCritSlots()]; + + for (let index = 0; index < crits.length && remaining > 0; index++) { + const slot = crits[index]; + if (slot.loc !== loc || slot.destroyed || !slot.eq?.flags?.has('F_MODULAR_ARMOR')) continue; + const consumed = Math.min(10, Math.max(0, slot.consumed ?? 0)); + const change = Math.min(remaining, applyingDamage ? 10 - consumed : consumed); + if (change === 0) continue; + crits[index] = { ...slot, consumed: consumed + (applyingDamage ? change : -change) }; + remaining -= change; + } + + const applied = Math.abs(Math.trunc(hits)) - remaining; + if (applied === 0) return 0; + const signedApplied = applyingDamage ? applied : -applied; + this.setCritSlots(crits); + this.state.turnState().addDmgReceived(signedApplied); + return signedApplied; + } + getInternalPoints(loc: string): number { return this.locations?.internal.get(loc)?.points || 0; } @@ -917,9 +1001,15 @@ export class CBTForceUnit extends ForceUnit { hits: number, consolidateImmediately: boolean = false, context: CBTInternalDamageContext = {}, - ) { + ): number { const previousHits = this.getInternalHits(loc); const internalPoints = this.getInternalPoints(loc); + const structureKind = this.getStructureKindAt(loc); + const previousDamageReceived = mekStructureDamageReceived( + internalPoints, + Math.min(internalPoints, Math.max(0, previousHits)), + structureKind, + ); const locations = { ...this.state.locations() }; if (locations[loc] === undefined) { locations[loc] = {}; @@ -942,18 +1032,15 @@ export class CBTForceUnit extends ForceUnit { const boundedPreviousHits = Math.min(internalPoints, Math.max(0, previousHits)); const boundedCurrentHits = Math.min(internalPoints, Math.max(0, this.getInternalHits(loc))); const appliedDamage = boundedCurrentHits - boundedPreviousHits; - const structureKind = this.getStructureKindAt(loc); - const phaseDamage = appliedDamage >= 0 - ? mekStructurePhaseDamage( - appliedDamage, - internalPoints - boundedPreviousHits, + let phaseDamage = mekStructureDamageReceived(internalPoints, boundedCurrentHits, structureKind) + - previousDamageReceived; + if (context.sharedCompositePip && structureKind === 'composite' && appliedDamage > 0) { + phaseDamage -= mekStructureDamageReceived( + internalPoints, + Math.min(boundedCurrentHits, boundedPreviousHits + 1), structureKind, - ) - : -mekStructurePhaseDamage( - -appliedDamage, - internalPoints - boundedCurrentHits, - structureKind, - ); + ) - previousDamageReceived; + } this.state.turnState().addDmgReceived(phaseDamage); // A single assignment is one hit/event, regardless of how many structure pips it marks. if (appliedDamage > 0) this.queueMekCriticalChance(loc, { @@ -961,6 +1048,7 @@ export class CBTForceUnit extends ForceUnit { locationDestroyed: boundedCurrentHits >= internalPoints, consolidateImmediately, }); + return phaseDamage; } setInternalHits(loc: string, hits: number) { diff --git a/src/app/models/unit-cover.model.spec.ts b/src/app/models/unit-cover.model.spec.ts index 465e6a0cc..c86fb991d 100644 --- a/src/app/models/unit-cover.model.spec.ts +++ b/src/app/models/unit-cover.model.spec.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { deserializeUnitCover, resolveUnitBuildingCoverState, resolveUnitWaterState, serializeUnitCover } from './unit-cover.model'; +import { deserializeUnitCover, resolveUnitBuildingCoverState, resolveUnitWaterState, serializeUnitCover, unitCoverWaterDepth } from './unit-cover.model'; describe('unit cover', () => { it('serializes water depths as 3, 4, and 5', () => { @@ -32,6 +32,14 @@ describe('unit cover', () => { expect(resolveUnitWaterState('underwater-depth-3', 3)).toEqual({ partiallyUnderwater: false, submerged: true }); }); + it('derives numeric water depth from cover', () => { + expect(unitCoverWaterDepth(undefined)).toBe(0); + expect(unitCoverWaterDepth('heavy')).toBe(0); + expect(unitCoverWaterDepth('underwater-depth-1')).toBe(1); + expect(unitCoverWaterDepth('underwater-depth-2')).toBe(2); + expect(unitCoverWaterDepth('underwater-depth-3')).toBe(3); + }); + it('resolves building cover from unit height and posture', () => { expect(resolveUnitBuildingCoverState('building-1', 1)).toEqual({ effect: 'heavy', modifier: 2 }); expect(resolveUnitBuildingCoverState('building-2', 1)).toEqual({ effect: 'heavy', modifier: 2 }); diff --git a/src/app/models/unit-cover.model.ts b/src/app/models/unit-cover.model.ts index 82419308d..66abdc4bc 100644 --- a/src/app/models/unit-cover.model.ts +++ b/src/app/models/unit-cover.model.ts @@ -65,6 +65,10 @@ export function unitWaterDepthNumber(depth: UnitWaterDepth): 1 | 2 | 3 { return WATER_DEPTH_NUMBER[depth]; } +export function unitCoverWaterDepth(cover: UnitCover | undefined): 0 | 1 | 2 | 3 { + return isUnitWaterDepth(cover) ? unitWaterDepthNumber(cover) : 0; +} + export function isUnitBuildingLevel(cover: unknown): cover is UnitBuildingLevel { return cover === 'building-1' || cover === 'building-2' || cover === 'building-3'; } diff --git a/src/app/services/falling-resolution.service.spec.ts b/src/app/services/falling-resolution.service.spec.ts index 0cd6c3a6e..1917870a5 100644 --- a/src/app/services/falling-resolution.service.spec.ts +++ b/src/app/services/falling-resolution.service.spec.ts @@ -10,6 +10,7 @@ import { } from '../components/falling-damage-dialog/falling-damage-dialog.component'; import { FallingNoticeDialogComponent } from '../components/falling-notice-dialog/falling-notice-dialog.component'; import type { CBTForceUnit, CBTMekFallDamageRoll } from '../models/cbt-force-unit.model'; +import { resolveMekFallArmorDamage } from '../utils/mek-falling.util'; import { CBTAutomationService } from './cbt-automation.service'; import { DialogsService } from './dialogs.service'; import { FallingResolutionService } from './falling-resolution.service'; @@ -66,7 +67,7 @@ describe('FallingResolutionService', () => { }, }, ); - expect(harness.addArmorHits).toHaveBeenCalledWith('HD', 5, false, false, 5); + expect(harness.addArmorHits).toHaveBeenCalledWith('HD', 5, false, false); expect(harness.applyHeadHitCrewHits).toHaveBeenCalledTimes(1); expect(harness.completePendingFall).toHaveBeenCalledOnceWith('fall:1'); expect(showToast).toHaveBeenCalledWith( @@ -136,7 +137,7 @@ describe('FallingResolutionService', () => { closed.complete(); await operation; - expect(harness.addArmorHits).toHaveBeenCalledOnceWith('HD', 5, false, false, 5); + expect(harness.addArmorHits).toHaveBeenCalledOnceWith('HD', 5, false, false); expect(resolveAutomation.calls.allArgs().map(args => args[0])).toEqual([ 'pilotHitsAndConsciousnessCheck', ]); @@ -184,7 +185,7 @@ describe('FallingResolutionService', () => { closed.complete(); await operation; - expect(harness.addArmorHits).toHaveBeenCalledOnceWith('HD', 5, false, false, 5); + expect(harness.addArmorHits).toHaveBeenCalledOnceWith('HD', 5, false, false); expect(harness.applyHeadHitCrewHits).not.toHaveBeenCalled(); expect(harness.completePendingFall).toHaveBeenCalledOnceWith('fall:1'); }); @@ -293,6 +294,7 @@ function createUnit( const armorHits = new Map(); const addArmorHits = jasmine.createSpy('addArmorHits').and.callFake((location: string, hits: number) => { armorHits.set(location, (armorHits.get(location) ?? 0) + hits); + return hits; }); const applyHeadHitCrewHits = jasmine.createSpy('applyHeadHitCrewHits').and.returnValue(1); const pendingFalls: Array<{ @@ -323,6 +325,7 @@ function createUnit( const unit = { id: 'unit:test-mek', gameRules: { id: 'core2026', aggregatedEndPhaseConsciousRolls: true }, + turnState: () => ({ cover: () => undefined }), locations: { internal: new Map([['HD', { loc: 'HD' }], ['CT', { loc: 'CT' }]]) }, getUnit: () => ({ type: 'Mek', @@ -357,10 +360,29 @@ function createUnit( getArmorTypeAt: () => 'STANDARD', hasArmorType: () => false, addArmorHits, + getModularArmorState: () => ({ hits: 0, points: 0, remaining: 0 }), + addModularArmorHits: () => 0, + applyMekFallArmorDamage: ( + location: string, + damage: number, + rear: boolean, + consolidateImmediately: boolean, + ) => { + const resolution = resolveMekFallArmorDamage( + 'core2026', + damage, + (location === 'HD' ? 9 : 10) - (armorHits.get(location) ?? 0), + 'STANDARD', + ); + if (resolution.armorDamage > 0) { + addArmorHits(location, resolution.armorDamage, rear, consolidateImmediately); + } + return resolution; + }, getInternalPoints: () => 10, getInternalHits: () => 0, getStructureKindAt: () => 'standard', - addInternalHits: jasmine.createSpy('addInternalHits'), + addInternalHits: jasmine.createSpy('addInternalHits').and.callFake((_location: string, hits: number) => hits), queueMekCriticalChance: jasmine.createSpy('queueMekCriticalChance'), applyHeadHitCrewHits, } as unknown as CBTForceUnit; diff --git a/src/app/services/falling-resolution.service.ts b/src/app/services/falling-resolution.service.ts index 687beeffb..4bf9eb5aa 100644 --- a/src/app/services/falling-resolution.service.ts +++ b/src/app/services/falling-resolution.service.ts @@ -18,11 +18,12 @@ import { import type { AutomationReviewEvent } from '../models/automation-review.model'; import type { CBTForceUnit, CBTMekFallDamageRoll } from '../models/cbt-force-unit.model'; import { getMekLocationLabel } from '../models/entity/types'; +import { unitCoverWaterDepth } from '../models/unit-cover.model'; import { applyMekFallDamage, isResolvedMekFallHitLocation, - mekFallDamage, - mekFallDamageGroups, + resolvedMekFallDamageGroups, + resolveMekFallDamage, resolveMekFallHitLocation, resolveMekFallOrientation, twoD6Total, @@ -141,9 +142,11 @@ export class FallingResolutionService { const pending = unit.getPendingFall(trigger.id); const orientationRoll = pending?.orientationRoll ?? this.rollD6(); const orientation = resolveMekFallOrientation(unit.gameRules.id, orientationRoll); - const damageGroups = mekFallDamageGroups(mekFallDamage( + const damageGroups = resolvedMekFallDamageGroups(resolveMekFallDamage( + unit.gameRules.id, unit.getUnit().tons, trigger.levelsFallen, + unitCoverWaterDepth(unit.turnState().cover()), )); const hitLocationTable = clusterTableForUnit(unit.getUnit()).hitLocationTable ?? 'biped'; const damageRolls: CBTMekFallDamageRoll[] = []; diff --git a/src/app/utils/mek-critical-hit.util.spec.ts b/src/app/utils/mek-critical-hit.util.spec.ts index 8542e354e..0f098257c 100644 --- a/src/app/utils/mek-critical-hit.util.spec.ts +++ b/src/app/utils/mek-critical-hit.util.spec.ts @@ -387,6 +387,24 @@ describe('Mek critical-hit workflow', () => { expect(armorHits.get('CT-rear')).toBe(12); }); + it('shares the final odd Core composite pip across an un-CASED explosion transfer', () => { + const fixture = explodingAmmoUnit(CORE_2026_GAME_RULES, 'composite'); + fixture.internalHits.set('LT', 1); + + const outcome = applyMekCriticalRoll(fixture.unit, 'LT', [1, 1], true); + + expect(outcome?.explosion?.locations.map(location => ({ + location: location.location, + internalDamage: location.internalDamage, + sharedCompositePip: location.sharedCompositePip, + }))).toEqual([ + { location: 'LT', internalDamage: 11, sharedCompositePip: undefined }, + { location: 'CT', internalDamage: 29, sharedCompositePip: true }, + ]); + expect(fixture.internalHits.get('LT')).toBe(12); + expect(fixture.internalHits.get('CT')).toBe(29); + }); + it('uses TW damage and transfers all uncased explosion overflow', () => { const fixture = explodingAmmoUnit(TW_GAME_RULES); diff --git a/src/app/utils/mek-critical-hit.util.ts b/src/app/utils/mek-critical-hit.util.ts index 714a302da..8fb261057 100644 --- a/src/app/utils/mek-critical-hit.util.ts +++ b/src/app/utils/mek-critical-hit.util.ts @@ -53,6 +53,8 @@ export interface MekExplosionLocationDamage { readonly armorDamage: number; readonly armorRear: boolean; readonly protection: MekExplosionProtection; + /** Core composite structure shares this pip with the preceding location. */ + readonly sharedCompositePip?: boolean; } export interface MekEquipmentExplosionResult { @@ -966,21 +968,25 @@ function resolveMekExplosionLocationDamage( let location: string | null = sourceLocation; let damage = plan.rawDamage; let armorBlowoutPending = false; + let sharedCompositePip = false; - while (location && damage > 0 && !visited.has(location)) { + while (location && (damage > 0 || sharedCompositePip) && !visited.has(location)) { visited.add(location); const protection = getMekExplosionProtection(unit, location); if (unit.gameRules.id === 'core2026' && protection === 'none' && damage > 20) { armorBlowoutPending = true; } const remainingInternal = Math.max(0, unit.getInternalPoints(location) - unit.getInternalHits(location)); + const receivedSharedCompositePip: boolean = sharedCompositePip && remainingInternal > 0; + const sharedInternalDamage: number = receivedSharedCompositePip ? 1 : 0; + sharedCompositePip = false; const structureKind = unit.getStructureKindAt(location); const torso = MEK_TORSO_LOCATIONS.has(location); const remainingArmor = Math.max(0, unit.getArmorPoints(location, torso) - unit.getArmorHits(location, torso)); const resolution = unit.gameRules.resolveMekExplosionDamage({ damage, protection, - remainingInternal: mekStructureDamageCapacity(remainingInternal, structureKind), + remainingInternal: mekStructureDamageCapacity(remainingInternal - sharedInternalDamage, structureKind), remainingArmor, originalArmor: unit.getArmorPoints(location, torso), torso, @@ -989,21 +995,33 @@ function resolveMekExplosionLocationDamage( const armorDamage = Math.min(remainingArmor, resolution.armorDamage); const structureDamage = resolveMekStructureDamage( resolution.internalDamage, - remainingInternal, + remainingInternal - sharedInternalDamage, structureKind, ); + const internalDamage: number = sharedInternalDamage + structureDamage.internalDamage; + const nextLocation: string | null = topology[location as keyof typeof topology]?.transfersTo ?? null; locations.push({ location, - internalDamage: structureDamage.internalDamage, + internalDamage, armorDamage, armorRear: resolution.armorRear, protection, + ...(receivedSharedCompositePip ? { sharedCompositePip: true } : {}), }); const overflow = structureDamage.overflowDamage; - if (overflow === 0 || resolution.stopsTransfer) break; - location = topology[location as keyof typeof topology]?.transfersTo ?? null; + sharedCompositePip = !receivedSharedCompositePip + && unit.gameRules.id === 'core2026' + && protection === 'none' + && remainingInternal % 2 === 1 + && structureKind === 'composite' + && internalDamage === remainingInternal + && !!nextLocation + && unit.getStructureKindAt(nextLocation) === 'composite' + && unit.getInternalPoints(nextLocation) - unit.getInternalHits(nextLocation) > 0; + if ((overflow === 0 && !sharedCompositePip) || resolution.stopsTransfer) break; + location = nextLocation; damage = overflow; } @@ -1034,6 +1052,7 @@ function applyMekEquipmentExplosion( consolidateImmediately, { explosionProtection: damage.protection, + ...(damage.sharedCompositePip ? { sharedCompositePip: true } : {}), ...(pilotDamageGroup && { pilotDamageGroup }), }, ); diff --git a/src/app/utils/mek-falling.util.spec.ts b/src/app/utils/mek-falling.util.spec.ts index 10a5284e7..e86f5fa07 100644 --- a/src/app/utils/mek-falling.util.spec.ts +++ b/src/app/utils/mek-falling.util.spec.ts @@ -8,13 +8,16 @@ import { applyMekFallDamage, mekFallDamage, mekFallDamageGroups, + resolvedMekFallDamageGroups, + resolveMekFallArmorDamage, + resolveMekFallDamage, resolveMekFallHitLocation, resolveMekFallOrientation, twoD6ForTotal, twoD6Total, type ResolvedMekFallDamageGroup, } from './mek-falling.util'; -import type { MekStructureKind } from './mek-structure-damage.util'; +import { mekStructureDamageReceived, type MekStructureKind } from './mek-structure-damage.util'; describe('Mek falling rules', () => { it('keeps Core facing while selecting rear only on an orientation roll of 1', () => { @@ -48,6 +51,17 @@ describe('Mek falling rules', () => { expect(mekFallDamageGroups(18)).toEqual([5, 5, 5, 3]); }); + it('uses the ruleset-specific MegaMek water fall calculation and separate clusters', () => { + const core = resolveMekFallDamage('core2026', 55, 0, 1); + const tw = resolveMekFallDamage('tw', 55, 0, 1); + const twFromHeight = resolveMekFallDamage('tw', 55, 3, 1); + + expect(core).toEqual({ surfaceDamage: 0, waterDamage: 6, totalDamage: 6 }); + expect(tw).toEqual({ surfaceDamage: 0, waterDamage: 3, totalDamage: 3 }); + expect(twFromHeight).toEqual({ surfaceDamage: 12, waterDamage: 6, totalDamage: 18 }); + expect(resolvedMekFallDamageGroups(twFromHeight)).toEqual([5, 5, 2, 5, 1]); + }); + it('derives a 2D6 total from the two persisted dice', () => { expect(twoD6Total([1, 6])).toBe(7); expect(twoD6ForTotal(7)).toEqual([3, 4]); @@ -141,7 +155,7 @@ describe('Mek falling rules', () => { continue; } const harness = createDamageHarness({ - armor: { [testCase.location]: 0, [testCase.torso]: 10 }, + armor: { [testCase.location]: 0, [`${testCase.torso}-rear`]: 10 }, internal: { FLL: 5, FRL: 5, RLL: 5, RRL: 5, LT: 10, RT: 10, CT: 10, HD: 3 }, initialInternalHits: { [testCase.location]: 5 }, }); @@ -154,7 +168,7 @@ describe('Mek falling rules', () => { }], false); expect(resolved.location).withContext(`${testCase.arc}:${testCase.roll}`).toBe(testCase.location); - expect(harness.armorHits.get(testCase.torso)).withContext(testCase.location).toBe(5); + expect(harness.armorHits.get(`${testCase.torso}-rear`)).withContext(testCase.location).toBe(5); expect(result.locations.map(entry => entry.location)) .withContext(testCase.location) .toEqual([testCase.location, testCase.torso]); @@ -187,6 +201,20 @@ describe('Mek falling rules', () => { expect(result.appliedDamage).toBe(1); }); + it('uses Total Warfare Impact-Resistant Armor reduction', () => { + const harness = createDamageHarness({ + rulesId: 'tw', + armorType: 'IMPACT_RESISTANT', + armor: { CT: 10 }, + internal: { CT: 10 }, + }); + + const result = applyMekFallDamage(harness.unit, [group('CT', 5)], true); + + expect(harness.armorHits.get('CT')).toBe(4); + expect(result.appliedDamage).toBe(4); + }); + it('re-evaluates patchwork armor when damage transfers to another location', () => { const harness = createDamageHarness({ armorTypes: { LA: 'STANDARD', LT: 'IMPACT_RESISTANT' }, @@ -214,7 +242,7 @@ describe('Mek falling rules', () => { expect(reflective.armorHits.get('CT')).toBe(10); }); - it('doubles a physical hit even when only one point of Reflective Armor remains', () => { + it('does not invent a second damage point when only one point of Reflective Armor remains', () => { const harness = createDamageHarness({ armorType: 'REFLECTIVE', armor: { CT: 1 }, internal: { CT: 10 }, }); @@ -223,7 +251,19 @@ describe('Mek falling rules', () => { expect(harness.armorHits.get('CT')).toBe(1); expect(harness.internalHits.get('CT')).toBeUndefined(); - expect(result.appliedDamage).toBe(2); + expect(result.appliedDamage).toBe(1); + }); + + it('uses MegaMek reflective accounting when physical damage penetrates', () => { + const harness = createDamageHarness({ + armorType: 'REFLECTIVE', armor: { CT: 9 }, internal: { CT: 10 }, + }); + + const result = applyMekFallDamage(harness.unit, [group('CT', 6)], false); + + expect(harness.armorHits.get('CT')).toBe(9); + expect(harness.internalHits.get('CT')).toBe(1); + expect(result.appliedDamage).toBe(11); }); it('stores Hardened Armor half-pips as integer armor damage', () => { @@ -264,10 +304,83 @@ describe('Mek falling rules', () => { expect(harness.internalHits.get('LA')).toBe(3); expect(harness.armorHits.get('LT')).toBeUndefined(); - expect(result.appliedDamage).toBe(3); + expect(result.appliedDamage).toBe(2); expect(result.locations).toHaveSize(1); }); + it('shares the final Core composite pip with the next unarmored composite location', () => { + const core = createDamageHarness({ + armor: { LA: 0, LT: 0 }, + internal: { LA: 3, LT: 4, CT: 10 }, + initialInternalHits: { LA: 2 }, + structureKinds: { LA: 'composite', LT: 'composite' }, + }); + + const result = applyMekFallDamage(core.unit, [group('LA', 1)], false); + + expect(core.internalHits.get('LA')).toBe(3); + expect(core.internalHits.get('LT')).toBe(1); + expect(result.appliedDamage).toBe(1); + expect(result.locations.map(entry => entry.location)).toEqual(['LA', 'LT']); + + const tw = createDamageHarness({ + rulesId: 'tw', + armor: { LA: 0, LT: 0 }, + internal: { LA: 3, LT: 4, CT: 10 }, + initialInternalHits: { LA: 2 }, + structureKinds: { LA: 'composite', LT: 'composite' }, + }); + applyMekFallDamage(tw.unit, [group('LA', 1)], false); + expect(tw.internalHits.get('LT')).toBeUndefined(); + }); + + it('shares the unused half of a multi-point Core composite hit', () => { + const harness = createDamageHarness({ + armor: { LA: 0, LT: 0 }, + internal: { LA: 3, LT: 4, CT: 10 }, + structureKinds: { LA: 'composite', LT: 'composite' }, + }); + + const result = applyMekFallDamage(harness.unit, [group('LA', 2)], false); + + expect(harness.internalHits.get('LA')).toBe(3); + expect(harness.internalHits.get('LT')).toBe(1); + expect(result.appliedDamage).toBe(2); + }); + + it('consumes modular armor before location armor', () => { + const harness = createDamageHarness({ + armor: { CT: 10 }, + internal: { CT: 10 }, + modularArmor: { CT: 3 }, + }); + + const result = applyMekFallDamage(harness.unit, [group('CT', 5)], false); + + expect(harness.modularArmorHits.get('CT')).toBe(3); + expect(harness.armorHits.get('CT')).toBe(2); + expect(result.locations[0]).toEqual(jasmine.objectContaining({ + modularArmorDamage: 3, + armorDamage: 2, + appliedDamage: 5, + })); + }); + + it('does not turn damage stopped by modular armor into a head hit or table critical', () => { + const harness = createDamageHarness({ + armor: { HD: 9 }, + internal: { HD: 3, CT: 10 }, + modularArmor: { HD: 5 }, + }); + + const result = applyMekFallDamage(harness.unit, [group('HD', 5, false, true)], false); + + expect(result.appliedDamage).toBe(5); + expect(result.headHits).toBe(0); + expect(harness.armorHits.get('HD')).toBeUndefined(); + expect(harness.queueMekCriticalChance).not.toHaveBeenCalled(); + }); + it('queues a table critical in addition to applying internal damage', () => { const harness = createDamageHarness({ armor: { CT: 0 }, @@ -319,6 +432,15 @@ describe('Mek falling rules', () => { expect(harness.armorHits.get('CT')).toBe(5); expect(harness.queueMekCriticalChance).not.toHaveBeenCalled(); + + const tw = createDamageHarness({ + rulesId: 'tw', + armorType: 'ANTI_PENETRATIVE_ABLATION', + armor: { CT: 10 }, + internal: { CT: 10 }, + }); + applyMekFallDamage(tw.unit, [group('CT', 5, false, true)], false); + expect(tw.queueMekCriticalChance).toHaveBeenCalled(); }); }); @@ -339,6 +461,8 @@ function createDamageHarness(options: { armor: Readonly>; internal: Readonly>; initialInternalHits?: Readonly>; + modularArmor?: Readonly>; + rulesId?: 'core2026' | 'tw'; armorType?: ArmorType; armorTypes?: Readonly>; structureKinds?: Readonly>; @@ -346,17 +470,35 @@ function createDamageHarness(options: { unit: CBTForceUnit; armorHits: Map; internalHits: Map; + modularArmorHits: Map; addInternalHits: jasmine.Spy; queueMekCriticalChance: jasmine.Spy; } { const armorHits = new Map(); const internalHits = new Map(Object.entries(options.initialInternalHits ?? {})); + const modularArmorHits = new Map(); const armorKey = (location: string, rear = false) => rear ? `${location}-rear` : location; - const addInternalHits = jasmine.createSpy('addInternalHits').and.callFake((location: string, hits: number) => { - internalHits.set(location, (internalHits.get(location) ?? 0) + hits); + const addInternalHits = jasmine.createSpy('addInternalHits').and.callFake(( + location: string, + hits: number, + _consolidateImmediately: boolean, + context: { sharedCompositePip?: boolean } = {}, + ) => { + const previous = internalHits.get(location) ?? 0; + const current = previous + hits; + internalHits.set(location, current); + const kind = options.structureKinds?.[location] ?? 'standard'; + const points = options.internal[location] ?? 0; + const previousDamage = mekStructureDamageReceived(points, previous, kind); + let damage = mekStructureDamageReceived(points, current, kind) - previousDamage; + if (context.sharedCompositePip && kind === 'composite') { + damage -= mekStructureDamageReceived(points, previous + 1, kind) - previousDamage; + } + return damage; }); const queueMekCriticalChance = jasmine.createSpy('queueMekCriticalChance').and.returnValue(true); const unit = { + gameRules: { id: options.rulesId ?? 'core2026' }, locations: { internal: new Map(Object.keys(options.internal).map(location => [location, { loc: location }])) }, getUnit: () => ({ type: 'Mek', subtype: 'BattleMek' }), getArmorTypeAt: (location: string) => options.armorTypes?.[location] @@ -365,6 +507,28 @@ function createDamageHarness(options: { getStructureKindAt: (location: string) => options.structureKinds?.[location] ?? 'standard', getArmorPoints: (location: string, rear = false) => options.armor[armorKey(location, rear)] ?? 0, getArmorHits: (location: string, rear = false) => armorHits.get(armorKey(location, rear)) ?? 0, + getModularArmorState: (location: string) => { + const points = options.modularArmor?.[location] ?? 0; + const hits = modularArmorHits.get(location) ?? 0; + return { hits, points, remaining: points - hits }; + }, + addModularArmorHits: (location: string, hits: number) => { + const points = options.modularArmor?.[location] ?? 0; + const previous = modularArmorHits.get(location) ?? 0; + const applied = Math.min(Math.max(0, hits), points - previous); + if (applied > 0) modularArmorHits.set(location, previous + applied); + return applied; + }, + applyMekFallArmorDamage: (location: string, damage: number, rear = false) => { + const key = armorKey(location, rear); + const remaining = (options.armor[key] ?? 0) - (armorHits.get(key) ?? 0); + const armorType = options.armorTypes?.[location] ?? options.armorType ?? 'STANDARD'; + const resolution = resolveMekFallArmorDamage(options.rulesId ?? 'core2026', damage, remaining, armorType); + if (resolution.armorDamage > 0) { + armorHits.set(key, (armorHits.get(key) ?? 0) + resolution.armorDamage); + } + return resolution; + }, addArmorHits: (location: string, hits: number, rear = false) => { const key = armorKey(location, rear); armorHits.set(key, (armorHits.get(key) ?? 0) + hits); @@ -374,5 +538,5 @@ function createDamageHarness(options: { addInternalHits, queueMekCriticalChance, } as unknown as CBTForceUnit; - return { unit, armorHits, internalHits, addInternalHits, queueMekCriticalChance }; + return { unit, armorHits, internalHits, modularArmorHits, addInternalHits, queueMekCriticalChance }; } diff --git a/src/app/utils/mek-falling.util.ts b/src/app/utils/mek-falling.util.ts index b281fc21a..715c8e333 100644 --- a/src/app/utils/mek-falling.util.ts +++ b/src/app/utils/mek-falling.util.ts @@ -45,7 +45,9 @@ export interface ResolvedMekFallDamageGroup extends MekFallHitLocationResult { export interface AppliedMekFallLocationDamage { readonly location: string; + /** The hit came from the rear arc; only torso locations have rear armor. */ readonly rear: boolean; + readonly modularArmorDamage: number; readonly armorDamage: number; readonly internalDamage: number; /** Rule-adjusted damage points consumed at this location. */ @@ -124,11 +126,54 @@ export function resolveMekFallOrientation(rulesId: MekFallRulesId, roll: number) /** Damage before terrain or armor-specific reductions. */ export function mekFallDamage(tons: number, levelsFallen = 0): number { - const normalizedTons = Number.isFinite(tons) ? Math.max(0, tons) : 0; + const weightDamage = mekFallWeightDamage(tons); const normalizedLevels = Number.isFinite(levelsFallen) ? Math.max(0, Math.trunc(levelsFallen)) : 0; - return Math.ceil(normalizedTons / 10) * (normalizedLevels + 1); + return weightDamage * (normalizedLevels + 1); +} + +export interface MekFallDamageBreakdown { + readonly surfaceDamage: number; + readonly waterDamage: number; + readonly totalDamage: number; +} + +/** MegaMek-compatible surface/bottom damage for a fall into the current water depth. */ +export function resolveMekFallDamage( + rulesId: MekFallRulesId, + tons: number, + levelsFallen = 0, + waterDepth = 0, +): MekFallDamageBreakdown { + const levels = normalizedInteger(levelsFallen); + const depth = normalizedInteger(waterDepth); + if (depth === 0) { + const surfaceDamage = mekFallDamage(tons, levels); + return { surfaceDamage, waterDamage: 0, totalDamage: surfaceDamage }; + } + + const weightDamage = mekFallWeightDamage(tons); + if (rulesId === 'core2026') { + const waterDamage = Math.floor(weightDamage * (levels + depth + 1) / 2); + return { surfaceDamage: 0, waterDamage, totalDamage: waterDamage }; + } + + let surfaceDamage = Math.floor(mekFallDamage(tons, levels) / 2); + let waterDamage = Math.floor(weightDamage * (depth + 1) / 2); + if (depth >= levels) { + surfaceDamage = 0; + waterDamage = Math.floor(weightDamage * (levels + 1) / 2); + } + return { surfaceDamage, waterDamage, totalDamage: surfaceDamage + waterDamage }; +} + +/** Preserves MegaMek's independent five-point clustering for surface and bottom impacts. */ +export function resolvedMekFallDamageGroups(damage: MekFallDamageBreakdown): readonly number[] { + return [ + ...mekFallDamageGroups(damage.surfaceDamage), + ...mekFallDamageGroups(damage.waterDamage), + ]; } /** Splits a fall into independently located groups of at most 5 damage. */ @@ -188,7 +233,8 @@ export function resolveMekFallHitLocation( tableLabel: cell.tableLabel, location, locationLabel: getMekLocationLabel(location ?? undefined), - rear: arc === 'rear' && location !== null && MEK_TORSO_LOCATIONS.has(location), + // Preserve the rolled arc through transfer; only torso locations select rear armor. + rear: arc === 'rear', critical: cell.critical, ...(tripodLegRoll !== undefined && tripodLeg ? { tripodLegRoll } : {}), ...(cell.tripodLegModifier !== undefined ? { tripodLegModifier: cell.tripodLegModifier } : {}), @@ -231,65 +277,92 @@ export function applyMekFallDamage( const originalArmorType = unit.getArmorTypeAt(group.location); const visited = new Set(); let groupDamaged = false; + let sharedCompositePip = false; - while (location && damage > 0 && !visited.has(location)) { + while (location && (damage > 0 || sharedCompositePip) && !visited.has(location)) { visited.add(location); const rear = group.rear && MEK_TORSO_LOCATIONS.has(location); + const nextLocation: string | null = topology[location as keyof typeof topology]?.transfersTo ?? null; + let modularArmorDamage = 0; + let armorDamage = 0; + let internalDamage = 0; + let locationAppliedDamage = 0; + + if (damage > 0) { + modularArmorDamage = unit.addModularArmorHits(location, damage); + damage -= modularArmorDamage; + appliedDamage += modularArmorDamage; + locationAppliedDamage += modularArmorDamage; + } + const remainingArmor = Math.max(0, unit.getArmorPoints(location, rear) - unit.getArmorHits(location, rear)); const armorType = unit.getArmorTypeAt(location); - const armor = resolveMekFallArmorDamage( - damage, - remainingArmor, - armorType, - ); - const armorDamage = armor.armorDamage; - if (armorDamage > 0) { - unit.addArmorHits( + if (damage > 0) { + const armor = unit.applyMekFallArmorDamage( location, - armorDamage, + damage, rear, consolidateImmediately, - armor.appliedDamage, ); + armorDamage = armor.armorDamage; + damage = armor.remainingDamage; + appliedDamage += armor.appliedDamage; + locationAppliedDamage += armor.appliedDamage; + groupDamaged ||= armorDamage > 0; } - damage = armor.remainingDamage; - appliedDamage += armor.appliedDamage; - groupDamaged ||= armorDamage > 0; const remainingInternal = Math.max( 0, unit.getInternalPoints(location) - unit.getInternalHits(location), ); + const receivedSharedCompositePip: boolean = sharedCompositePip; + const sharedInternalDamage = receivedSharedCompositePip && remainingInternal > 0 ? 1 : 0; + sharedCompositePip = false; + const structureKind = unit.getStructureKindAt(location); + const structureIncomingDamage = damage; const structure = resolveMekStructureDamage( damage, - remainingInternal, - unit.getStructureKindAt(location), + remainingInternal - sharedInternalDamage, + structureKind, ); - const internalDamage = structure.internalDamage; + internalDamage = sharedInternalDamage + structure.internalDamage; if (internalDamage > 0) { - unit.addInternalHits(location, internalDamage, consolidateImmediately, { + const appliedInternalDamage = unit.addInternalHits(location, internalDamage, consolidateImmediately, { hardenedArmorApplies: armorType === 'HARDENED' && remainingArmor > 0, + ...(receivedSharedCompositePip ? { sharedCompositePip: true } : {}), }); + appliedDamage += appliedInternalDamage; + locationAppliedDamage += appliedInternalDamage; } damage = structure.overflowDamage; - appliedDamage += structure.phaseDamage; groupDamaged ||= internalDamage > 0; + sharedCompositePip = !receivedSharedCompositePip + && structureIncomingDamage > 0 + && unit.gameRules.id === 'core2026' + && remainingInternal % 2 === 1 + && structureKind === 'composite' + && internalDamage === remainingInternal + && canShareCompositePoint(unit, nextLocation, group.rear); + locations.push({ location, rear, + modularArmorDamage, armorDamage, internalDamage, - appliedDamage: armor.appliedDamage + structure.phaseDamage, + appliedDamage: locationAppliedDamage, }); - if (damage <= 0) break; - location = topology[location as keyof typeof topology]?.transfersTo ?? null; + if (damage <= 0 && !sharedCompositePip) break; + location = nextLocation; } if (group.location === 'HD' && groupDamaged) headHits++; if (group.critical && groupDamaged - && !(originalArmorType === 'ANTI_PENETRATIVE_ABLATION' && originalArmor > 0)) { + && !(unit.gameRules.id === 'core2026' + && originalArmorType === 'ANTI_PENETRATIVE_ABLATION' + && originalArmor > 0)) { unit.queueMekCriticalChance(group.location, { consolidateImmediately, hardenedArmorApplies: originalArmorType === 'HARDENED' && originalArmor > 0, @@ -303,6 +376,7 @@ export function applyMekFallDamage( /** Applies the armor rule for physical non-attack damage at one exact location. */ export function resolveMekFallArmorDamage( + rulesId: MekFallRulesId, damage: number, remainingArmor: number, armorType: ArmorType | null, @@ -318,7 +392,7 @@ export function resolveMekFallArmorDamage( } if (armorType === 'REFLECTIVE') { - const modifiedDamage = incoming * 2; + const modifiedDamage = incoming + Math.min(incoming, Math.floor(armor / 2)); if (armor >= modifiedDamage) { return { armorDamage: modifiedDamage, @@ -346,7 +420,9 @@ export function resolveMekFallArmorDamage( const modifiedDamage = armorType === 'FERRO_LAMELLOR' ? Math.floor(incoming * 4 / 5) : armorType === 'IMPACT_RESISTANT' - ? Math.max(1, Math.floor(incoming / 2)) + ? rulesId === 'core2026' + ? Math.max(1, Math.floor(incoming / 2)) + : Math.max(1, 2 * Math.floor(incoming / 3) + incoming % 3) : incoming; const armorDamage = Math.min(armor, modifiedDamage); return { @@ -356,6 +432,18 @@ export function resolveMekFallArmorDamage( }; } +function canShareCompositePoint( + unit: CBTForceUnit, + location: string | null, + rearArc: boolean, +): boolean { + if (!location || unit.getStructureKindAt(location) !== 'composite') return false; + const rear = rearArc && MEK_TORSO_LOCATIONS.has(location); + return unit.getModularArmorState(location).remaining === 0 + && unit.getArmorPoints(location, rear) - unit.getArmorHits(location, rear) <= 0 + && unit.getInternalPoints(location) - unit.getInternalHits(location) > 0; +} + function throughArmorHitArc(group: ResolvedMekFallDamageGroup): MekFallHitArc { if (group.location === 'LT') return 'left'; if (group.location === 'RT') return 'right'; @@ -366,6 +454,11 @@ function normalizedInteger(value: number): number { return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0; } +function mekFallWeightDamage(tons: number): number { + const normalizedTons = Number.isFinite(tons) ? Math.max(0, tons) : 0; + return Math.round(normalizedTons / 10); +} + function assertIntegerInRange(value: number, min: number, max: number, label: string): void { if (!Number.isInteger(value) || value < min || value > max) { throw new RangeError(`${label} must be an integer from ${min} to ${max}.`); diff --git a/src/app/utils/mek-structure-damage.util.spec.ts b/src/app/utils/mek-structure-damage.util.spec.ts index 24ee90419..c5b893bdb 100644 --- a/src/app/utils/mek-structure-damage.util.spec.ts +++ b/src/app/utils/mek-structure-damage.util.spec.ts @@ -4,7 +4,7 @@ import { mekStructureDamageCapacity, - mekStructurePhaseDamage, + mekStructureDamageReceived, resolveMekStructureDamage, } from './mek-structure-damage.util'; @@ -18,12 +18,10 @@ describe('Mek structure damage', () => { it('drops the unusable half point when odd composite structure is destroyed', () => { expect(resolveMekStructureDamage(2, 3, 'composite')).toEqual({ internalDamage: 3, - phaseDamage: 3, overflowDamage: 0, }); expect(resolveMekStructureDamage(2, 1, 'composite')).toEqual({ internalDamage: 1, - phaseDamage: 1, overflowDamage: 1, }); }); @@ -31,28 +29,27 @@ describe('Mek structure damage', () => { it('keeps incoming phase damage while composite structure survives', () => { expect(resolveMekStructureDamage(1, 3, 'composite')).toEqual({ internalDamage: 2, - phaseDamage: 1, overflowDamage: 0, }); }); - it('derives phase damage from applied pips, remaining structure, and structure kind', () => { - expect(mekStructurePhaseDamage(2, 6, 'composite')).toBe(1); - expect(mekStructurePhaseDamage(3, 3, 'composite')).toBe(3); - expect(mekStructurePhaseDamage(1, 6, 'reinforced')).toBe(0); - expect(mekStructurePhaseDamage(1, 5, 'reinforced')).toBe(1); - expect(mekStructurePhaseDamage(2, 6, 'standard')).toBe(2); + it('derives received damage cumulatively from marked pips and structure kind', () => { + expect([0, 1, 2, 3].map(hits => mekStructureDamageReceived(5, hits, 'composite'))) + .toEqual([0, 1, 1, 2]); + expect([0, 1, 2, 3].map(hits => mekStructureDamageReceived(4, hits, 'composite'))) + .toEqual([0, 0, 1, 1]); + expect([0, 1, 2, 3].map(hits => mekStructureDamageReceived(6, hits, 'reinforced'))) + .toEqual([0, 0, 1, 1]); + expect(mekStructureDamageReceived(5, 2, 'standard')).toBe(2); }); it('records Reinforced Structure as integer half-pips and counts only completed circles', () => { expect(resolveMekStructureDamage(3, 6, 'reinforced')).toEqual({ internalDamage: 3, - phaseDamage: 1, overflowDamage: 0, }); expect(resolveMekStructureDamage(1, 3, 'reinforced')).toEqual({ internalDamage: 1, - phaseDamage: 1, overflowDamage: 0, }); }); @@ -60,7 +57,6 @@ describe('Mek structure damage', () => { it('uses reinforced structure pips as two incoming damage points', () => { expect(resolveMekStructureDamage(5, 2, 'reinforced')).toEqual({ internalDamage: 2, - phaseDamage: 1, overflowDamage: 3, }); }); diff --git a/src/app/utils/mek-structure-damage.util.ts b/src/app/utils/mek-structure-damage.util.ts index f22c9b6eb..b77eb6801 100644 --- a/src/app/utils/mek-structure-damage.util.ts +++ b/src/app/utils/mek-structure-damage.util.ts @@ -12,8 +12,6 @@ export const MEK_STRUCTURE_TYPE = { export interface MekStructureDamageResolution { readonly internalDamage: number; readonly overflowDamage: number; - /** MegaMek-compatible amount added to damage received this phase. */ - readonly phaseDamage: number; } /** Incoming damage required to destroy the remaining structure. */ @@ -44,22 +42,28 @@ export function resolveMekStructureDamage( : internalDamage); return { internalDamage, - phaseDamage: mekStructurePhaseDamage(internalDamage, capacity, kind), overflowDamage: incoming - absorbedDamage, }; } -/** Damage contributed by an applied structure-pip delta to the phase's 20+ damage PSR. */ -export function mekStructurePhaseDamage( - internalDamage: number, - remainingInternal: number, +/** + * Damage represented by the marked structure pips. Keeping this cumulative makes + * the result independent of whether the same record edit was entered in one step + * or several smaller steps. + */ +export function mekStructureDamageReceived( + internalPoints: number, + internalHits: number, kind: MekStructureKind, ): number { - const capacity = normalizedInteger(remainingInternal); - const applied = Math.min(capacity, normalizedInteger(internalDamage)); - if (kind === 'reinforced') return fullDoubleDamagePipsRemoved(capacity, applied); - if (kind === 'composite' && applied < capacity) return Math.ceil(applied / 2); - return applied; + const points = normalizedInteger(internalPoints); + const hits = Math.min(points, normalizedInteger(internalHits)); + if (kind === 'reinforced') return fullDoubleDamagePipsRemoved(points, hits); + if (kind === 'composite') { + return mekStructureDamageCapacity(points, kind) + - mekStructureDamageCapacity(points - hits, kind); + } + return hits; } /** Full printed pips removed when each pip is represented by two ordered damage pips. */ From be9cd504f65c20c50ba46571b336317b61c8fcf3 Mon Sep 17 00:00:00 2001 From: exeea Date: Thu, 27 Aug 2026 23:12:06 +0200 Subject: [PATCH 44/87] . --- src/app/utils/formation-blueprints.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/utils/formation-blueprints.ts b/src/app/utils/formation-blueprints.ts index 64526035a..8543109db 100644 --- a/src/app/utils/formation-blueprints.ts +++ b/src/app/utils/formation-blueprints.ts @@ -1206,14 +1206,14 @@ export const FORMATION_RUNTIME_DEFINITIONS: FormationTypeDefinitionSource[] = [ name: 'Aerospace Superiority', description: 'An air-superiority formation balancing speed, firepower, and armor to defeat opposing aerospace units.', classic: { - effectDescription: 'Before the scenario, assign up to two of Blood Stalker, Hot Dog, and Ride the Wash—in any combination—to up to half the units.', + effectDescription: 'Before the scenario, assign up to two of Blood Stalker, Hot Dog, and Ride the Wash, in any combination, to up to half the units.', effectGroups: [{ abilityIds: ['blood_stalker', 'ride_the_wash', 'hot_dog'], selection: 'choose-each', distribution: 'up-to-50-percent', maxPerUnit: 2 }], minUnits: 6, rulesRef: [{ book: Rulebook.CO, page: 67 }], requirements: 'Minimum 6 units. All must be aerospace or conventional fighters. More than 50% must have the Interceptor or Fast Dogfighter role.', }, alphaStrike: { - effectDescription: 'Before the scenario, assign up to two of Blood Stalker, Hot Dog, and Ride the Wash—in any combination—to up to half the units.', + effectDescription: 'Before the scenario, assign up to two of Blood Stalker, Hot Dog, and Ride the Wash, in any combination, to up to half the units.', effectGroups: [{ abilityIds: ['blood_stalker', 'ride_the_wash', 'hot_dog'], selection: 'choose-each', distribution: 'up-to-50-percent', maxPerUnit: 2 }], minUnits: 6, rulesRef: [{ book: Rulebook.ASCE, page: 122 }], From 4ee8622868fbe181aa9056257bb18f4a297c6655 Mon Sep 17 00:00:00 2001 From: exeea Date: Thu, 27 Aug 2026 23:45:21 +0200 Subject: [PATCH 45/87] breach check --- .../svg-interaction.service.spec.ts | 63 +++++++ .../page-viewer/svg-interaction.service.ts | 28 +++- src/app/models/cbt-force-unit.model.spec.ts | 150 ++++++++++++++++- src/app/models/cbt-force-unit.model.ts | 155 +++++++++++++++--- src/app/models/rules/game-rules.spec.ts | 12 ++ src/app/models/rules/game-rules.ts | 18 ++ src/app/utils/mek-critical-hit.util.spec.ts | 2 +- src/app/utils/mek-critical-hit.util.ts | 1 + src/app/utils/mek-falling.util.ts | 1 + 9 files changed, 402 insertions(+), 28 deletions(-) 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 fd6f16de8..d4f9d7e82 100644 --- a/src/app/components/page-viewer/svg-interaction.service.spec.ts +++ b/src/app/components/page-viewer/svg-interaction.service.spec.ts @@ -85,6 +85,7 @@ function createSvgInteractionUnit(overrides: T): T & { getInve getNotificationDisplayName: () => 'Test Unit', automationMode: () => 'ask', applyUnderwaterBreachAndFlooding: () => undefined, + resolveUnderwaterHullBreachCheck: () => null, automationTriggers: new Subject(), rules: NO_CONDITION_RULES, ...overrides, @@ -597,6 +598,64 @@ describe('SvgInteractionService', () => { expect(setLocationCondition).toHaveBeenCalledOnceWith('LL', 'flooded', true, true); }); + it('rolls an accepted hull-breach check through breach and flood automation', async () => { + const automationTriggers = new Subject(); + const resolveUnderwaterHullBreachCheck = jasmine.createSpy('resolveUnderwaterHullBreachCheck'); + const unit = createSvgInteractionUnit({ + id: 'unit-a', + automationTriggers, + getNotificationDisplayName: () => 'Archer ARC-2D', + resolveUnderwaterHullBreachCheck, + }); + automationResolve.and.resolveTo(new Set(['hull:1'])); + phaseIsResolving.and.returnValue(true); + service.updateUnit(unit); + + automationTriggers.next({ + kind: 'hull-breach-check', + id: 'hull:1', + location: 'LL', + commit: false, + }); + await service.automationQueue; + + expect(automationResolve).toHaveBeenCalledOnceWith( + 'breachAndFloodCheck', + [jasmine.objectContaining({ + id: 'hull:1', + event: 'Hull breach check', + description: 'Left Leg took damage while submerged', + effects: ['Roll 2D6; the location breaches and floods on 2–4.'], + })], + { + title: 'Review Hull Breach Check', + message: 'Choose whether to roll and resolve this hull breach check.', + }, + ); + expect(resolveUnderwaterHullBreachCheck).toHaveBeenCalledOnceWith('LL', false); + }); + + it('does not roll a skipped hull-breach check', async () => { + const automationTriggers = new Subject(); + const resolveUnderwaterHullBreachCheck = jasmine.createSpy('resolveUnderwaterHullBreachCheck'); + const unit = createSvgInteractionUnit({ + automationTriggers, + resolveUnderwaterHullBreachCheck, + }); + automationResolve.and.resolveTo(new Set()); + service.updateUnit(unit); + + automationTriggers.next({ + kind: 'hull-breach-check', + id: 'hull:skip', + location: 'RL', + commit: true, + }); + await service.automationQueue; + + expect(resolveUnderwaterHullBreachCheck).not.toHaveBeenCalled(); + }); + it('does not discard a breach and flood review during phase resolution', async () => { const automationTriggers = new Subject(); const setLocationCondition = jasmine.createSpy('setLocationCondition'); @@ -1464,6 +1523,7 @@ describe('SvgInteractionService', () => { expect(unit.addArmorHits).toHaveBeenCalledWith('LT', 15, false, false); expect(unit.addInternalHits).toHaveBeenCalledWith('LT', 12, false, { hardenedArmorApplies: true, + armorDamagedBySameHit: true, }); }); @@ -1500,6 +1560,7 @@ describe('SvgInteractionService', () => { expect(unit.addArmorHits).toHaveBeenCalledWith('HD', 2, false, false); expect(unit.addInternalHits).toHaveBeenCalledWith('HD', 2, false, { hardenedArmorApplies: true, + armorDamagedBySameHit: true, }); expect(showToast).toHaveBeenCalledWith( 'Test Unit — Pilot hit from head damage in Head: 3 applied', @@ -1601,6 +1662,7 @@ describe('SvgInteractionService', () => { expect(unit.addArmorHits).toHaveBeenCalledWith('LT', 2, true, false); expect(unit.addInternalHits).toHaveBeenCalledWith('LT', 2, false, { hardenedArmorApplies: true, + armorDamagedBySameHit: true, }); }); @@ -1696,6 +1758,7 @@ describe('SvgInteractionService', () => { expect(unit.addArmorHits).toHaveBeenCalledWith('LT', 15, false, false); expect(unit.addInternalHits).toHaveBeenCalledWith('LT', 1, false, { hardenedArmorApplies: true, + armorDamagedBySameHit: true, }); }); diff --git a/src/app/components/page-viewer/svg-interaction.service.ts b/src/app/components/page-viewer/svg-interaction.service.ts index 2ce889836..bc8fa2a8d 100644 --- a/src/app/components/page-viewer/svg-interaction.service.ts +++ b/src/app/components/page-viewer/svg-interaction.service.ts @@ -794,6 +794,7 @@ export class SvgInteractionService { if (internalDamage > 0) { unit.addInternalHits(loc, internalDamage, this.consolidateImmediately, { hardenedArmorApplies: ordinaryArmorRemaining > 0, + ...(armorDamage > 0 ? { armorDamagedBySameHit: true } : {}), }); } } else { @@ -1752,7 +1753,8 @@ export class SvgInteractionService { // Events emitted while END PHASE is draining are already represented in // the unit queue and belong to that awaited workflow. Breach reviews are // transient, so they must still be delivered rather than silently lost. - const phaseOwned = trigger.kind !== 'breach-and-flood'; + const phaseOwned = trigger.kind !== 'breach-and-flood' + && trigger.kind !== 'hull-breach-check'; if (phaseOwned && this.phaseResolution.isResolving(unit)) return; let task: () => Promise; @@ -1762,6 +1764,8 @@ export class SvgInteractionService { task = () => this.unitCheckResolution.open([unit]); } else if (trigger.kind === 'falling') { task = () => this.fallingResolution.open(unit, trigger, this.consolidateImmediately); + } else if (trigger.kind === 'hull-breach-check') { + task = () => this.handleHullBreachCheckTrigger(unit, trigger); } else { task = () => this.handleBreachAndFloodTrigger(unit, trigger); } @@ -1825,6 +1829,28 @@ export class SvgInteractionService { } } + private async handleHullBreachCheckTrigger( + unit: CBTForceUnit, + trigger: Extract, + ): Promise { + const locationLabel = getMekLocationLabel(trigger.location) ?? trigger.location; + const breachRange = unit.gameRules.getHullBreachCheckRangeLabel(); + const event: AutomationReviewEvent = { + id: trigger.id, + subject: unit.getNotificationDisplayName(), + event: 'Hull breach check', + description: `${locationLabel} took damage while submerged`, + effects: [`Roll 2D6; the location breaches and floods on ${breachRange}.`], + }; + const accepted = await this.automations.resolve('breachAndFloodCheck', [event], { + title: 'Review Hull Breach Check', + message: 'Choose whether to roll and resolve this hull breach check.', + }); + if (accepted?.has(event.id)) { + unit.resolveUnderwaterHullBreachCheck(trigger.location, trigger.commit); + } + } + private openMekCriticalChanceDialog( unit: CBTForceUnit, location: string, diff --git a/src/app/models/cbt-force-unit.model.spec.ts b/src/app/models/cbt-force-unit.model.spec.ts index aaa15aff7..4dbe09a1f 100644 --- a/src/app/models/cbt-force-unit.model.spec.ts +++ b/src/app/models/cbt-force-unit.model.spec.ts @@ -2072,6 +2072,125 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(forceUnit.getLocationCondition('LT', 'flooded')).toBeTrue(); }); + it('floods a structurally destroyed location with depleted armor when it becomes submerged', () => { + automationModes.pilotSkillCheck = 'no'; + const { forceUnit } = createCriticalHeatSinkForceUnit(); + forceUnit.setArmorHits('LL', forceUnit.getArmorPoints('LL')); + forceUnit.setInternalHits('LL', forceUnit.getInternalPoints('LL')); + + expect(forceUnit.isInternalLocStructurallyDestroyed('LL')).toBeTrue(); + expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeFalse(); + + forceUnit.turnState().setCover('underwater-depth-1'); + + expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeTrue(); + }); + + it('does not use structural destruction as a substitute for depleted armor', () => { + automationModes.pilotSkillCheck = 'no'; + const { forceUnit } = createCriticalHeatSinkForceUnit(); + forceUnit.setInternalHits('LL', forceUnit.getInternalPoints('LL')); + + forceUnit.turnState().setCover('underwater-depth-1'); + + expect(forceUnit.isInternalLocStructurallyDestroyed('LL')).toBeTrue(); + expect(forceUnit.getArmorHits('LL')).toBe(0); + expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeFalse(); + }); + + it('automatically resolves a successful Core hull-breach check after underwater damage', () => { + automationModes.pilotSkillCheck = 'no'; + const { forceUnit } = createCriticalHeatSinkForceUnit(); + forceUnit.turnState().setCover('underwater-depth-1'); + spyOn(Math, 'random').and.returnValues(0, 0); + + forceUnit.addArmorHits('LL', 1); + + expect(Math.random).toHaveBeenCalledTimes(2); + expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeTrue(); + expect(toastService.showToast).toHaveBeenCalledWith( + jasmine.stringContaining('Hull breach check: Left Leg breached and flooded (2 on 2D6; breach on 2–4)'), + 'error', + ); + }); + + it('does not flood when a Core hull-breach check rolls above 4', () => { + const { forceUnit } = createCriticalHeatSinkForceUnit(); + forceUnit.turnState().setCover('underwater-depth-1'); + spyOn(Math, 'random').and.returnValues(0.5, 0.5); + + forceUnit.addArmorHits('LL', 1); + + expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeFalse(); + expect(toastService.showToast).toHaveBeenCalledWith( + jasmine.stringContaining('Hull breach check: Left Leg held (8 on 2D6; breach on 2–4)'), + 'success', + ); + }); + + it('uses the Total Warfare 10+ hull-breach result', () => { + cbtRules = 'tw'; + automationModes.pilotSkillCheck = 'no'; + const { forceUnit } = createCriticalHeatSinkForceUnit(); + forceUnit.turnState().setCover('underwater-depth-1'); + spyOn(Math, 'random').and.returnValues(0.7, 0.7); + + forceUnit.addArmorHits('LL', 1); + + expect(forceUnit.gameRules.id).toBe('tw'); + expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeTrue(); + expect(toastService.showToast).toHaveBeenCalledWith( + jasmine.stringContaining('Hull breach check: Left Leg breached and flooded (10 on 2D6; breach on 10+)'), + 'error', + ); + }); + + it('does not breach on a low Total Warfare hull-breach roll', () => { + cbtRules = 'tw'; + const { forceUnit } = createCriticalHeatSinkForceUnit(); + forceUnit.turnState().setCover('underwater-depth-1'); + spyOn(Math, 'random').and.returnValues(0, 0); + + forceUnit.addArmorHits('LL', 1); + + expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeFalse(); + expect(toastService.showToast).toHaveBeenCalledWith( + jasmine.stringContaining('Hull breach check: Left Leg held (2 on 2D6; breach on 10+)'), + 'success', + ); + }); + + it('does not roll when depleted armor makes the underwater breach automatic', () => { + automationModes.pilotSkillCheck = 'no'; + const { forceUnit } = createCriticalHeatSinkForceUnit(); + forceUnit.turnState().setCover('underwater-depth-1'); + spyOn(Math, 'random'); + + forceUnit.addArmorHits('LL', forceUnit.getArmorPoints('LL')); + + expect(Math.random).not.toHaveBeenCalled(); + expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeTrue(); + }); + + it('requests a hull-breach check in ask mode without resolving it', () => { + automationModes.breachAndFloodCheck = 'ask'; + const { forceUnit } = createCriticalHeatSinkForceUnit(); + const triggers: CBTUnitAutomationTrigger[] = []; + forceUnit.automationTriggers.subscribe(trigger => triggers.push(trigger)); + forceUnit.turnState().setCover('underwater-depth-1'); + + forceUnit.addArmorHits('LL', 1); + + expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeFalse(); + expect(triggers).toEqual([ + jasmine.objectContaining({ + kind: 'hull-breach-check', + location: 'LL', + commit: false, + }), + ]); + }); + it('marks flooding when pending armor breaches underwater and commits it at phase end', () => { const { forceUnit } = createCriticalHeatSinkForceUnit(); forceUnit.turnState().setCover('underwater-depth-1'); @@ -2097,7 +2216,11 @@ describe('CBTForceUnit direct inventory ammo bins', () => { forceUnit.automationTriggers.subscribe(trigger => triggers.push(trigger)); forceUnit.turnState().setCover('underwater-depth-1'); - forceUnit.addArmorHits('LL', 5); + forceUnit.addArmorHits('LL', 1); + expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeFalse(); + expect(triggers).toEqual([]); + + forceUnit.addArmorHits('LL', 4); forceUnit.endPhase(); expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeFalse(); @@ -2216,6 +2339,31 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(reinforced.turnState().dmgReceived()).toBe(1); }); + it('counts every Core composite pip destroyed by an internal explosion toward the damage PSR', () => { + const explosion = createCriticalHeatSinkForceUnit().forceUnit; + spyOn(explosion, 'getStructureKindAt').and.returnValue('composite'); + explosion.locations!.internal.set('LT', { loc: 'LT', points: 20 }); + + // Explosion resolution has already capped 10 damage and doubled it to 20 structure pips. + expect(explosion.addInternalHits('LT', 20, false, { + explosionProtection: 'none', + })).toBe(20); + + expect(explosion.turnState().dmgReceived()).toBe(20); + expect(explosion.turnState().getPSRChecks()).toContain(jasmine.objectContaining({ + kind: PSR_CHECK_KIND.DAMAGE_THRESHOLD, + })); + + const transferred = createCriticalHeatSinkForceUnit().forceUnit; + spyOn(transferred, 'getStructureKindAt').and.returnValue('composite'); + + expect(transferred.addInternalHits('LT', 1, false, { + explosionProtection: 'none', + sharedCompositePip: true, + })).toBe(1); + expect(transferred.turnState().dmgReceived()).toBe(1); + }); + it('does not queue automatic critical chances when that automation is no', () => { automationModes.criticalHitChanceCheck = 'no'; const { forceUnit } = createCriticalHeatSinkForceUnit(); diff --git a/src/app/models/cbt-force-unit.model.ts b/src/app/models/cbt-force-unit.model.ts index f9ecef56f..4b25c66d7 100644 --- a/src/app/models/cbt-force-unit.model.ts +++ b/src/app/models/cbt-force-unit.model.ts @@ -94,6 +94,7 @@ export interface CBTEndTurnAutomationDecisions { } export interface CBTInternalDamageContext { + /** Present only for internal-explosion damage; records the protection used to resolve it. */ readonly explosionProtection?: MekExplosionProtection; /** Whether Hardened Armor remained in the exact facing/location when this hit reached structure. */ readonly hardenedArmorApplies?: boolean; @@ -101,6 +102,8 @@ export interface CBTInternalDamageContext { readonly pilotDamageGroup?: string; /** The first composite pip shares a damage point already counted in the previous location. */ readonly sharedCompositePip?: boolean; + /** This hit already damaged armor in the same location and initiated its hull-breach resolution. */ + readonly armorDamagedBySameHit?: boolean; /** Hit-table arc for a possible through-armor critical. */ readonly throughArmorHitArc?: MekHitArc; } @@ -116,6 +119,12 @@ export interface CBTMekFallDamageRoll { readonly tripodLegRoll: number | null; } +export interface CBTHullBreachCheckResolution { + readonly dice: readonly [number, number]; + readonly total: number; + readonly breached: boolean; +} + /** Serialized event facts exposed to the dialog with explicit unrolled values. */ export type CBTPendingMekFall = Omit< SerializedPendingMekFall, @@ -132,6 +141,10 @@ function fallDamageRollForDialog(roll: SerializedMekFallDamageRoll): CBTMekFallD }; } +function rollD6(random: () => number): number { + return Math.floor(random() * 6) + 1; +} + export type CBTUnitAutomationTrigger = | { readonly kind: 'critical-hit-chance'; @@ -151,6 +164,12 @@ export type CBTUnitAutomationTrigger = readonly id: string; readonly locations: readonly string[]; readonly commit: boolean; + } + | { + readonly kind: 'hull-breach-check'; + readonly id: string; + readonly location: string; + readonly commit: boolean; }; export class CBTForceUnit extends ForceUnit { @@ -846,7 +865,14 @@ export class CBTForceUnit extends ForceUnit { const damageReceived = this.getArmorTypeAt(loc) === 'HARDENED' ? Math.floor(currentHits / 2) - Math.floor(previousHits / 2) : currentHits - previousHits; - this.recordArmorHits(loc, hits, rear, consolidateImmediately, damageReceived); + this.recordArmorHits( + loc, + hits, + rear, + consolidateImmediately, + damageReceived, + damageReceived > 0, + ); return damageReceived; } @@ -871,6 +897,7 @@ export class CBTForceUnit extends ForceUnit { rear, consolidateImmediately, resolution.appliedDamage, + resolution.appliedDamage > 0, ); } return resolution; @@ -882,6 +909,7 @@ export class CBTForceUnit extends ForceUnit { rear: boolean | undefined, consolidateImmediately: boolean, damageReceived: number, + armorDamageApplied: boolean, ): void { const locKey = rear ? `${loc}-rear` : loc; const locations = { ...this.state.locations() }; @@ -898,6 +926,7 @@ export class CBTForceUnit extends ForceUnit { this.state.turnState().addDmgReceived(damageReceived); if (consolidateImmediately) this.state.consolidateLocations(); else this.applyUnderwaterBreachAndFlooding(); + if (armorDamageApplied) this.applyUnderwaterHullBreachCheck(loc, consolidateImmediately); this.evaluateDestroyed(); this.setModified(); } @@ -1032,9 +1061,17 @@ export class CBTForceUnit extends ForceUnit { const boundedPreviousHits = Math.min(internalPoints, Math.max(0, previousHits)); const boundedCurrentHits = Math.min(internalPoints, Math.max(0, this.getInternalHits(loc))); const appliedDamage = boundedCurrentHits - boundedPreviousHits; - let phaseDamage = mekStructureDamageReceived(internalPoints, boundedCurrentHits, structureKind) - - previousDamageReceived; - if (context.sharedCompositePip && structureKind === 'composite' && appliedDamage > 0) { + // Core counts every Composite structure pip destroyed by an internal explosion toward the damage PSR. + const countsExplosionPips = this.gameRules.id === 'core2026' + && context.explosionProtection !== undefined + && structureKind === 'composite'; + let phaseDamage = countsExplosionPips + ? appliedDamage + : mekStructureDamageReceived(internalPoints, boundedCurrentHits, structureKind) - previousDamageReceived; + if (!countsExplosionPips + && context.sharedCompositePip + && structureKind === 'composite' + && appliedDamage > 0) { phaseDamage -= mekStructureDamageReceived( internalPoints, Math.min(boundedCurrentHits, boundedPreviousHits + 1), @@ -1043,11 +1080,17 @@ export class CBTForceUnit extends ForceUnit { } this.state.turnState().addDmgReceived(phaseDamage); // A single assignment is one hit/event, regardless of how many structure pips it marks. - if (appliedDamage > 0) this.queueMekCriticalChance(loc, { - ...context, - locationDestroyed: boundedCurrentHits >= internalPoints, - consolidateImmediately, - }); + if (appliedDamage > 0) { + if (!context.armorDamagedBySameHit) { + this.applyUnderwaterBreachAndFlooding(consolidateImmediately); + this.applyUnderwaterHullBreachCheck(loc, consolidateImmediately); + } + this.queueMekCriticalChance(loc, { + ...context, + locationDestroyed: boundedCurrentHits >= internalPoints, + consolidateImmediately, + }); + } return phaseDamage; } @@ -1151,6 +1194,81 @@ export class CBTForceUnit extends ForceUnit { return location.split('/').some(loc => legLocations.has(loc.trim())); } + private isFloodableLocation(location: string): boolean { + const internalLocations = this.locations?.internal; + if (!internalLocations?.has(location) || this.getLocationCondition(location, 'blown-off')) return false; + const structurallyDestroyed = this.isInternalLocStructurallyDestroyed(location); + // A location destroyed only through its parent is detached. A location whose own + // structure is destroyed remains eligible when its armor also satisfies the rule. + return structurallyDestroyed || !this.isInternalLocPhysicallyDestroyed(location); + } + + private isLocationArmorDepletedForFlooding(location: string, commit: boolean): boolean { + const armorLocations = this.locations?.armor; + if (!armorLocations) return false; + const armorByFacing = new Map( + Array.from(armorLocations.values()) + .filter(armor => armor.loc === location) + .map(armor => [armor.rear, armor] as const), + ); + const armorFacings = MEK_REAR_ARMOR_LOCATIONS.has(location) ? [false, true] : [false]; + return armorFacings.some(rear => { + const armor = armorByFacing.get(rear); + const armorHits = commit + ? this.getCommittedArmorHits(location, rear) + : this.getArmorHits(location, rear); + return !armor || armorHits >= this.getArmorPoints(location, rear); + }); + } + + /** Initiates the per-damaging-hit hull-breach check for an armored submerged location. */ + applyUnderwaterHullBreachCheck(location: string, commit = false): void { + const mode = this.automationMode('breachAndFloodCheck'); + if (mode === 'no' + || this.getUnit().type !== 'Mek' + || !this.isLocationSubmerged(location) + || !this.isFloodableLocation(location) + || this.getLocationCondition(location, 'flooded') + || this.isLocationArmorDepletedForFlooding(location, commit)) return; + + if (mode === 'yes') { + this.resolveUnderwaterHullBreachCheck(location, commit); + return; + } + if (!this.automationTriggers.observed) return; + this.automationTriggers.next({ + kind: 'hull-breach-check', + id: uuidv7(), + location, + commit, + }); + } + + /** Rolls and applies one previously established hull-breach check. */ + resolveUnderwaterHullBreachCheck( + location: string, + commit = false, + random: () => number = Math.random, + ): CBTHullBreachCheckResolution | null { + if (this.getUnit().type !== 'Mek' + || !this.isFloodableLocation(location) + || this.getLocationCondition(location, 'flooded')) return null; + + const dice = [rollD6(random), rollD6(random)] as const; + const total = dice[0] + dice[1]; + const breached = this.gameRules.hullBreachCheckSucceeds(total); + if (breached) this.setLocationCondition(location, 'flooded', true, commit); + + const locationLabel = getMekLocationLabel(location) ?? location; + const breachRange = this.gameRules.getHullBreachCheckRangeLabel(); + this.injector.get(CBTAutomationToastService).show( + this, + `Hull breach check: ${locationLabel} ${breached ? 'breached and flooded' : 'held'} (${total} on 2D6; breach on ${breachRange})`, + breached ? 'error' : 'success', + ); + return { dice, total, breached }; + } + applyUnderwaterBreachAndFlooding(commit = false): void { const internalLocations = this.locations?.internal; const armorLocations = this.locations?.armor; @@ -1170,22 +1288,9 @@ export class CBTForceUnit extends ForceUnit { : getMekLegLocations(inferMekConfigFromLocations(internalLocations.keys())); const eligibleLocations: string[] = []; for (const loc of submergedLocations) { - if (!internalLocations.has(loc) || this.isInternalLocPhysicallyDestroyed(loc)) continue; - // Armor metadata is sparse, so a missing front/rear entry means that facing is exposed. - const armorByFacing = new Map( - Array.from(armorLocations.values()) - .filter(armor => armor.loc === loc) - .map(armor => [armor.rear, armor] as const), - ); - const armorFacings = MEK_REAR_ARMOR_LOCATIONS.has(loc) ? [false, true] : [false]; - const armorBreached = armorFacings.some(rear => { - const armor = armorByFacing.get(rear); - const armorHits = commit - ? this.getCommittedArmorHits(loc, rear) - : this.getArmorHits(loc, rear); - return !armor || armorHits >= this.getArmorPoints(loc, rear); - }); - if (armorBreached && !this.getLocationCondition(loc, 'flooded')) eligibleLocations.push(loc); + if (!this.isFloodableLocation(loc)) continue; + if (this.isLocationArmorDepletedForFlooding(loc, commit) + && !this.getLocationCondition(loc, 'flooded')) eligibleLocations.push(loc); } const eligible = new Set(eligibleLocations); diff --git a/src/app/models/rules/game-rules.spec.ts b/src/app/models/rules/game-rules.spec.ts index 15d95fefc..50b4c623b 100644 --- a/src/app/models/rules/game-rules.spec.ts +++ b/src/app/models/rules/game-rules.spec.ts @@ -305,6 +305,18 @@ describe('game rules', () => { expect(TW_GAME_RULES.aggregatedEndPhaseConsciousRolls).toBeFalse(); }); + it('owns the ruleset-specific hull-breach result and label', () => { + expect(CORE_2026_GAME_RULES.getHullBreachCheckRangeLabel()).toBe('2–4'); + expect(CORE_2026_GAME_RULES.hullBreachCheckSucceeds(2)).toBeTrue(); + expect(CORE_2026_GAME_RULES.hullBreachCheckSucceeds(4)).toBeTrue(); + expect(CORE_2026_GAME_RULES.hullBreachCheckSucceeds(5)).toBeFalse(); + + expect(TW_GAME_RULES.getHullBreachCheckRangeLabel()).toBe('10+'); + expect(TW_GAME_RULES.hullBreachCheckSucceeds(9)).toBeFalse(); + expect(TW_GAME_RULES.hullBreachCheckSucceeds(10)).toBeTrue(); + expect(TW_GAME_RULES.hullBreachCheckSucceeds(12)).toBeTrue(); + }); + describe('escalating failure targets', () => { it('uses the standardized numeric Core sequence for every checked component', () => { const standard = [3, 5, 7, 10, 11] as const; diff --git a/src/app/models/rules/game-rules.ts b/src/app/models/rules/game-rules.ts index 25b38dad9..88fa2f4d1 100644 --- a/src/app/models/rules/game-rules.ts +++ b/src/app/models/rules/game-rules.ts @@ -214,6 +214,8 @@ export abstract class CBTGameRules { abstract getExplosiveWeaponDamage(weapon: WeaponEquipment, mountedCriticalSlots: number): number; abstract resolveMekExplosionDamage(context: MekExplosionDamageContext): MekExplosionDamageResolution; abstract getMekExplosionProtectionNote(protection: MekExplosionProtection): string | null; + abstract hullBreachCheckSucceeds(total: number): boolean; + abstract getHullBreachCheckRangeLabel(): string; protected abstract canFireTorpedoesIndirectly(context: IndirectFireContext): boolean; /** Resolves immediate Mek explosion effects after handler-owned delayed cases are excluded. */ @@ -524,6 +526,14 @@ export class GameRules extends CBTGameRules { { munitionType: 'M_AX_HEAD', shotsMultiplier: 1, baseAmmoBvMultiplier: 1 }, ]; + override hullBreachCheckSucceeds(total: number): boolean { + return total >= 2 && total <= 4; + } + + override getHullBreachCheckRangeLabel(): string { + return '2–4'; + } + override resolveC3Targeting(target: InventoryControlRuntimeTarget, degradationSource: C3DegradationSource): C3TargetingResolution { return { target, degradationSource }; } @@ -657,6 +667,14 @@ export class TWGameRules extends CBTGameRules { { munitionType: 'M_AX_HEAD', shotsMultiplier: 0.5, baseAmmoBvMultiplier: 2 }, ]; + override hullBreachCheckSucceeds(total: number): boolean { + return total >= 10 && total <= 12; + } + + override getHullBreachCheckRangeLabel(): string { + return '10+'; + } + override resolveC3Targeting(target: InventoryControlRuntimeTarget, degradationSource: C3DegradationSource): C3TargetingResolution { return { target: degradationSource === 'none' || target.c3Distance === undefined diff --git a/src/app/utils/mek-critical-hit.util.spec.ts b/src/app/utils/mek-critical-hit.util.spec.ts index 0f098257c..a267adb6d 100644 --- a/src/app/utils/mek-critical-hit.util.spec.ts +++ b/src/app/utils/mek-critical-hit.util.spec.ts @@ -879,7 +879,7 @@ describe('Mek critical-hit workflow', () => { 'LT', 1, true, - { explosionProtection: 'case-ii' }, + { explosionProtection: 'case-ii', armorDamagedBySameHit: true }, ); }); }); diff --git a/src/app/utils/mek-critical-hit.util.ts b/src/app/utils/mek-critical-hit.util.ts index 8fb261057..f050fa73c 100644 --- a/src/app/utils/mek-critical-hit.util.ts +++ b/src/app/utils/mek-critical-hit.util.ts @@ -1052,6 +1052,7 @@ function applyMekEquipmentExplosion( consolidateImmediately, { explosionProtection: damage.protection, + ...(damage.armorDamage > 0 ? { armorDamagedBySameHit: true } : {}), ...(damage.sharedCompositePip ? { sharedCompositePip: true } : {}), ...(pilotDamageGroup && { pilotDamageGroup }), }, diff --git a/src/app/utils/mek-falling.util.ts b/src/app/utils/mek-falling.util.ts index 715c8e333..e0ca3fe18 100644 --- a/src/app/utils/mek-falling.util.ts +++ b/src/app/utils/mek-falling.util.ts @@ -329,6 +329,7 @@ export function applyMekFallDamage( if (internalDamage > 0) { const appliedInternalDamage = unit.addInternalHits(location, internalDamage, consolidateImmediately, { hardenedArmorApplies: armorType === 'HARDENED' && remainingArmor > 0, + ...(armorDamage > 0 ? { armorDamagedBySameHit: true } : {}), ...(receivedSharedCompositePip ? { sharedCompositePip: true } : {}), }); appliedDamage += appliedInternalDamage; From f4260d0fb6aff1494bb99f9554e6900b2a9ab34e Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 28 Aug 2026 00:02:36 +0200 Subject: [PATCH 46/87] unified --- src/app/utils/mek-falling.util.spec.ts | 28 ++++++++++++ src/app/utils/mek-falling.util.ts | 62 +++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/app/utils/mek-falling.util.spec.ts b/src/app/utils/mek-falling.util.spec.ts index e86f5fa07..285313ff7 100644 --- a/src/app/utils/mek-falling.util.spec.ts +++ b/src/app/utils/mek-falling.util.spec.ts @@ -9,6 +9,7 @@ import { mekFallDamage, mekFallDamageGroups, resolvedMekFallDamageGroups, + rollMekFallDice, resolveMekFallArmorDamage, resolveMekFallDamage, resolveMekFallHitLocation, @@ -123,6 +124,33 @@ describe('Mek falling rules', () => { })); }); + it('rolls and restores all fall dice through one workflow', () => { + const values = [0.7, 0, 0.2, 0.5]; + const random = () => values.shift() ?? 0; + + const rolled = rollMekFallDice('tw', 'tripod', 1, { random }); + + expect(rolled.orientation).toEqual(jasmine.objectContaining({ hitArc: 'left' })); + expect(rolled.damageRolls).toEqual([{ + hitLocationDice: [1, 2], + tripodLegRoll: 4, + }]); + expect(rolled.hitLocations[0]).toEqual(jasmine.objectContaining({ + location: 'LL', + adjustedTripodLegRoll: 5, + })); + + const unusedRandom = jasmine.createSpy('random'); + const restored = rollMekFallDice('tw', 'tripod', 1, { + orientationRoll: rolled.orientationRoll, + damageRolls: rolled.damageRolls, + random: unusedRandom, + }); + + expect(restored).toEqual(rolled); + expect(unusedRandom).not.toHaveBeenCalled(); + }); + it('applies armor, internal damage, and normal inward transfer for each group', () => { const harness = createDamageHarness({ armor: { LA: 1, LT: 10 }, diff --git a/src/app/utils/mek-falling.util.ts b/src/app/utils/mek-falling.util.ts index e0ca3fe18..0f683a2f2 100644 --- a/src/app/utils/mek-falling.util.ts +++ b/src/app/utils/mek-falling.util.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import type { CBTForceUnit, CBTMekFallDamageRoll } from '../models/cbt-force-unit.model'; import { getMekLocationLabel, getTopologyFor, MEK_TORSO_LOCATIONS, type ArmorType } from '../models/entity/types'; import type { MekHitArc } from '../models/force-serialization'; import { fullDoubleDamagePipsRemoved, resolveMekStructureDamage } from './mek-structure-damage.util'; @@ -43,6 +43,19 @@ export interface ResolvedMekFallDamageGroup extends MekFallHitLocationResult { readonly locationLabel: string; } +export interface MekFallDiceOptions { + readonly orientationRoll?: number | null; + readonly damageRolls?: readonly CBTMekFallDamageRoll[]; + readonly random?: () => number; +} + +export interface RolledMekFallDice { + readonly orientationRoll: number; + readonly orientation: MekFallOrientation; + readonly damageRolls: readonly CBTMekFallDamageRoll[]; + readonly hitLocations: readonly MekFallHitLocationResult[]; +} + export interface AppliedMekFallLocationDamage { readonly location: string; /** The hit came from the rear arc; only torso locations have rear armor. */ @@ -198,6 +211,49 @@ export function twoD6ForTotal(total: number | null): readonly [number, number] | : null; } +/** Rolls or restores every die needed to resolve one fall. */ +export function rollMekFallDice( + rulesId: MekFallRulesId, + table: MekHitLocationTable, + damageGroupCount: number, + options: MekFallDiceOptions = {}, +): RolledMekFallDice { + const random = options.random ?? Math.random; + const orientationRoll = options.orientationRoll ?? rollD6(random); + const orientation = resolveMekFallOrientation(rulesId, orientationRoll); + const rolled = Array.from({ length: damageGroupCount }, (_unused, index) => { + const saved = options.damageRolls?.[index]; + const hitLocationDice = saved?.hitLocationDice ?? [rollD6(random), rollD6(random)] as const; + const preliminary = resolveMekFallHitLocation( + table, + orientation.hitArc, + twoD6Total(hitLocationDice), + ); + const needsTripodLeg = preliminary.location === null + && preliminary.tripodLegModifier !== undefined; + const tripodLegRoll = needsTripodLeg + ? saved?.tripodLegRoll ?? rollD6(random) + : null; + return { + damageRoll: { hitLocationDice, tripodLegRoll }, + hitLocation: tripodLegRoll === null + ? preliminary + : resolveMekFallHitLocation( + table, + orientation.hitArc, + twoD6Total(hitLocationDice), + tripodLegRoll, + ), + }; + }); + return { + orientationRoll, + orientation, + damageRolls: rolled.map(result => result.damageRoll), + hitLocations: rolled.map(result => result.hitLocation), + }; +} + /** Resolves one 2D6 hit-location roll, including the extra tripod leg roll. */ export function resolveMekFallHitLocation( table: MekHitLocationTable, @@ -455,6 +511,10 @@ function normalizedInteger(value: number): number { return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0; } +function rollD6(random: () => number): number { + return Math.floor(random() * 6) + 1; +} + function mekFallWeightDamage(tons: number): number { const normalizedTons = Number.isFinite(tons) ? Math.max(0, tons) : 0; return Math.round(normalizedTons / 10); From 647d284451ac9cc17ece66541b31f40d91ed47d8 Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 28 Aug 2026 00:08:07 +0200 Subject: [PATCH 47/87] cleanup --- .../falling-damage-dialog.component.ts | 26 ++------- .../services/falling-resolution.service.ts | 54 ++++--------------- src/app/services/options.service.spec.ts | 2 +- src/app/services/options.service.ts | 2 +- src/app/utils/mek-critical-hit.util.spec.ts | 34 ++++++++++++ src/app/utils/mek-critical-hit.util.ts | 12 +++-- 6 files changed, 59 insertions(+), 71 deletions(-) diff --git a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.ts b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.ts index f75841635..10eb5a828 100644 --- a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.ts +++ b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.ts @@ -13,6 +13,7 @@ import { unitCoverWaterDepth } from '../../models/unit-cover.model'; import { isResolvedMekFallHitLocation, resolvedMekFallDamageGroups, + rollMekFallDice, resolveMekFallDamage, resolveMekFallHitLocation, resolveMekFallOrientation, @@ -147,24 +148,9 @@ export class FallingDamageDialogComponent { } rollAllResults(random: () => number = Math.random): void { - const orientationRoll = rollD6(random); - const orientation = resolveMekFallOrientation(this.rulesId, orientationRoll); - this.orientationRoll.set(orientationRoll); - this.groupRolls.set(this.damageGroups.map(() => { - const hitLocationDice = [rollD6(random), rollD6(random)] as const; - const hitLocationRoll = twoD6Total(hitLocationDice); - const preliminary = resolveMekFallHitLocation( - this.hitLocationTable, - orientation.hitArc, - hitLocationRoll, - ); - const needsTripodLeg = preliminary.location === null - && preliminary.tripodLegModifier !== undefined; - return { - hitLocationDice, - tripodLegRoll: needsTripodLeg ? rollD6(random) : null, - }; - })); + const rolled = rollMekFallDice(this.rulesId, this.hitLocationTable, this.damageGroups.length, { random }); + this.orientationRoll.set(rolled.orientationRoll); + this.groupRolls.set(rolled.damageRolls); this.persistRolls(); } @@ -208,7 +194,3 @@ export class FallingDamageDialogComponent { function validRoll(value: number | null, min: number, max: number): number | null { return value !== null && Number.isInteger(value) && value >= min && value <= max ? value : null; } - -function rollD6(random: () => number): number { - return Math.floor(random() * 6) + 1; -} diff --git a/src/app/services/falling-resolution.service.ts b/src/app/services/falling-resolution.service.ts index 4bf9eb5aa..8ee213e54 100644 --- a/src/app/services/falling-resolution.service.ts +++ b/src/app/services/falling-resolution.service.ts @@ -16,17 +16,15 @@ import { type FallingNoticeDialogData, } from '../components/falling-notice-dialog/falling-notice-dialog.component'; import type { AutomationReviewEvent } from '../models/automation-review.model'; -import type { CBTForceUnit, CBTMekFallDamageRoll } from '../models/cbt-force-unit.model'; +import type { CBTForceUnit } from '../models/cbt-force-unit.model'; import { getMekLocationLabel } from '../models/entity/types'; import { unitCoverWaterDepth } from '../models/unit-cover.model'; import { applyMekFallDamage, isResolvedMekFallHitLocation, resolvedMekFallDamageGroups, + rollMekFallDice, resolveMekFallDamage, - resolveMekFallHitLocation, - resolveMekFallOrientation, - twoD6Total, type ResolvedMekFallDamageGroup, } from '../utils/mek-falling.util'; import { clusterTableForUnit } from '../utils/record-sheet-reference-table'; @@ -140,8 +138,6 @@ export class FallingResolutionService { trigger: FallingAutomationTrigger, ): AcceptedFallingDamageDialogResult { const pending = unit.getPendingFall(trigger.id); - const orientationRoll = pending?.orientationRoll ?? this.rollD6(); - const orientation = resolveMekFallOrientation(unit.gameRules.id, orientationRoll); const damageGroups = resolvedMekFallDamageGroups(resolveMekFallDamage( unit.gameRules.id, unit.getUnit().tons, @@ -149,47 +145,23 @@ export class FallingResolutionService { unitCoverWaterDepth(unit.turnState().cover()), )); const hitLocationTable = clusterTableForUnit(unit.getUnit()).hitLocationTable ?? 'biped'; - const damageRolls: CBTMekFallDamageRoll[] = []; - const groups: ResolvedMekFallDamageGroup[] = []; - - damageGroups.forEach((damage, index) => { - const saved = pending?.damageRolls[index]; - const hitLocationDice = saved?.hitLocationDice - ?? [this.rollD6(), this.rollD6()] as const; - const hitLocationRoll = twoD6Total(hitLocationDice); - const preliminary = resolveMekFallHitLocation( - hitLocationTable, - orientation.hitArc, - hitLocationRoll, - ); - const needsTripodLeg = preliminary.location === null - && preliminary.tripodLegModifier !== undefined; - const tripodLegRoll = !needsTripodLeg - ? null - : saved?.tripodLegRoll ?? this.rollD6(); - const result = resolveMekFallHitLocation( - hitLocationTable, - orientation.hitArc, - hitLocationRoll, - tripodLegRoll ?? undefined, - ); + const rolled = rollMekFallDice(unit.gameRules.id, hitLocationTable, damageGroups.length, { + orientationRoll: pending?.orientationRoll, + damageRolls: pending?.damageRolls, + }); + const groups = rolled.hitLocations.map((result, index) => { if (!isResolvedMekFallHitLocation(result)) { throw new Error('Automatic falling resolution did not produce a hit location.'); } - - damageRolls.push({ - hitLocationDice, - tripodLegRoll, - }); - groups.push({ ...result, damage }); + return { ...result, damage: damageGroups[index] }; }); unit.setPendingFallRolls( trigger.id, - orientationRoll, - damageRolls, + rolled.orientationRoll, + rolled.damageRolls, ); - return { action: 'accept', orientation, groups }; + return { action: 'accept', orientation: rolled.orientation, groups }; } private async applyAcceptedFall( @@ -246,10 +218,6 @@ export class FallingResolutionService { unit.completePendingFall(trigger.id); } - private rollD6(): number { - return Math.floor(Math.random() * 6) + 1; - } - private async reviewHeadHits(unit: CBTForceUnit, count: number): Promise { if (count <= 0) return 0; const events: AutomationReviewEvent[] = Array.from({ length: count }, (_unused, index) => ({ diff --git a/src/app/services/options.service.spec.ts b/src/app/services/options.service.spec.ts index 8c2b98f2e..e26a8ec23 100644 --- a/src/app/services/options.service.spec.ts +++ b/src/app/services/options.service.spec.ts @@ -266,7 +266,7 @@ describe('OptionsService', () => { pilotHitsAndConsciousnessCheck: 'no', internalExplosionsCheck: 'ask', criticalHitChanceCheck: 'no', - breachAndFloodCheck: 'yes', + breachAndFloodCheck: 'no', fallingCheck: 'no', }); }); diff --git a/src/app/services/options.service.ts b/src/app/services/options.service.ts index 34e161c4f..77a0470d3 100644 --- a/src/app/services/options.service.ts +++ b/src/app/services/options.service.ts @@ -49,7 +49,7 @@ const DEFAULT_OPTIONS: Options = { pilotHitsAndConsciousnessCheck: 'no', internalExplosionsCheck: 'ask', criticalHitChanceCheck: 'no', - breachAndFloodCheck: 'yes', + breachAndFloodCheck: 'no', fallingCheck: 'no', }, CBTOptionalRules: { diff --git a/src/app/utils/mek-critical-hit.util.spec.ts b/src/app/utils/mek-critical-hit.util.spec.ts index a267adb6d..6111a8dd6 100644 --- a/src/app/utils/mek-critical-hit.util.spec.ts +++ b/src/app/utils/mek-critical-hit.util.spec.ts @@ -432,6 +432,39 @@ describe('Mek critical-hit workflow', () => { expect(internalHits.get('CT')).toBe(18); }); + it('resolves TW CASE II against printed Hardened Armor points', () => { + const fixture = explodingWeaponUnit(TW_GAME_RULES); + fixture.unit.getCritSlots().forEach(slot => { slot.loc = 'LA'; }); + const caseII = new MiscEquipment({ + id: 'ISCASEII', + name: 'CASE II', + type: 'misc', + flags: ['F_CASE_II'], + }); + fixture.unit.getCritSlots().push({ + id: 'caseii@LA', + name: caseII.name, + loc: 'LA', + slot: 5, + eq: caseII, + }); + const getArmorPoints = fixture.unit.getArmorPoints; + spyOn(fixture.unit, 'getArmorTypeAt').and.callFake(location => + location === 'LA' ? 'HARDENED' : 'STANDARD'); + spyOn(fixture.unit, 'getArmorPoints').and.callFake((location, rear) => + location === 'LA' && !rear ? 18 : getArmorPoints(location, rear)); + + const outcome = applyMekCriticalRoll(fixture.unit, 'LA', [1, 1], true); + + expect(outcome?.explosion?.locations[0]).toEqual(jasmine.objectContaining({ + location: 'LA', + internalDamage: 1, + armorDamage: 10, + protection: 'case-ii', + })); + expect(fixture.armorHits.get('LA')).toBe(10); + }); + it('drops the odd composite remainder instead of transferring fractional explosion damage', () => { const fixture = explodingWeaponUnit(TW_GAME_RULES, ['F_GAUSS'], 'Gauss Rifle', 'composite'); fixture.internalHits.set('LT', 1); @@ -965,6 +998,7 @@ function explodingWeaponUnit( readonly unit: CBTForceUnit; readonly entry: MountedEquipment; readonly internalHits: Map; + readonly armorHits: Map; } { const weapon = new WeaponEquipment({ id: 'TestGauss', diff --git a/src/app/utils/mek-critical-hit.util.ts b/src/app/utils/mek-critical-hit.util.ts index f050fa73c..d8d3fd1f5 100644 --- a/src/app/utils/mek-critical-hit.util.ts +++ b/src/app/utils/mek-critical-hit.util.ts @@ -982,17 +982,21 @@ function resolveMekExplosionLocationDamage( sharedCompositePip = false; const structureKind = unit.getStructureKindAt(location); const torso = MEK_TORSO_LOCATIONS.has(location); - const remainingArmor = Math.max(0, unit.getArmorPoints(location, torso) - unit.getArmorHits(location, torso)); + const armorPoints = unit.getArmorPoints(location, torso); + const remainingArmor = Math.max(0, armorPoints - unit.getArmorHits(location, torso)); + const armorPipsPerPoint = unit.gameRules.id === 'tw' + && protection === 'case-ii' + && unit.getArmorTypeAt(location) === 'HARDENED' ? 2 : 1; const resolution = unit.gameRules.resolveMekExplosionDamage({ damage, protection, remainingInternal: mekStructureDamageCapacity(remainingInternal - sharedInternalDamage, structureKind), - remainingArmor, - originalArmor: unit.getArmorPoints(location, torso), + remainingArmor: Math.ceil(remainingArmor / armorPipsPerPoint), + originalArmor: Math.ceil(armorPoints / armorPipsPerPoint), torso, armorBlowoutPending, }); - const armorDamage = Math.min(remainingArmor, resolution.armorDamage); + const armorDamage = Math.min(remainingArmor, resolution.armorDamage * armorPipsPerPoint); const structureDamage = resolveMekStructureDamage( resolution.internalDamage, remainingInternal - sharedInternalDamage, From 0aec8700c2e78f5cc43ed15b4057c3aa12a4bf4c Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 28 Aug 2026 10:01:43 +0200 Subject: [PATCH 48/87] roll all results button --- .../falling-damage-dialog.component.html | 25 +++++++---- .../falling-damage-dialog.component.scss | 15 +++++++ .../falling-damage-dialog.component.spec.ts | 41 +++++++++++++++++++ .../falling-damage-dialog.component.ts | 17 ++++++++ 4 files changed, 90 insertions(+), 8 deletions(-) diff --git a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.html b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.html index 7aa7e6112..2f3627687 100644 --- a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.html +++ b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.html @@ -15,12 +15,20 @@

{{ armorNote }}

} + +
- +
OrientationDetermine facing and the damage arc.
@@ -43,10 +51,11 @@
- +
Hit locationsResolve each damage group separately.
diff --git a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.scss b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.scss index 0abb43793..d0a124b93 100644 --- a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.scss +++ b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.scss @@ -11,6 +11,21 @@ background-size: 32px; } +.fall-roll-all { + display: flex; + width: 100%; + min-height: 40px; + align-items: center; + justify-content: center; + gap: 8px; +} + +.fall-roll-icon { + width: 24px; + height: 24px; + background: url('/images/random.svg') center / contain no-repeat; +} + .falling-body { display: grid; gap: 10px; diff --git a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.spec.ts b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.spec.ts index f17d5bbfb..3585e9177 100644 --- a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.spec.ts +++ b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.spec.ts @@ -74,4 +74,45 @@ describe('FallingDamageDialogComponent', () => { expect(fixture.componentInstance.groupRows().map(row => row.hitLocationRoll)).toEqual([8, 2]); expect(random).toHaveBeenCalledTimes(10); }); + + it('rolls only orientation from the orientation section button', () => { + fixture.componentInstance.setOrientationRoll(3); + fixture.componentInstance.setHitLocationRoll(0, 5); + fixture.componentInstance.setHitLocationRoll(1, 9); + persistRolls.calls.reset(); + spyOn(Math, 'random').and.returnValue(0.999); + + (fixture.nativeElement as HTMLElement) + .querySelector('.orientation-roll-button')!.click(); + + expect(fixture.componentInstance.orientationRoll()).toBe(6); + expect(fixture.componentInstance.groupRows().map(row => row.hitLocationRoll)).toEqual([5, 9]); + expect(persistRolls).toHaveBeenCalledTimes(1); + }); + + it('rolls only hit locations from the hit-locations section button', () => { + fixture.componentInstance.setOrientationRoll(4); + fixture.componentInstance.setHitLocationRoll(0, 5); + fixture.componentInstance.setHitLocationRoll(1, 9); + fixture.detectChanges(); + persistRolls.calls.reset(); + const random = spyOn(Math, 'random').and.returnValues(0, 0, 0.999, 0.999); + + (fixture.nativeElement as HTMLElement) + .querySelector('.locations-roll-button')!.click(); + + expect(fixture.componentInstance.orientationRoll()).toBe(4); + expect(fixture.componentInstance.groupRows().map(row => row.hitLocationRoll)).toEqual([2, 12]); + expect(random).toHaveBeenCalledTimes(4); + expect(persistRolls).toHaveBeenCalledTimes(1); + }); + + it('shows a labeled roll-all button before the Orientation section', () => { + const element = fixture.nativeElement as HTMLElement; + const rollAll = element.querySelector('.fall-roll-all')!; + const orientationHeading = element.querySelector('.fall-step'); + + expect(rollAll.textContent).toContain('ROLL ALL RESULTS'); + expect(rollAll.nextElementSibling).toBe(orientationHeading); + }); }); diff --git a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.ts b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.ts index 10eb5a828..623525f49 100644 --- a/src/app/components/falling-damage-dialog/falling-damage-dialog.component.ts +++ b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.ts @@ -147,6 +147,23 @@ export class FallingDamageDialogComponent { }); } + rollOrientation(random: () => number = Math.random): void { + const rolled = rollMekFallDice(this.rulesId, this.hitLocationTable, 0, { random }); + this.orientationRoll.set(rolled.orientationRoll); + this.persistRolls(); + } + + rollHitLocations(random: () => number = Math.random): void { + const orientationRoll = this.orientationRoll(); + if (orientationRoll === null) return; + const rolled = rollMekFallDice(this.rulesId, this.hitLocationTable, this.damageGroups.length, { + orientationRoll, + random, + }); + this.groupRolls.set(rolled.damageRolls); + this.persistRolls(); + } + rollAllResults(random: () => number = Math.random): void { const rolled = rollMekFallDice(this.rulesId, this.hitLocationTable, this.damageGroups.length, { random }); this.orientationRoll.set(rolled.orientationRoll); From 64963e20a0b424f47768d1db1bc54e8e5b804753 Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 28 Aug 2026 10:10:37 +0200 Subject: [PATCH 49/87] . --- src/app/utils/mek-falling.util.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/utils/mek-falling.util.ts b/src/app/utils/mek-falling.util.ts index 0f683a2f2..f31f2f68e 100644 --- a/src/app/utils/mek-falling.util.ts +++ b/src/app/utils/mek-falling.util.ts @@ -130,8 +130,8 @@ export function resolveMekFallOrientation(rulesId: MekFallRulesId, roll: number) hitArc: rear ? 'rear' : 'front', hitArcLabel: rear ? 'Rear' : 'Front', rulesExplanation: rear - ? 'The Mek keeps its existing facing; a roll of 1 applies all fall damage to the rear.' - : 'The Mek keeps its existing facing; a roll of 2–6 applies all fall damage to the front.', + ? 'The Mek keeps its existing facing; all fall damage to the rear.' + : 'The Mek keeps its existing facing; all fall damage to the front.', }; } return { roll, ...TW_ORIENTATION[roll] }; From 4956f12088cd060d7a1b6ef5507432f074ba1c42 Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 28 Aug 2026 10:13:20 +0200 Subject: [PATCH 50/87] notification.count --- .../unit-notification-badges.component.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/components/unit-notification-badges/unit-notification-badges.component.html b/src/app/components/unit-notification-badges/unit-notification-badges.component.html index 627fd56df..72e1d6a2c 100644 --- a/src/app/components/unit-notification-badges/unit-notification-badges.component.html +++ b/src/app/components/unit-notification-badges/unit-notification-badges.component.html @@ -35,7 +35,9 @@ - + @if (notification.count > 1) { + + } } @case ('psr') {

{{ unitTitle() }}

@if (!readOnly()) { } @if (!readOnly()) { } @@ -110,4 +116,4 @@

{{ unitTitle() }}

-
\ No newline at end of file +
diff --git a/src/app/components/equipment-dialog/equipment-dialog.component.scss b/src/app/components/equipment-dialog/equipment-dialog.component.scss index e39ca7e9b..6e25243f5 100644 --- a/src/app/components/equipment-dialog/equipment-dialog.component.scss +++ b/src/app/components/equipment-dialog/equipment-dialog.component.scss @@ -98,58 +98,57 @@ } .turn-tracker-title-button { - color: #000; - - svg { - color: #000; - } + --turn-movement-fill: var(--move-unassigned); + --turn-movement-foreground: var(--move-on-dark); svg .octagon { - stroke: #fff; + fill: var(--turn-movement-fill); + stroke: var(--turn-movement-foreground); stroke-width: 1; + transform: scale(1.08); + transform-box: fill-box; + transform-origin: center; + paint-order: stroke fill; + } + + svg { + overflow: visible; } svg text { pointer-events: none; font-weight: bold; - fill: #fff; - } - - svg.ranged .octagon { - fill: var(--phase-ranged); - } - - svg.physical .octagon { - fill: var(--phase-physical); + fill: var(--turn-movement-foreground); } - svg.heat .octagon { - fill: var(--phase-heat); + svg.stationary { + --turn-movement-fill: var(--move-stationary); + --turn-movement-foreground: var(--move-on-dark); } - svg.warning { - color: var(--damage-color); + svg.walk { + --turn-movement-fill: var(--move-walk); + --turn-movement-foreground: var(--move-on-light); } - svg.warning text { - fill: #fff; + svg.run { + --turn-movement-fill: var(--move-run); + --turn-movement-foreground: var(--move-on-dark); } -} -:host-context(html.night-mode) .turn-tracker-title-button { - color: #fff; - - svg { - color: #fff; + svg.jump { + --turn-movement-fill: var(--move-jump); + --turn-movement-foreground: var(--move-on-dark); } - svg text { - fill: #000; + svg.sprint { + --turn-movement-fill: var(--move-sprint); + --turn-movement-foreground: var(--move-on-light); } - svg .octagon { - stroke-width: 0; - stroke: #000; + svg.warning { + --turn-movement-fill: var(--damage-color); + --turn-movement-foreground: var(--move-on-dark); } } @@ -505,4 +504,4 @@ ammo-loadout-panel { .footer-nav { padding: 0px 2px; } -} \ No newline at end of file +} diff --git a/src/app/components/equipment-dialog/equipment-dialog.component.spec.ts b/src/app/components/equipment-dialog/equipment-dialog.component.spec.ts index 61d443efe..20228aec6 100644 --- a/src/app/components/equipment-dialog/equipment-dialog.component.spec.ts +++ b/src/app/components/equipment-dialog/equipment-dialog.component.spec.ts @@ -3,6 +3,7 @@ // Author: Drake import { DialogRef, DIALOG_DATA } from '@angular/cdk/dialog'; +import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Subject } from 'rxjs'; @@ -57,7 +58,6 @@ function createUnit(id: string, entries: MountedEquipment[] = []): CBTForceUnit dirty: () => false, autoFall: () => false, PSRRollsCount: () => 0, - currentPhase: () => '' }); spyOn(harness.unit, 'setHeat').and.callThrough(); spyOn(harness.unit, 'setInventoryEntry').and.callThrough(); @@ -150,6 +150,23 @@ describe('EquipmentDialogComponent', () => { expect(registration.handle(new KeyboardEvent('keydown', { key: 'ArrowRight', ctrlKey: true }))).toBeFalse(); }); + it('shows M until movement is selected, then shows its letter and color', () => { + const unit = createUnit('unit-a'); + const moveMode = signal<'walk' | null>(null); + Object.assign(unit.turnState(), { moveMode }); + const { fixture } = createDialog({ unit, context: createContext() }); + const movementSvg = fixture.nativeElement.querySelector('.turn-tracker-title-button svg') as SVGElement; + + expect(movementSvg.querySelector('text')?.textContent?.trim()).toBe('M'); + expect(movementSvg.classList.contains('walk')).toBeFalse(); + + moveMode.set('walk'); + fixture.detectChanges(); + + expect(movementSvg.querySelector('text')?.textContent?.trim()).toBe('W'); + expect(movementSvg.classList.contains('walk')).toBeTrue(); + }); + it('renders selected weapon actions beside dismiss in the dialog footer', () => { const laser = weaponEntry('laser'); const unit = createUnit('unit-a', [laser]); diff --git a/src/app/components/equipment-dialog/equipment-dialog.component.ts b/src/app/components/equipment-dialog/equipment-dialog.component.ts index 8f09307e2..ab10197a0 100644 --- a/src/app/components/equipment-dialog/equipment-dialog.component.ts +++ b/src/app/components/equipment-dialog/equipment-dialog.component.ts @@ -17,6 +17,7 @@ import type { EquipmentDialogData, EquipmentDialogTab } from './equipment-dialog import { PageInteractionOverlayComponent } from '../page-viewer/overlay/page-interaction-overlay.component'; import { PageTurnSummaryPanelComponent } from '../page-viewer/overlay/page-turn-summary-panel.component'; import { WeaponTargetsOverlayController } from './weapon-targets-overlay.controller'; +import { getTurnMovementIndicator } from '../../utils/turn-movement-indicator.util'; const WEAPON_TARGETS_OVERLAY_KEY = 'weapon-equipment-targets'; const WEAPON_TARGET_CHOICE_OVERLAY_KEY = 'weapon-equipment-target-choice'; @@ -58,6 +59,9 @@ export class EquipmentDialogComponent { readonly unitIndex = signal(this.initialUnitIndex()); readonly unitList = computed(() => this.resolveUnitList()); readonly unit = computed(() => this.unitList()[this.unitIndex()] ?? this.requiredUnit()); + readonly turnSummaryMovement = computed(() => + getTurnMovementIndicator(this.unit().turnState().moveMode()) + ); readonly targets = computed(() => { this.unit().getInventoryControlTargetsMap(); return this.unit().getInventoryControlTargets(); @@ -110,10 +114,6 @@ export class EquipmentDialogComponent { this.activeTab.set(tab); } - turnSummaryDirty(): boolean { - return this.unit().turnState().dirty(); - } - turnSummaryFalling(): boolean { return this.unit().turnState().autoFall(); } @@ -126,10 +126,6 @@ export class EquipmentDialogComponent { return this.unit().turnState().PSRRollsCount(); } - turnSummaryPhase(): string { - return this.unit().turnState().currentPhase(); - } - openTurnSummary(event: MouseEvent): void { event.stopPropagation(); if (this.readOnly()) return; diff --git a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.html b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.html index e818a4974..3dd46b3b5 100644 --- a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.html +++ b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.html @@ -21,16 +21,17 @@ - diff --git a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.scss b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.scss index 65131cb0a..73ee94d75 100644 --- a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.scss +++ b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.scss @@ -23,17 +23,21 @@ height: 100%; } -.overlay-button { - opacity: 0.8; +.overlay-button, +.turn-tracker-button { outline: none; - transition: opacity 0.2s; - color: #000; padding: 0; margin: 0; border: none; background: none; cursor: pointer; pointer-events: auto; +} + +.overlay-button { + opacity: 0.8; + transition: opacity 0.2s; + color: #000; &:hover { opacity: 1.0; @@ -112,51 +116,56 @@ } .turn-tracker-button { + --turn-movement-fill: var(--move-unassigned); + --turn-movement-foreground: var(--move-on-dark); width: 40px; height: 40px; + opacity: 1; svg { width: 100%; height: 100%; + overflow: visible; .octagon { - stroke: white; + fill: var(--turn-movement-fill); + stroke: var(--turn-movement-foreground); stroke-width: 1; + transform: scale(1.13); + transform-box: fill-box; + transform-origin: center; + paint-order: stroke fill; } text { pointer-events: none; font-weight: bold; - fill: #fff; - } - - &.move .octagon { - fill: var(--phase-move); + fill: var(--turn-movement-foreground); } - &.ranged .octagon { - fill: var(--phase-ranged); + &.stationary { + --turn-movement-fill: var(--move-stationary); + --turn-movement-foreground: var(--move-on-dark); } - &.physical .octagon { - fill: var(--phase-physical); + &.walk { + --turn-movement-fill: var(--move-walk); + --turn-movement-foreground: var(--move-on-light); } - &.heat .octagon { - fill: var(--phase-heat); + &.run { + --turn-movement-fill: var(--move-run); + --turn-movement-foreground: var(--move-on-dark); } - } - - :host-context(.night-mode) & { - color: #fff; - - svg text { - fill: #000; + &.jump { + --turn-movement-fill: var(--move-jump); + --turn-movement-foreground: var(--move-on-dark); } - svg .octagon { - stroke-width: 0; + &.sprint { + --turn-movement-fill: var(--move-sprint); + --turn-movement-foreground: var(--move-on-light); } } } diff --git a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.spec.ts b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.spec.ts index 6c1c56d6a..ac393e471 100644 --- a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.spec.ts +++ b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.spec.ts @@ -33,7 +33,7 @@ describe('PageInteractionOverlayComponent pending work', () => { const turnState = { dirty: () => false, dirtyPhase: () => false, - currentPhase: () => 'W', + moveMode: () => null, autoFall: () => false, actionablePSRRollsCount: () => 0, PSRRollsCount: () => 0, diff --git a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.ts b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.ts index 24b567cff..c4535386d 100644 --- a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.ts +++ b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.ts @@ -38,6 +38,7 @@ import { type UnitNotificationActivation, } from '../../unit-notification-badges/unit-notification-badges.component'; import { WeaponTargetsOverlayController } from '../../equipment-dialog/weapon-targets-overlay.controller'; +import { getTurnMovementIndicator } from '../../../utils/turn-movement-indicator.util'; const PAGE_TARGETS_OVERLAY_PREFIX = 'page-viewer-targets'; @@ -95,22 +96,16 @@ export class PageInteractionOverlayComponent { return this.host.nativeElement; } - dirty = computed(() => { - const unit = this.unit(); - if (!unit) return false; - return unit.turnState().dirty(); - }); - dirtyPhase = computed(() => { const unit = this.unit(); if (!unit) return false; return unit.turnState().dirtyPhase(); }); - currentPhase = computed(() => { + movementIndicator = computed(() => { const unit = this.unit(); - if (!unit) return ''; - return unit.turnState().currentPhase(); + if (!unit) return null; + return getTurnMovementIndicator(unit.turnState().moveMode()); }); endTurnButtonVisible = computed(() => { diff --git a/src/app/components/unit-block/unit-block.component.html b/src/app/components/unit-block/unit-block.component.html index 57021b902..4d35791f4 100644 --- a/src/app/components/unit-block/unit-block.component.html +++ b/src/app/components/unit-block/unit-block.component.html @@ -1,17 +1,20 @@ @let unitDisplayName = optionsService.options().unitDisplayName; @let fu = forceUnit(); @let badgeUnit = notificationUnit(); +@let movement = movementIndicator(); @if (compactMode()) {
@if (dirty()) { - @let phase = unitPhase(); -
+
+ } + @if (movement) { + } @if (isCommander()) { +
+ @if (isAlphaStrike()) { + + } @else { + + } +
} @else {
@if (dirty()) { - @let phase = unitPhase(); -
+
+ } + @if (movement) { + } @if (isCommander()) { @if (!isReadOnly && groups.length > 1) {
- {{ filteredRows().length }}/{{ allRows().length }} entries + {{ filteredRows().length }}/{{ filterableRows().length }} entries @if (selectedHeaderTag()) { {{ selectedHeaderTagQuantityTotal() }} total sum } @@ -139,6 +139,22 @@
@for (row of filteredRows(); track row.key) { + @if (row.key === firstUntaggedRowKey() && showUntaggedSeparator()) { +
+ + + UNTAGGED UNITS + +
+ }
} -
- @for (tag of row.tags; track tag.removalKey) { -
- {{ tag.tag }} - - @if (tag.pendingRemoval) { - - } @else { - - } -
- } -
+ @if (row.tags.length > 0) { +
+ @for (tag of row.tags; track tag.removalKey) { +
+ {{ tag.tag }} + + @if (tag.pendingRemoval) { + + } @else { + + } +
+ } +
+ }
} @empty {
{{ getCollectionEmptyStateMessage() }}
diff --git a/src/app/components/collection-dialog/collection-dialog.component.scss b/src/app/components/collection-dialog/collection-dialog.component.scss index fa79ba503..f453494e6 100644 --- a/src/app/components/collection-dialog/collection-dialog.component.scss +++ b/src/app/components/collection-dialog/collection-dialog.component.scss @@ -425,6 +425,47 @@ } } +.untagged-separator { + display: grid; + grid-template-columns: 44px minmax(0, 1fr); + align-items: center; + gap: 12px; + padding: 14px 10px 7px; +} + +.untagged-separator-label { + display: flex; + grid-column: 2; + align-items: center; + gap: 10px; + min-width: 0; + color: var(--text-color-tertiary); + font-size: 0.68rem; + font-weight: 900; + letter-spacing: 0.06em; + + &::before, + &::after { + content: ''; + height: 1px; + background: rgba(255, 255, 255, 0.16); + } + + &::before { + flex: 0 0 28px; + } + + &::after { + flex: 1 1 auto; + } +} + +.untagged-select-all-control { + display: flex; + align-items: center; + cursor: pointer; +} + .target-cell { min-width: 0; } @@ -852,6 +893,11 @@ input[disabled] { padding: 6px 10px; } + .untagged-separator { + grid-template-columns: 20px minmax(0, 1fr); + gap: 8px 10px; + } + .tag-list { grid-column: 2; } diff --git a/src/app/components/collection-dialog/collection-dialog.component.spec.ts b/src/app/components/collection-dialog/collection-dialog.component.spec.ts new file mode 100644 index 000000000..3bea8c67b --- /dev/null +++ b/src/app/components/collection-dialog/collection-dialog.component.spec.ts @@ -0,0 +1,156 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { DialogRef } from '@angular/cdk/dialog'; +import { TestBed } from '@angular/core/testing'; +import type { UnitSummary } from '../../models/unit-summary.model'; +import { DataService } from '../../services/data.service'; +import { DialogsService } from '../../services/dialogs.service'; +import { GameService } from '../../services/game.service'; +import { TagsService } from '../../services/tags.service'; +import { TaggingService } from '../../services/tagging.service'; +import { ToastService } from '../../services/toast.service'; +import { UserStateService } from '../../services/userState.service'; +import { createEmptyUnit } from '../../testing/unit-test-helpers'; +import { CollectionDialogComponent } from './collection-dialog.component'; + +describe('CollectionDialogComponent', () => { + let units: UnitSummary[]; + + beforeEach(async () => { + units = []; + + await TestBed.configureTestingModule({ + imports: [CollectionDialogComponent], + providers: [ + provideZonelessChangeDetection(), + { provide: DialogRef, useValue: { close: jasmine.createSpy('close') } }, + { + provide: DataService, + useValue: { + getUnits: () => units, + tagsVersion: signal(0), + }, + }, + { provide: DialogsService, useValue: {} }, + { provide: GameService, useValue: {} }, + { provide: TagsService, useValue: { version: signal(0) } }, + { provide: TaggingService, useValue: {} }, + { provide: ToastService, useValue: {} }, + { provide: UserStateService, useValue: {} }, + ], + }).compileComponents(); + }); + + function selectOrganization(component: CollectionDialogComponent, organizationUnits: UnitSummary[]): void { + const counts = new Map(); + for (const unit of organizationUnits) { + counts.set(`name:${unit.name}`, 1); + counts.set(`chassis:${TagsService.getChassisTagKey(unit)}`, 1); + } + + component.selectedOrganizationId.set('test-organization'); + component.organizationUnitCounts.set(counts); + } + + it('places untagged TO&E units after tagged entries with a separator', () => { + const nameTagged = createEmptyUnit({ + name: 'Alpha A-1', + chassis: 'Alpha', + model: 'A-1', + _nameTags: [{ tag: 'Owned', quantity: 1 }], + }); + const chassisTagged = createEmptyUnit({ + name: 'Bravo B-1', + chassis: 'Bravo', + model: 'B-1', + _chassisTags: [{ tag: 'Reserve', quantity: 1 }], + }); + const untagged = createEmptyUnit({ + name: 'Charlie C-1', + chassis: 'Charlie', + model: 'C-1', + }); + const secondUntagged = createEmptyUnit({ + name: 'Delta D-1', + chassis: 'Delta', + model: 'D-1', + }); + const outsideOrganization = createEmptyUnit({ + name: 'Echo E-1', + chassis: 'Echo', + model: 'E-1', + }); + units = [nameTagged, chassisTagged, untagged, secondUntagged, outsideOrganization]; + + const fixture = TestBed.createComponent(CollectionDialogComponent); + const component = fixture.componentInstance; + selectOrganization(component, [nameTagged, chassisTagged, untagged, secondUntagged]); + fixture.detectChanges(); + + expect(component.filterableRows().map(row => ({ title: row.title, tags: row.tags.length }))).toEqual([ + { title: 'Alpha A-1', tags: 1 }, + { title: 'Bravo', tags: 1 }, + { title: 'Charlie C-1', tags: 0 }, + { title: 'Delta D-1', tags: 0 }, + ]); + expect(component.firstUntaggedRowKey()).toBe(`name:${untagged.name}`); + + const separator = fixture.nativeElement.querySelector('.untagged-separator') as HTMLElement; + expect(separator.querySelector('.untagged-separator-label')?.textContent?.trim()).toBe('UNTAGGED UNITS'); + expect(fixture.nativeElement.querySelectorAll('.collection-row .tag-list').length).toBe(2); + + component.selectedRows.set(new Set([`name:${nameTagged.name}`])); + const selectAllUntagged = separator.querySelector('.untagged-select-all-control input') as HTMLInputElement; + selectAllUntagged.checked = true; + selectAllUntagged.dispatchEvent(new Event('change')); + fixture.detectChanges(); + + expect(component.selectedRows()).toEqual(new Set([ + `name:${nameTagged.name}`, + `name:${untagged.name}`, + `name:${secondUntagged.name}`, + ])); + expect(component.allVisibleUntaggedSelected()).toBeTrue(); + + selectAllUntagged.checked = false; + selectAllUntagged.dispatchEvent(new Event('change')); + fixture.detectChanges(); + + expect(component.selectedRows()).toEqual(new Set([`name:${nameTagged.name}`])); + + component.unitTextFilter.set('Charlie'); + fixture.detectChanges(); + + expect(component.showUntaggedSeparator()).toBeFalse(); + expect(fixture.nativeElement.querySelector('.untagged-separator')).toBeNull(); + expect(fixture.nativeElement.querySelector('.untagged-select-all-control')).toBeNull(); + }); + + it('keeps a specific tag filter strict when a TO&E contains untagged units', () => { + const tagged = createEmptyUnit({ + name: 'Alpha A-1', + chassis: 'Alpha', + model: 'A-1', + _nameTags: [{ tag: 'Owned', quantity: 1 }], + }); + const untagged = createEmptyUnit({ + name: 'Charlie C-1', + chassis: 'Charlie', + model: 'C-1', + }); + units = [tagged, untagged]; + + const fixture = TestBed.createComponent(CollectionDialogComponent); + const component = fixture.componentInstance; + selectOrganization(component, units); + component.tagFilter.set('Owned'); + fixture.detectChanges(); + + expect(component.filteredRows().map(row => row.title)).toEqual(['Alpha A-1']); + expect(component.firstUntaggedRowKey()).toBe(''); + expect(fixture.nativeElement.querySelector('.untagged-separator')).toBeNull(); + }); +}); diff --git a/src/app/components/collection-dialog/collection-dialog.component.ts b/src/app/components/collection-dialog/collection-dialog.component.ts index a9e993cc3..c922b2a3b 100644 --- a/src/app/components/collection-dialog/collection-dialog.component.ts +++ b/src/app/components/collection-dialog/collection-dialog.component.ts @@ -210,22 +210,64 @@ export class CollectionDialogComponent { return Array.from(tags.values()).sort(naturalCompare); }); + readonly organizationUntaggedRows = computed(() => { + this.tagsService.version(); + this.dataService.tagsVersion(); + + if (!this.selectedOrganizationId()) { + return []; + } + + const organizationUnitCounts = this.organizationUnitCounts(); + const taggedRowKeys = new Set(this.allRows().map(row => row.key)); + const rows = new Map(); + + for (const unit of this.dataService.getUnits()) { + const rowKey = this.getRowKey('name', unit); + const chassisRowKey = this.getRowKey('chassis', unit); + if ((organizationUnitCounts.get(rowKey) ?? 0) === 0 + || taggedRowKeys.has(rowKey) + || taggedRowKeys.has(chassisRowKey)) { + continue; + } + + rows.set(rowKey, { + key: rowKey, + rowType: 'name', + unit, + title: this.getUnitDisplayName(unit), + subtitle: unit.as.TP, + tags: [] + }); + } + + return Array.from(rows.values()) + .sort((left, right) => naturalCompare(left.title, right.title)); + }); + + readonly filterableRows = computed(() => { + const organizationId = this.selectedOrganizationId(); + if (!organizationId) { + return this.allRows(); + } + + const organizationUnitCounts = this.organizationUnitCounts(); + const taggedRows = this.allRows() + .filter(row => (organizationUnitCounts.get(row.key) ?? 0) > 0); + + return [...taggedRows, ...this.organizationUntaggedRows()]; + }); + readonly filteredRows = computed(() => { const tagFilter = this.tagFilter().trim().toLowerCase(); const unitTextFilter = this.unitTextFilter().trim(); - const organizationId = this.selectedOrganizationId(); - const organizationUnitCounts = this.organizationUnitCounts(); const textTokens = parseSearchQuery(unitTextFilter); - let rows = this.allRows(); + let rows = this.filterableRows(); if (tagFilter) { rows = rows.filter(row => row.tags.some(tag => tag.lowerTag === tagFilter)); } - if (organizationId) { - rows = rows.filter(row => (organizationUnitCounts.get(row.key) ?? 0) > 0); - } - if (textTokens.length > 0) { rows = rows.filter(row => matchesSearch(this.getRowSearchText(row), textTokens, true)); } @@ -233,6 +275,27 @@ export class CollectionDialogComponent { return rows; }); + readonly visibleUntaggedRows = computed(() => { + return this.filteredRows().filter(row => row.tags.length === 0); + }); + + readonly firstUntaggedRowKey = computed(() => this.visibleUntaggedRows()[0]?.key ?? ''); + + readonly showUntaggedSeparator = computed(() => { + const untaggedCount = this.visibleUntaggedRows().length; + return untaggedCount > 0 && untaggedCount < this.filteredRows().length; + }); + + readonly allVisibleUntaggedSelected = computed(() => { + const rows = this.visibleUntaggedRows(); + if (rows.length === 0) { + return false; + } + + const selected = this.selectedRows(); + return rows.every(row => selected.has(row.key)); + }); + readonly selectedCount = computed(() => { const selected = this.selectedRows(); return this.filteredRows().filter(row => selected.has(row.key)).length; @@ -815,11 +878,19 @@ export class CollectionDialogComponent { toggleAllFiltered(event: Event): void { const checked = (event.target as HTMLInputElement).checked; - const rows = this.filteredRows(); + this.setRowsSelected(this.filteredRows(), checked); + } + + toggleAllVisibleUntagged(event: Event): void { + const checked = (event.target as HTMLInputElement).checked; + this.setRowsSelected(this.visibleUntaggedRows(), checked); + } + + private setRowsSelected(rows: readonly CollectionRow[], selected: boolean): void { this.selectedRows.update(current => { const next = new Set(current); for (const row of rows) { - if (checked) { + if (selected) { next.add(row.key); } else { next.delete(row.key); From 689976c1cc7842d2181db236b10e578c99db5bf8 Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 28 Aug 2026 21:09:43 +0200 Subject: [PATCH 63/87] tons in overview --- .../force-overview-dialog.component.html | 10 ++++++++-- .../force-overview-dialog.component.scss | 10 ++++++++-- .../force-overview-dialog.component.ts | 6 ++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/app/components/force-overview-dialog/force-overview-dialog.component.html b/src/app/components/force-overview-dialog/force-overview-dialog.component.html index 5931e22a7..2e9082e15 100644 --- a/src/app/components/force-overview-dialog/force-overview-dialog.component.html +++ b/src/app/components/force-overview-dialog/force-overview-dialog.component.html @@ -160,7 +160,10 @@

- {{ isAlphaStrike() ? 'PV' : 'BV' }}: {{ displayedBvPv(row.group.units()) }} + + {{ totalTons(row.group.units()) | formatTons }} tons · + {{ isAlphaStrike() ? 'PV' : 'BV' }}: {{ displayedBvPv(row.group.units()) }} +

} @@ -258,7 +261,10 @@

- {{ isAlphaStrike() ? 'PV' : 'BV' }}: {{ displayedBvPv(group.units()) }} + + {{ totalTons(group.units()) | formatTons }} tons · + {{ isAlphaStrike() ? 'PV' : 'BV' }}: {{ displayedBvPv(group.units()) }} +

@let units = group.units(); diff --git a/src/app/components/force-overview-dialog/force-overview-dialog.component.scss b/src/app/components/force-overview-dialog/force-overview-dialog.component.scss index 19ee66160..201e1c722 100644 --- a/src/app/components/force-overview-dialog/force-overview-dialog.component.scss +++ b/src/app/components/force-overview-dialog/force-overview-dialog.component.scss @@ -371,8 +371,11 @@ .group-bv { font-size: 0.85rem; - font-weight: bold; color: var(--text-color); + + .value { + font-weight: bold; + } } .group-units { @@ -679,8 +682,11 @@ .table-group-bv { font-size: 0.85rem; - font-weight: bold; color: var(--text-color); + + .value { + font-weight: bold; + } } /* Table cell content styles */ diff --git a/src/app/components/force-overview-dialog/force-overview-dialog.component.ts b/src/app/components/force-overview-dialog/force-overview-dialog.component.ts index 758a9a353..c863b0209 100644 --- a/src/app/components/force-overview-dialog/force-overview-dialog.component.ts +++ b/src/app/components/force-overview-dialog/force-overview-dialog.component.ts @@ -37,6 +37,7 @@ import { LongPressDirective } from '../../directives/long-press.directive'; import { FORCE_NOTE_MAX_LENGTH } from '../../models/force-serialization'; import { naturalCompare } from '../../utils/sort.util'; import { formatBvPv } from '../../utils/force-viewer-bv-pv-display.util'; +import { FormatTonsPipe } from '../../pipes/format-tons.pipe'; import { buildUnitDataTableColumns, formatAlphaStrikeUnitMovement, @@ -98,6 +99,7 @@ export const DEFAULT_OVERVIEW_STATE: OverviewState = { DataTableComponent, TooltipDirective, LongPressDirective, + FormatTonsPipe, ], host: { class: 'fullscreen-dialog-host fullheight tv-fade' @@ -293,6 +295,10 @@ export class ForceOverviewDialogComponent { ); } + totalTons(units: readonly ForceUnit[]): number { + return units.reduce((total, unit) => total + unit.getUnit().tons, 0); + } + displayedUnitBvPv(unit: ForceUnit): string { return formatBvPv( unit.getBv(), From 7fafc639de585237a1a1a6bd5c4a68dd1f38b31e Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 28 Aug 2026 21:17:00 +0200 Subject: [PATCH 64/87] overview fixes --- .../force-overview-dialog.component.scss | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/components/force-overview-dialog/force-overview-dialog.component.scss b/src/app/components/force-overview-dialog/force-overview-dialog.component.scss index 201e1c722..7c70e0dc5 100644 --- a/src/app/components/force-overview-dialog/force-overview-dialog.component.scss +++ b/src/app/components/force-overview-dialog/force-overview-dialog.component.scss @@ -281,11 +281,12 @@ justify-content: space-between; position: sticky; top: 0; - z-index: 1; + z-index: 4; align-items: center; padding: 6px 12px; background-color: var(--background-color-menu); cursor: grab; + flex-wrap: wrap; } .group-name-area { @@ -294,8 +295,6 @@ justify-content: flex-start; flex-wrap: wrap; gap: 0px 4px; - flex: 1; - min-width: 0; } .group-name { @@ -367,6 +366,8 @@ display: flex; align-items: center; gap: 12px; + justify-content: end; + margin-left: auto; } .group-bv { From 9291a3b1e4c419cee939dda219813a4f93c00f67 Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 28 Aug 2026 21:24:33 +0200 Subject: [PATCH 65/87] fixed drag'n'drop in overview --- .../force-overview-dialog.component.html | 2 +- .../force-overview-dialog.component.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/components/force-overview-dialog/force-overview-dialog.component.html b/src/app/components/force-overview-dialog/force-overview-dialog.component.html index 2e9082e15..6a1614d65 100644 --- a/src/app/components/force-overview-dialog/force-overview-dialog.component.html +++ b/src/app/components/force-overview-dialog/force-overview-dialog.component.html @@ -270,7 +270,7 @@

{ const ids: string[] = []; for (const g of this.data.force.groups()) { - ids.push(`group-${g.id}`); + ids.push(`overview-group-${g.id}`); } if (this.newGroupDropzone()?.nativeElement) { ids.push('new-group-dropzone'); @@ -833,7 +833,7 @@ export class ForceOverviewDialogComponent { const force = this.data.force; const groups = force.groups(); - const groupIdFromContainer = (id?: string) => id && id.startsWith('group-') ? id.substring('group-'.length) : null; + const groupIdFromContainer = (id?: string) => id && id.startsWith('overview-group-') ? id.substring('overview-group-'.length) : null; const fromGroupId = groupIdFromContainer(event.previousContainer?.id); const toGroupId = groupIdFromContainer(event.container?.id); @@ -871,9 +871,9 @@ export class ForceOverviewDialogComponent { if (!newGroup) return; const prevId = event.previousContainer?.id; - if (!prevId || !prevId.startsWith('group-')) return; + if (!prevId || !prevId.startsWith('overview-group-')) return; - const sourceGroupId = prevId.substring('group-'.length); + const sourceGroupId = prevId.substring('overview-group-'.length); const sourceGroup = force.groups().find(g => g.id === sourceGroupId); if (!sourceGroup) return; From ecaa1e32805fcd167771035c86212d49fc9e260a Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 28 Aug 2026 22:10:10 +0200 Subject: [PATCH 66/87] Sr color --- src/styles.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles.scss b/src/styles.scss index d4a007b52..715a5115b 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -56,7 +56,7 @@ --move-walk: #eee; --move-run: #333; --move-jump: #c00; - --move-sprint: #dd0; + --move-sprint: #e6e600; --move-on-dark: #fff; --move-on-light: #111; From ca93cda568be30758f8caa45d8a153cc8df00f3b Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 28 Aug 2026 22:10:55 +0200 Subject: [PATCH 67/87] Sr options --- .../options-dialog.component.html | 21 +++++++++++++------ .../options-dialog.component.spec.ts | 2 ++ src/app/models/options.model.ts | 1 + src/app/services/options.service.spec.ts | 4 ++++ src/app/services/options.service.ts | 2 ++ 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/app/components/options-dialog/options-dialog.component.html b/src/app/components/options-dialog/options-dialog.component.html index 9c86d402e..b5824bb95 100644 --- a/src/app/components/options-dialog/options-dialog.component.html +++ b/src/app/components/options-dialog/options-dialog.component.html @@ -573,12 +573,12 @@

- - + +
@@ -590,6 +590,15 @@
+
+ + +
} @case ('Alpha Strike') { diff --git a/src/app/components/options-dialog/options-dialog.component.spec.ts b/src/app/components/options-dialog/options-dialog.component.spec.ts index 16fa9e960..8c370e1fe 100644 --- a/src/app/components/options-dialog/options-dialog.component.spec.ts +++ b/src/app/components/options-dialog/options-dialog.component.spec.ts @@ -106,6 +106,7 @@ describe('OptionsDialogComponent', () => { forcedWithdrawal: true, extremeRange: false, floatingCriticals: true, + sprinting: false, }, }), setOption, @@ -120,6 +121,7 @@ describe('OptionsDialogComponent', () => { forcedWithdrawal: false, extremeRange: false, floatingCriticals: true, + sprinting: false, }); }); diff --git a/src/app/models/options.model.ts b/src/app/models/options.model.ts index ee7680199..1e56ebc5c 100644 --- a/src/app/models/options.model.ts +++ b/src/app/models/options.model.ts @@ -80,6 +80,7 @@ export interface CBTOptionalRules { floatingCriticals: boolean; forcedWithdrawal: boolean; extremeRange: boolean; + sprinting: boolean; } export interface Options { diff --git a/src/app/services/options.service.spec.ts b/src/app/services/options.service.spec.ts index e26a8ec23..d06e3f9ef 100644 --- a/src/app/services/options.service.spec.ts +++ b/src/app/services/options.service.spec.ts @@ -189,6 +189,7 @@ describe('OptionsService', () => { forcedWithdrawal: true, extremeRange: false, floatingCriticals: false, + sprinting: false, }); expect(service.options().lastCanvasState).toBeUndefined(); expect(service.options().sidebarLipPosition).toBeUndefined(); @@ -251,6 +252,7 @@ describe('OptionsService', () => { forcedWithdrawal: true, extremeRange: false, floatingCriticals: false, + sprinting: false, }); }); @@ -315,6 +317,7 @@ describe('OptionsService', () => { forcedWithdrawal: false, extremeRange: true, floatingCriticals: true, + sprinting: true, }, }; @@ -324,6 +327,7 @@ describe('OptionsService', () => { forcedWithdrawal: false, extremeRange: true, floatingCriticals: true, + sprinting: true, }); }); diff --git a/src/app/services/options.service.ts b/src/app/services/options.service.ts index 77a0470d3..1b68cdb3f 100644 --- a/src/app/services/options.service.ts +++ b/src/app/services/options.service.ts @@ -56,6 +56,7 @@ const DEFAULT_OPTIONS: Options = { floatingCriticals: false, forcedWithdrawal: true, extremeRange: false, + sprinting: false, }, allowMultipleActiveSheets: false, CBTRules: 'tw', @@ -192,6 +193,7 @@ function resolveCBTOptionalRules(saved: Options | null | undefined): CBTOptional floatingCriticals: resolveSavedValue(saved?.CBTOptionalRules?.floatingCriticals, defaults.floatingCriticals), forcedWithdrawal: resolveSavedValue(saved?.CBTOptionalRules?.forcedWithdrawal, defaults.forcedWithdrawal), extremeRange: resolveSavedValue(saved?.CBTOptionalRules?.extremeRange, defaults.extremeRange), + sprinting: resolveSavedValue(saved?.CBTOptionalRules?.sprinting, defaults.sprinting), }; } From 90dc75375f59d3ee49ea763e55d304ab9aa20381 Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 28 Aug 2026 22:32:56 +0200 Subject: [PATCH 68/87] sprint --- .../tn-calculator-dialog.component.ts | 9 +- .../page-turn-summary-panel.component.html | 2 +- .../page-turn-summary-panel.component.scss | 14 +++ .../page-turn-summary-panel.component.spec.ts | 68 +++++++++++- .../page-turn-summary-panel.component.ts | 12 ++- .../overlay/page-turn-summary.util.spec.ts | 4 +- .../overlay/page-turn-summary.util.ts | 2 +- src/app/models/cbt-force-unit.model.spec.ts | 1 + src/app/models/cbt-force-unit.model.ts | 4 + src/app/models/force-serialization.spec.ts | 7 ++ src/app/models/force-serialization.ts | 2 +- src/app/models/motiveModes.model.spec.ts | 15 ++- src/app/models/motiveModes.model.ts | 15 ++- src/app/models/rules/mek-rules.spec.ts | 92 ++++++++++++++++ src/app/models/rules/mek-rules.ts | 102 ++++++++++++++---- src/app/models/rules/tw-rules.ts | 6 +- src/app/models/rules/unit-type-rules.ts | 3 +- .../turn-movement-indicator.util.spec.ts | 4 +- src/app/utils/turn-movement-indicator.util.ts | 7 +- 19 files changed, 323 insertions(+), 46 deletions(-) diff --git a/src/app/components/equipment-dialog/tn-calculator-dialog.component.ts b/src/app/components/equipment-dialog/tn-calculator-dialog.component.ts index 30e687763..efe554aeb 100644 --- a/src/app/components/equipment-dialog/tn-calculator-dialog.component.ts +++ b/src/app/components/equipment-dialog/tn-calculator-dialog.component.ts @@ -765,6 +765,10 @@ export interface TnCalculatorDialogResult { transition: border 0.2s ease-in-out, background 0.2s ease-in-out, color 0.2s ease-in-out; } + .bt-button[disabled] .modifier-badge { + opacity: 0.4; + } + .bt-button.move-button { pointer-events: auto; cursor: pointer; @@ -785,11 +789,6 @@ export interface TnCalculatorDialogResult { background-color: #000; } - .bt-button.move-button:disabled { - cursor: not-allowed; - opacity: 0.7; - } - .derived-target-state .bt-button, .derived-target-state .bt-button:hover, .derived-target-state .bt-button:active, diff --git a/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.html b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.html index f97afe820..a455a1ee1 100644 --- a/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.html +++ b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.html @@ -31,7 +31,7 @@ } @if (canSwitchAirborneMode() === false || airborne() !== null) { -
+
@for (moveMode of moveModes(); track moveMode.mode) { @let mode = moveMode.mode;
- @if (tracksHeat()) { -
-

Heat

-
- @for (row of heatRows(); track row.id) { -
- - {{ row.label }}@if (row.selectedValue !== undefined) { (Selected)}: - - - {{ (row.value > 0 ? '+' : '') + row.value }}@if (row.selectedValue !== undefined) { ({{ (row.selectedValue > 0 ? '+' : '') + row.selectedValue }})} - -
- } -
-
+ @if (tracksHeat() && heatRows(); as heatRows) { + @if (heatRows.length > 0) { +
+

Heat

+
+ @for (row of heatRows; track row.id) { +
+ + {{ row.label }}@if (row.selectedValue !== undefined) { (Selected)}: + + + {{ (row.value > 0 ? '+' : '') + row.value }}@if (row.selectedValue !== undefined) { ({{ (row.selectedValue > 0 ? '+' : '') + row.selectedValue }})} + +
+ } +
+
+ } }

Other

- Damage received: + Damage received this phase: {{ damageReceived() }}
diff --git a/src/app/components/page-viewer/overlay/page-turn-summary.util.spec.ts b/src/app/components/page-viewer/overlay/page-turn-summary.util.spec.ts index a6cc3b7f1..6339c3a66 100644 --- a/src/app/components/page-viewer/overlay/page-turn-summary.util.spec.ts +++ b/src/app/components/page-viewer/overlay/page-turn-summary.util.spec.ts @@ -100,17 +100,18 @@ describe('composeTurnSummaryHeatRows', () => { ]); }); - it('combines passive equipment heat into one Equipment row', () => { + it('keeps compactly grouped equipment sources separate in the detailed turn summary', () => { expect(composeTurnSummaryHeatRows( [ - { id: 'equipment:null-signature', label: 'Equipment', value: 10 }, + { id: 'stealth:null-signature', label: 'Stealth', value: 10, group: 'Equipment' }, { id: 'engine', label: 'Engine', value: 5 }, - { id: 'equipment:chameleon-lps', label: 'Equipment', value: 6 }, + { id: 'nova-cews', label: 'Nova CEWS', value: 2, group: 'Equipment' }, ], { hasSelection: false, value: 0, entryIds: new Set() } )).toEqual([ - { id: 'equipment', label: 'Equipment', value: 16 }, + { id: 'stealth:null-signature', label: 'Stealth', value: 10 }, { id: 'engine', label: 'Engine', value: 5 }, + { id: 'nova-cews', label: 'Nova CEWS', value: 2 }, ]); }); diff --git a/src/app/components/page-viewer/overlay/page-turn-summary.util.ts b/src/app/components/page-viewer/overlay/page-turn-summary.util.ts index 349cade4c..19fde2450 100644 --- a/src/app/components/page-viewer/overlay/page-turn-summary.util.ts +++ b/src/app/components/page-viewer/overlay/page-turn-summary.util.ts @@ -43,6 +43,7 @@ export function composeTurnSummaryHeatRows( ): TurnSummaryHeatRow[] { const rows: TurnSummaryHeatRow[] = []; for (const source of sources) { + if (source.value === 0) continue; //We skip entries with 0 heat const rowIndex = rows.findIndex(row => row.label === source.label); if (rowIndex >= 0) { const row = rows[rowIndex]; diff --git a/src/app/components/unit-block/unit-block.component.ts b/src/app/components/unit-block/unit-block.component.ts index 967336bfd..eadb9ab42 100644 --- a/src/app/components/unit-block/unit-block.component.ts +++ b/src/app/components/unit-block/unit-block.component.ts @@ -13,7 +13,6 @@ import { UnitIconComponent } from '../unit-icon/unit-icon.component'; import { CBTForceUnit } from '../../models/cbt-force-unit.model'; import { TooltipDirective } from '../../directives/tooltip.directive'; import type { TooltipLine } from '../tooltip/tooltip.component'; -import { ECMMode } from '../../models/common.model'; import { ASForceUnit } from '../../models/as-force-unit.model'; import { C3Capabilities, C3Network, c3NetworkTypeName, type C3Component, type C3NetworkType } from '../../models/c3-network.model'; import { GameSystem } from '../../models/common.model'; @@ -22,6 +21,7 @@ import { getUnitConditionDefinition, unitConditionSortIndex } from '../../models import { formatBvPv } from '../../utils/force-viewer-bv-pv-display.util'; import { UnitNotificationBadgesComponent } from '../unit-notification-badges/unit-notification-badges.component'; import { getTurnMovementIndicator } from '../../utils/turn-movement-indicator.util'; +import { getEcmDisplay, getTagDisplay } from '../../utils/force-viewer-electronics-display.util'; interface UnitConditionDisplay { key: string; @@ -29,11 +29,6 @@ interface UnitConditionDisplay { color: string; } -interface ECMDisplay { - mode: ECMMode | string; - unavailable: boolean; -} - export interface UnitBlockPilotEditEvent { event: MouseEvent; } @@ -169,51 +164,9 @@ export class UnitBlockComponent { return [...unitConditions, ...crewConditions, ...locationConditions]; }); - tagDisplay = computed<{ label: 'TAG' | 'LTAG'; unavailable: boolean } | undefined>(() => { - const forceUnit = this.forceUnit(); - if (!forceUnit) return undefined; - if (forceUnit instanceof ASForceUnit) { - const specials = forceUnit.getUnit().as.specials; - if (specials.includes('TAG')) { - return { label: 'TAG', unavailable: false }; - } - if (specials.includes('LTAG')) { - return { label: 'LTAG', unavailable: false }; - } - return undefined; - } else - if (forceUnit instanceof CBTForceUnit) { - const tagMounts = forceUnit.getMountedEquipmentByFlag('F_TAG'); - if (tagMounts.length === 0) return undefined; - const tag = tagMounts.find(mount => mount.owner.canPerformEquipmentAction(mount, 'activate')) ?? tagMounts[0]; - const names = [tag.name, tag.equipment?.name, tag.equipment?.shortName, tag.equipment?.sortingName] - .filter((name): name is string => !!name); - return { - label: names.some(name => /\blight\b/i.test(name)) ? 'LTAG' : 'TAG', - unavailable: tagMounts.every(mount => !mount.owner.canPerformEquipmentAction(mount, 'activate')), - }; - } - return undefined; - }); + tagDisplay = computed(() => getTagDisplay(this.forceUnit())); - ecmDisplay = computed(() => { - const forceUnit = this.forceUnit(); - if (!forceUnit) return null; - if (forceUnit instanceof ASForceUnit) { - const mode = forceUnit.getUnit().as.specials.find(spec => spec === 'ECM' || spec === 'AECM' || spec === 'LECM'); - return mode ? { mode, unavailable: false } : null; - } - if (forceUnit instanceof CBTForceUnit) { - const ecms = forceUnit.getMountedEquipmentByFlag('F_ECM'); - if (ecms.length === 0) return null; - const mount = ecms.find(candidate => candidate.owner.canPerformEquipmentAction(candidate, 'activate')) ?? ecms[0]; - return { - mode: mount.states.get('ecm_mode') as ECMMode || ECMMode.ECM, - unavailable: ecms.every(candidate => !candidate.owner.canPerformEquipmentAction(candidate, 'activate')), - }; - } - return null; - }); + ecmDisplay = computed(() => getEcmDisplay(this.forceUnit())); /** Get individual C3 network items for display */ c3NetworkItems = computed<{ label: string; networkType: C3NetworkType; enabled: boolean; unavailable: boolean; color?: string }[]>(() => { diff --git a/src/app/equipment-handlers/bap.handler.ts b/src/app/equipment-handlers/bap.handler.ts index e6443286a..f52265fbe 100644 --- a/src/app/equipment-handlers/bap.handler.ts +++ b/src/app/equipment-handlers/bap.handler.ts @@ -3,13 +3,19 @@ // Author: Drake import { EquipmentFlag } from '../models/equipment-flags.type'; +import type { MountedEquipment } from '../models/mounted-equipment.model'; import { ToggleHandler } from './base/toggle.handler'; export class BAPHandler extends ToggleHandler { readonly id = 'bap-handler'; override readonly flags: EquipmentFlag[] = ['F_BAP']; override readonly priority = 10; + + override applicableTo(equipment: MountedEquipment): boolean { + // Nova CEWS powers its probe together with its ECM and C3 functions. + return equipment.equipment?.flags.has('F_NOVA') !== true; + } protected override readonly enabledLabel = 'Active Probe is ON'; protected override readonly disabledLabel = 'Active Probe is OFF'; -} \ No newline at end of file +} diff --git a/src/app/equipment-handlers/c3.handler.ts b/src/app/equipment-handlers/c3.handler.ts index 182273329..8804d3b15 100644 --- a/src/app/equipment-handlers/c3.handler.ts +++ b/src/app/equipment-handlers/c3.handler.ts @@ -13,13 +13,13 @@ export class C3Handler extends EquipmentInteractionHandler { override readonly flags: EquipmentFlag[] = ['ANY_C3']; override readonly priority = 10; - getChoices(equipment: MountedEquipment, _context: HandlerQueryContext): HandlerChoice[] { + getChoices(_equipment: MountedEquipment, _context: HandlerQueryContext): HandlerChoice[] { return [ { label: 'Configure', value: 'c3-network-configuration', action: 'configure-network', - readOnlySafe: _context.isReadOnly(equipment), + readOnlySafe: true, displayType: 'button' } ]; diff --git a/src/app/equipment-handlers/coolant-system-failure.util.ts b/src/app/equipment-handlers/coolant-system-failure.util.ts index d6fd2ee9a..243a9533c 100644 --- a/src/app/equipment-handlers/coolant-system-failure.util.ts +++ b/src/app/equipment-handlers/coolant-system-failure.util.ts @@ -5,7 +5,7 @@ import { WeaponEquipment } from '../models/equipment.model'; import type { MountedEquipment } from '../models/mounted-equipment.model'; import type { TurnState } from '../models/turn-state.model'; -import type { UnitHeatSource } from '../models/rules/unit-type-rules'; +import { EQUIPMENT_HEAT_SOURCE_GROUP, type UnitHeatSource } from '../models/rules/unit-type-rules'; import { isEquipmentDisabledByFailure } from './disabled-equipment.handler'; /** Heat leaked by a failed/damaged RHS or RISC emergency coolant system. */ @@ -22,7 +22,7 @@ export function getFailedCoolantSystemHeatSources( const sources: UnitHeatSource[] = []; const moveMode = turnState.effectiveMoveMode(); if (moveMode !== null && moveMode !== 'stationary') { - sources.push({ id: `${sourceId}:movement`, label, value: 1 }); + sources.push({ id: `${sourceId}:movement`, label, value: 1, group: EQUIPMENT_HEAT_SOURCE_GROUP }); } const selectedWeapon = equipment.owner.getInventory().some(entry => @@ -30,7 +30,7 @@ export function getFailedCoolantSystemHeatSources( && (equipment.owner.isInventoryControlEntrySelected?.(entry.id) ?? false) ); if (turnState.weaponsHeat() > 0 || selectedWeapon) { - sources.push({ id: `${sourceId}:weapons`, label, value: 1 }); + sources.push({ id: `${sourceId}:weapons`, label, value: 1, group: EQUIPMENT_HEAT_SOURCE_GROUP }); } return sources; } diff --git a/src/app/equipment-handlers/ecm.handler.ts b/src/app/equipment-handlers/ecm.handler.ts index e8cb9878e..8c093ce8c 100644 --- a/src/app/equipment-handlers/ecm.handler.ts +++ b/src/app/equipment-handlers/ecm.handler.ts @@ -9,14 +9,20 @@ import { ECMMode } from '../models/common.model'; import { EquipmentFlag } from '../models/equipment-flags.type'; import type { CBTForceUnit } from '../models/cbt-force-unit.model'; import { unitHasActiveC3DisruptingStealth } from '../models/stealth-equipment.model'; +import { ECM_MODE_STATE_KEY, isEcmModeActive } from '../utils/ecm-state.util'; -export const ECM_MODE_STATE_KEY = 'ecm_mode'; +export { ECM_MODE_STATE_KEY } from '../utils/ecm-state.util'; export class ECMHandler extends EquipmentInteractionHandler { readonly id = 'ecm-handler'; override readonly flags: EquipmentFlag[] = ['F_ECM']; override readonly priority = 10; + override applicableTo(equipment: MountedEquipment): boolean { + // Nova CEWS has one shared ECM/Active Probe/C3 power state. + return equipment.equipment?.flags.has('F_NOVA') !== true; + } + private getDefaultMode(): string { return ECMMode.ECM; } @@ -75,7 +81,6 @@ export class ECMHandler extends EquipmentInteractionHandler { isActive(equipment: MountedEquipment): boolean { if (unitHasActiveC3DisruptingStealth(equipment.owner as CBTForceUnit)) return false; - const ecmMode = equipment.states?.get(ECM_MODE_STATE_KEY); - return (ecmMode || ECMMode.ECM) !== ECMMode.OFF; + return isEcmModeActive(equipment); } } diff --git a/src/app/equipment-handlers/escalating-equipment.handler.spec.ts b/src/app/equipment-handlers/escalating-equipment.handler.spec.ts index 934f999b7..fa3e11863 100644 --- a/src/app/equipment-handlers/escalating-equipment.handler.spec.ts +++ b/src/app/equipment-handlers/escalating-equipment.handler.spec.ts @@ -123,8 +123,8 @@ describe('additional escalating-failure equipment handlers', () => { entry.commitPendingDestroyed(); expect(handler.getInventoryHeatSources(entry, turnState, queryContext)).toEqual([ - { id: 'radical-heat-sink:F_RADICAL_HEATSINK:movement', label: 'Radical Heat Sink leak', value: 1 }, - { id: 'radical-heat-sink:F_RADICAL_HEATSINK:weapons', label: 'Radical Heat Sink leak', value: 1 }, + { id: 'radical-heat-sink:F_RADICAL_HEATSINK:movement', label: 'Radical Heat Sink leak', value: 1, group: 'Equipment' }, + { id: 'radical-heat-sink:F_RADICAL_HEATSINK:weapons', label: 'Radical Heat Sink leak', value: 1, group: 'Equipment' }, ]); }); @@ -196,6 +196,7 @@ describe('additional escalating-failure equipment handlers', () => { id: 'risc-viral-jammer:F_VIRAL_JAMMER_DECOY', label: 'RISC Viral Jammer', value: 12, + group: 'Equipment', }]); handler.onEndTurn(decoy, notifications()); diff --git a/src/app/equipment-handlers/index.ts b/src/app/equipment-handlers/index.ts index 69e987065..869d23804 100644 --- a/src/app/equipment-handlers/index.ts +++ b/src/app/equipment-handlers/index.ts @@ -3,33 +3,34 @@ // Author: Drake import type { EquipmentInteractionRegistryService } from '../services/equipment-interaction-registry.service'; -import { ECMHandler } from './ecm.handler'; +import { ApolloHandler } from './apollo.handler'; +import { ArtemisVHandler } from './artemis-v.handler'; +import { AtmHandler } from './atm.handler'; import { BAPHandler } from './bap.handler'; -import { GaussPowerHandler } from './gauss-power.handler'; -import { StealthHandler } from './stealth.handler'; -import { UACJammingHandler } from './uacjamming.handler'; -import { UACFiringModeHandler } from './uac-firing-mode.handler'; +import { BlueShieldHandler } from './blue-shield.handler'; +import { BombastLaserHandler } from './bombast-laser.handler'; +import { C3EmergencyMasterHandler } from './c3-emergency-master.handler'; import { C3Handler } from './c3.handler'; +import { ECMHandler } from './ecm.handler'; +import { FlamerHandler } from './flamer.handler'; +import { GaussPowerHandler } from './gauss-power.handler'; +import { HagHandler } from './hag.handler'; import { InventoryModeHandler } from './inventory-mode.handler'; -import { PpcCapacitorHandler } from './ppc-capacitor.handler'; -import { MmlHandler } from './mml.handler'; -import { AtmHandler } from './atm.handler'; -import { ArtemisVHandler } from './artemis-v.handler'; -import { ApolloHandler } from './apollo.handler'; import { LaserInsulatorHandler } from './laser-insulator.handler'; -import { RiscLaserPulseModuleHandler } from './risc-laser-pulse-module.handler'; -import { HagHandler } from './hag.handler'; import { MascHandler } from './masc.handler'; +import { MmlHandler } from './mml.handler'; +import { NovaCewsHandler } from './nova-cews.handler'; +import { PpcCapacitorHandler } from './ppc-capacitor.handler'; +import { PrecisionAmmoHandler } from './precision-ammo.handler'; import { RadicalHeatSinkHandler } from './radical-heat-sink.handler'; -import { BlueShieldHandler } from './blue-shield.handler'; import { RiscEmergencyCoolantSystemHandler } from './risc-emergency-coolant-system.handler'; +import { RiscLaserPulseModuleHandler } from './risc-laser-pulse-module.handler'; import { RiscViralJammerHandler } from './risc-viral-jammer.handler'; -import { VibrobladeHandler } from './vibroblade.handler'; -import { BombastLaserHandler } from './bombast-laser.handler'; +import { StealthHandler } from './stealth.handler'; import { TwBombastLaserHandler } from './tw-bombast-laser.handler'; -import { C3EmergencyMasterHandler } from './c3-emergency-master.handler'; -import { FlamerHandler } from './flamer.handler'; -import { PrecisionAmmoHandler } from './precision-ammo.handler'; +import { UACFiringModeHandler } from './uac-firing-mode.handler'; +import { UACJammingHandler } from './uacjamming.handler'; +import { VibrobladeHandler } from './vibroblade.handler'; /** * Register all equipment handlers. @@ -37,34 +38,35 @@ import { PrecisionAmmoHandler } from './precision-ammo.handler'; */ export function registerAllHandlers(registryService: EquipmentInteractionRegistryService): void { const registry = registryService.getRegistry(); - + // Register all handlers - registry.register(new ECMHandler()); + registry.register(new ApolloHandler()); + registry.register(new ArtemisVHandler()); + registry.register(new AtmHandler()); registry.register(new BAPHandler()); + registry.register(new BlueShieldHandler()); + registry.register(new BombastLaserHandler()); + registry.register(new C3EmergencyMasterHandler()); + registry.register(new C3Handler()); + registry.register(new ECMHandler()); + registry.register(new FlamerHandler()); registry.register(new GaussPowerHandler()); - registry.register(new StealthHandler()); + registry.register(new HagHandler()); registry.register(new InventoryModeHandler()); - registry.register(new MmlHandler()); - registry.register(new AtmHandler()); - registry.register(new ArtemisVHandler()); - registry.register(new ApolloHandler()); - registry.register(new VibrobladeHandler()); registry.register(new LaserInsulatorHandler()); - registry.register(new RiscLaserPulseModuleHandler()); - registry.register(new HagHandler()); registry.register(new MascHandler()); + registry.register(new MmlHandler()); + registry.register(new NovaCewsHandler()); + registry.register(new PpcCapacitorHandler()); + registry.register(new PrecisionAmmoHandler()); registry.register(new RadicalHeatSinkHandler()); - registry.register(new BlueShieldHandler()); registry.register(new RiscEmergencyCoolantSystemHandler()); + registry.register(new RiscLaserPulseModuleHandler()); registry.register(new RiscViralJammerHandler()); - registry.register(new C3EmergencyMasterHandler()); - registry.register(new PpcCapacitorHandler()); - registry.register(new BombastLaserHandler()); + registry.register(new StealthHandler()); registry.register(new TwBombastLaserHandler()); - registry.register(new FlamerHandler()); - registry.register(new PrecisionAmmoHandler()); registry.register(new UACFiringModeHandler()); registry.register(new UACJammingHandler()); - registry.register(new C3Handler()); + registry.register(new VibrobladeHandler()); // registry.register(new WeaponAmmoHandler()); // TODO: is a bit annoying } diff --git a/src/app/equipment-handlers/nova-cews.handler.spec.ts b/src/app/equipment-handlers/nova-cews.handler.spec.ts new file mode 100644 index 000000000..174398c44 --- /dev/null +++ b/src/app/equipment-handlers/nova-cews.handler.spec.ts @@ -0,0 +1,236 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { PickerChoice } from '../components/picker/picker.interface'; +import { MiscEquipment } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; +import { MountedEquipment } from '../models/mounted-equipment.model'; +import type { TurnState } from '../models/turn-state.model'; +import { + createHandlerCommandContext, + createHandlerQueryContext, + EquipmentInteractionRegistry, +} from '../services/equipment-interaction-registry.service'; +import type { DialogsService } from '../services/dialogs.service'; +import type { ToastService } from '../services/toast.service'; +import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; +import { + isNovaCewsEffectivelyActive, + NOVA_CEWS_OFF_STATE, + NOVA_CEWS_ON_STATE, + NOVA_CEWS_STATE_KEY, + NOVA_CEWS_TURNING_OFF_STATE, + NOVA_CEWS_TURNING_ON_STATE, +} from '../utils/ecm-state.util'; +import { BAPHandler } from './bap.handler'; +import { C3Handler } from './c3.handler'; +import { ECMHandler } from './ecm.handler'; +import { NOVA_CEWS_HANDLER_ID, NovaCewsHandler } from './nova-cews.handler'; + +function fixture() { + const test = createTestEquipmentOwner({ + resolveEquipmentActionPermission: () => true, + }); + const markEquipmentStateChanged = jasmine.createSpy('markEquipmentStateChanged'); + Object.assign(test.owner, { + turnState: () => ({ markEquipmentStateChanged }), + }); + spyOn(test.owner, 'setInventoryEntry').and.callThrough(); + + const add = (id = 'nova', states = new Map()) => { + const equipment = new MiscEquipment({ + id: 'NovaCEWS', + name: 'Nova Combined Electronic Warfare System (CEWS)', + shortName: 'Nova CEWS', + type: 'misc', + flags: ['F_NOVA', 'F_ECM', 'F_BAP', 'ANY_C3'], + modes: ['ECM', 'Off'], + }); + const mounted = new MountedEquipment({ + owner: test.owner, + id, + name: equipment.name, + equipment, + states, + }); + test.owner.setInventoryEntry(mounted); + return mounted; + }; + + return { ...test, add, markEquipmentStateChanged }; +} + +describe('NovaCewsHandler', () => { + const handler = new NovaCewsHandler(); + const queryContext = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); + const toastService = jasmine.createSpyObj('ToastService', ['showToast', 'toasts']); + const commandContext = createHandlerCommandContext( + EMPTY_EQUIPMENT_REGISTRY, + toastService, + jasmine.createSpyObj('DialogsService', ['createDialog']), + ); + + beforeEach(() => toastService.showToast.calls.reset()); + + it('replaces the independent ECM and probe controls with one shared toggle', () => { + const mounted = fixture().add(); + const registry = new EquipmentInteractionRegistry(); + registry.register(handler); + registry.register(new ECMHandler()); + registry.register(new BAPHandler()); + registry.register(new C3Handler()); + + expect(registry.getHandlers(mounted).map(candidate => candidate.id)).toEqual([ + NOVA_CEWS_HANDLER_ID, + 'c3-handler', + ]); + expect(registry.getChoices(mounted, queryContext).map(choice => choice.label)).toEqual([ + 'Nova CEWS is ON', + 'Configure', + ]); + }); + + it('defaults to active and contributes the rules-mandated two heat', () => { + const mounted = fixture().add(); + + expect(isNovaCewsEffectivelyActive(mounted)).toBeTrue(); + expect(handler.getChoices(mounted, queryContext)[0]).toEqual(jasmine.objectContaining({ + label: 'Nova CEWS is ON', + value: NOVA_CEWS_TURNING_OFF_STATE, + active: true, + displayType: 'toggle', + })); + expect(handler.getInventoryHeatSources(mounted, {} as TurnState, queryContext)).toEqual([{ + id: 'nova-cews:nova', + label: 'Nova CEWS', + value: 2, + group: 'Equipment', + }]); + }); + + it('keeps its effects and heat through a pending End-Phase shutdown', () => { + const test = fixture(); + const mounted = test.add(); + + handler.handleSelection( + mounted, + handler.getChoices(mounted, queryContext)[0] as PickerChoice, + commandContext, + ); + + expect(mounted.states.get(NOVA_CEWS_STATE_KEY)).toBe(NOVA_CEWS_TURNING_OFF_STATE); + expect(isNovaCewsEffectivelyActive(mounted)).toBeTrue(); + expect(handler.getInventoryHeatSources(mounted, {} as TurnState, queryContext)[0].value).toBe(2); + expect(test.markEquipmentStateChanged).toHaveBeenCalledTimes(1); + + handler.onEndTurn(mounted); + + expect(mounted.states.get(NOVA_CEWS_STATE_KEY)).toBe(NOVA_CEWS_OFF_STATE); + expect(isNovaCewsEffectivelyActive(mounted)).toBeFalse(); + expect(handler.getInventoryHeatSources(mounted, {} as TurnState, queryContext)).toEqual([]); + }); + + it('does not activate or generate heat until a pending startup completes', () => { + const test = fixture(); + const mounted = test.add('nova', new Map([[NOVA_CEWS_STATE_KEY, NOVA_CEWS_OFF_STATE]])); + + handler.handleSelection( + mounted, + handler.getChoices(mounted, queryContext)[0] as PickerChoice, + commandContext, + ); + + expect(mounted.states.get(NOVA_CEWS_STATE_KEY)).toBe(NOVA_CEWS_TURNING_ON_STATE); + expect(isNovaCewsEffectivelyActive(mounted)).toBeFalse(); + expect(handler.getInventoryHeatSources(mounted, {} as TurnState, queryContext)).toEqual([]); + + handler.onEndTurn(mounted); + + expect(mounted.states.get(NOVA_CEWS_STATE_KEY)).toBe(NOVA_CEWS_ON_STATE); + expect(isNovaCewsEffectivelyActive(mounted)).toBeTrue(); + expect(handler.getInventoryHeatSources(mounted, {} as TurnState, queryContext)[0].value).toBe(2); + }); + + it('does not multiply heat when a unit carries multiple active mounts', () => { + const test = fixture(); + const first = test.add('nova-1'); + const second = test.add('nova-2'); + const registry = new EquipmentInteractionRegistry(); + registry.register(handler); + + expect(isNovaCewsEffectivelyActive(first)).toBeTrue(); + expect(isNovaCewsEffectivelyActive(second)).toBeFalse(); + expect(registry.getInventoryHeatSources( + [first, second], + {} as TurnState, + queryContext, + )).toEqual([{ + id: 'nova-cews:nova-1', + label: 'Nova CEWS', + value: 2, + group: 'Equipment', + }]); + }); + + it('hands operation to another mount only after the End-Phase transition', () => { + const test = fixture(); + const first = test.add('nova-1'); + const second = test.add('nova-2'); + + expect(handler.getChoices(second, queryContext)[0]).toEqual(jasmine.objectContaining({ + label: 'Nova CEWS is OFF', + value: NOVA_CEWS_TURNING_ON_STATE, + })); + + handler.handleSelection( + second, + handler.getChoices(second, queryContext)[0] as PickerChoice, + commandContext, + ); + + expect(first.states.has(NOVA_CEWS_STATE_KEY)).toBeFalse(); + expect(second.states.get(NOVA_CEWS_STATE_KEY)).toBe(NOVA_CEWS_TURNING_ON_STATE); + expect(isNovaCewsEffectivelyActive(first)).toBeTrue(); + expect(isNovaCewsEffectivelyActive(second)).toBeFalse(); + + handler.onEndTurn(first); + handler.onEndTurn(second); + + expect(isNovaCewsEffectivelyActive(first)).toBeFalse(); + expect(isNovaCewsEffectivelyActive(second)).toBeTrue(); + }); + + it('leaves the current mount active when a pending handoff is cancelled', () => { + const test = fixture(); + const first = test.add('nova-1'); + const second = test.add('nova-2'); + + handler.handleSelection(second, handler.getChoices(second, queryContext)[0], commandContext); + handler.handleSelection(second, handler.getChoices(second, queryContext)[0], commandContext); + handler.onEndTurn(first); + handler.onEndTurn(second); + + expect(isNovaCewsEffectivelyActive(first)).toBeTrue(); + expect(isNovaCewsEffectivelyActive(second)).toBeFalse(); + }); + + it('suppresses heat when the active mount cannot provide passive effects', () => { + const test = createTestEquipmentOwner({ destroyed: true }); + const equipment = new MiscEquipment({ + id: 'NovaCEWS', + name: 'Nova CEWS', + type: 'misc', + flags: ['F_NOVA'], + }); + const mounted = new MountedEquipment({ + owner: test.owner, + id: 'nova', + name: equipment.name, + equipment, + }); + test.owner.setInventoryEntry(mounted); + + expect(handler.getInventoryHeatSources(mounted, {} as TurnState, queryContext)).toEqual([]); + }); +}); diff --git a/src/app/equipment-handlers/nova-cews.handler.ts b/src/app/equipment-handlers/nova-cews.handler.ts new file mode 100644 index 000000000..55adab517 --- /dev/null +++ b/src/app/equipment-handlers/nova-cews.handler.ts @@ -0,0 +1,81 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { EquipmentFlag } from '../models/equipment-flags.type'; +import type { MountedEquipment } from '../models/mounted-equipment.model'; +import { EQUIPMENT_HEAT_SOURCE_GROUP, type UnitHeatSource } from '../models/rules/unit-type-rules'; +import type { TurnState } from '../models/turn-state.model'; +import type { HandlerQueryContext } from '../services/equipment-interaction-registry.service'; +import { + isNovaCewsEffectivelyActive, + NOVA_CEWS_OFF_STATE, + NOVA_CEWS_ON_STATE, + NOVA_CEWS_STATE_KEY, + NOVA_CEWS_TURNING_OFF_STATE, + NOVA_CEWS_TURNING_ON_STATE, + novaCewsState, +} from '../utils/ecm-state.util'; +import { ToggleHandler } from './base/toggle.handler'; + +export const NOVA_CEWS_HANDLER_ID = 'nova-cews-handler'; + +export class NovaCewsHandler extends ToggleHandler { + readonly id = NOVA_CEWS_HANDLER_ID; + override readonly flags: EquipmentFlag[] = ['F_NOVA']; + override readonly priority = 20; + + protected override readonly stateKey = NOVA_CEWS_STATE_KEY; + protected override readonly toggleMode = 'transient' as const; + protected override readonly enabledState = NOVA_CEWS_ON_STATE; + protected override readonly enablingState = NOVA_CEWS_TURNING_ON_STATE; + protected override readonly disabledState = NOVA_CEWS_OFF_STATE; + protected override readonly disablingState = NOVA_CEWS_TURNING_OFF_STATE; + protected override readonly defaultEnabled = true; + protected override readonly enabledLabel = 'Nova CEWS is ON'; + protected override readonly enablingLabel = 'Turning Nova CEWS on…'; + protected override readonly disabledLabel = 'Nova CEWS is OFF'; + protected override readonly disablingLabel = 'Turning Nova CEWS off…'; + protected override readonly enabledToastVerb = 'on'; + protected override readonly enablingToastVerb = 'turning on'; + protected override readonly disabledToastVerb = 'off'; + protected override readonly disablingToastVerb = 'turning off'; + + protected override getToggleState(equipment: MountedEquipment): string { + return novaCewsState(equipment); + } + + isActive(equipment: MountedEquipment): boolean { + return isNovaCewsEffectivelyActive(equipment); + } + + override onEndTurn(equipment: MountedEquipment): void { + const activating = novaCewsState(equipment) === NOVA_CEWS_TURNING_ON_STATE; + super.onEndTurn(equipment); + if (!activating || novaCewsState(equipment) !== NOVA_CEWS_ON_STATE) return; + + // A unit may operate only one Nova CEWS. Commit the selected mount's + // handoff after outgoing-turn effects and heat have already resolved. + for (const other of equipment.owner.getInventory()) { + if (other === equipment || other.equipment?.flags.has('F_NOVA') !== true) continue; + if (other.setState(NOVA_CEWS_STATE_KEY, NOVA_CEWS_OFF_STATE)) { + other.owner.setInventoryEntry(other); + } + } + } + + override getInventoryHeatSources( + equipment: MountedEquipment, + _turnState: TurnState, + context: HandlerQueryContext, + ): UnitHeatSource[] { + if (!this.isActive(equipment) || !context.canProvidePassiveEffect(equipment)) return []; + + return [{ + id: `nova-cews:${equipment.id}`, + label: 'Nova CEWS', + value: 2, + group: EQUIPMENT_HEAT_SOURCE_GROUP, + }]; + } +} diff --git a/src/app/equipment-handlers/ppc-capacitor.handler.spec.ts b/src/app/equipment-handlers/ppc-capacitor.handler.spec.ts index 2621f2942..97bf4da8a 100644 --- a/src/app/equipment-handlers/ppc-capacitor.handler.spec.ts +++ b/src/app/equipment-handlers/ppc-capacitor.handler.spec.ts @@ -240,6 +240,7 @@ describe('PpcCapacitorHandler', () => { id: 'ppc-capacitor:ppc', label: 'PPC Capacitor', value: 5, + group: 'Equipment', replacedByFiringEntryId: 'ppc' }]); }); diff --git a/src/app/equipment-handlers/ppc-capacitor.handler.ts b/src/app/equipment-handlers/ppc-capacitor.handler.ts index 55dcc7b9c..3b2cef2fd 100644 --- a/src/app/equipment-handlers/ppc-capacitor.handler.ts +++ b/src/app/equipment-handlers/ppc-capacitor.handler.ts @@ -5,7 +5,7 @@ import type { PickerChoice } from '../components/picker/picker.interface'; import { MountedWeapon, type MountedEquipment } from '../models/mounted-equipment.model'; import type { TurnState } from '../models/turn-state.model'; -import type { UnitHeatSource } from '../models/rules/unit-type-rules'; +import { EQUIPMENT_HEAT_SOURCE_GROUP, type UnitHeatSource } from '../models/rules/unit-type-rules'; import { EquipmentInteractionHandler, setEffectiveWeaponType, @@ -202,6 +202,7 @@ export class PpcCapacitorHandler extends EquipmentInteractionHandler { id: `ppc-capacitor:${equipment.id}`, label: 'PPC Capacitor', value: PPC_CAPACITOR_HEAT_BONUS, + group: EQUIPMENT_HEAT_SOURCE_GROUP, replacedByFiringEntryId: ppcCapacitorState(capacitor) === PPC_CAPACITOR_CHARGED_STATE ? equipment.id : undefined diff --git a/src/app/equipment-handlers/risc-viral-jammer.handler.ts b/src/app/equipment-handlers/risc-viral-jammer.handler.ts index df9e2cbd3..047056c25 100644 --- a/src/app/equipment-handlers/risc-viral-jammer.handler.ts +++ b/src/app/equipment-handlers/risc-viral-jammer.handler.ts @@ -4,7 +4,7 @@ import type { EquipmentFlag } from '../models/equipment-flags.type'; import type { MountedEquipment } from '../models/mounted-equipment.model'; -import type { UnitHeatSource } from '../models/rules/unit-type-rules'; +import { EQUIPMENT_HEAT_SOURCE_GROUP, type UnitHeatSource } from '../models/rules/unit-type-rules'; import type { TurnState } from '../models/turn-state.model'; import type { HandlerQueryContext } from '../services/equipment-interaction-registry.service'; import { EscalatingFailureHandler } from './escalatingfailure.handler'; @@ -45,6 +45,7 @@ export class RiscViralJammerHandler extends EscalatingFailureHandler { id: `risc-viral-jammer:${equipment.id}`, label: 'RISC Viral Jammer', value: 12, + group: EQUIPMENT_HEAT_SOURCE_GROUP, }]; } } diff --git a/src/app/equipment-handlers/stealth.handler.spec.ts b/src/app/equipment-handlers/stealth.handler.spec.ts index b8cf02764..6707865f0 100644 --- a/src/app/equipment-handlers/stealth.handler.spec.ts +++ b/src/app/equipment-handlers/stealth.handler.spec.ts @@ -160,7 +160,7 @@ describe('StealthHandler', () => { } }); - it('switches stealth at end turn and contributes 10 Equipment heat only while effective', () => { + it('switches stealth at end turn and contributes 10 grouped heat only while effective', () => { const test = fixture(); const stealth = test.add('stealth', stealthArmor()); const ecm = test.add('ecm', misc('ECM', 'F_ECM')); @@ -172,9 +172,10 @@ describe('StealthHandler', () => { handler.onEndTurn(stealth); expect(stealth.states.get(STEALTH_STATE_KEY)).toBe(STEALTH_ENABLED_STATE); expect(handler.getInventoryHeatSources(stealth, {} as TurnState, queryContext)).toEqual([{ - id: 'equipment:stealth', - label: 'Equipment', + id: 'stealth:stealth', + label: 'Stealth', value: 10, + group: 'Equipment', }]); expect(isC3DisruptingStealthActive(stealth)).toBeTrue(); expect(new ECMHandler().isActive(ecm)).toBeFalse(); @@ -229,9 +230,10 @@ describe('StealthHandler', () => { handler.onEndTurn(chameleon); expect(handler.getInventoryHeatSources(chameleon, {} as TurnState, queryContext)).toEqual([{ - id: 'equipment:chameleon', - label: 'Equipment', + id: 'stealth:chameleon', + label: 'Stealth', value: 6, + group: 'Equipment', }]); expect(isC3DisruptingStealthActive(chameleon)).toBeFalse(); }); @@ -244,9 +246,10 @@ describe('StealthHandler', () => { handler.onEndTurn(nullSignature); expect(handler.getInventoryHeatSources(nullSignature, {} as TurnState, queryContext)).toEqual([{ - id: 'equipment:null-signature', - label: 'Equipment', + id: 'stealth:null-signature', + label: 'Stealth', value: 10, + group: 'Equipment', }]); expect(isC3DisruptingStealthActive(nullSignature)).toBeFalse(); }); diff --git a/src/app/equipment-handlers/stealth.handler.ts b/src/app/equipment-handlers/stealth.handler.ts index 55ba8ae9a..9301634f4 100644 --- a/src/app/equipment-handlers/stealth.handler.ts +++ b/src/app/equipment-handlers/stealth.handler.ts @@ -5,7 +5,7 @@ import type { PickerChoice } from '../components/picker/picker.interface'; import type { EquipmentFlag } from '../models/equipment-flags.type'; import type { MountedEquipment } from '../models/mounted-equipment.model'; -import type { UnitHeatSource } from '../models/rules/unit-type-rules'; +import { EQUIPMENT_HEAT_SOURCE_GROUP, type UnitHeatSource } from '../models/rules/unit-type-rules'; import { hasFunctionalEcmForStealth, isChameleonShieldActive, @@ -83,9 +83,10 @@ export class StealthHandler extends ToggleHandler { ? (isNullSignatureActive(equipment) ? 10 : 0) : (isStealthEquipmentFunctioning(equipment) ? 10 : 0); return heat > 0 ? [{ - id: `equipment:${equipment.id}`, - label: 'Equipment', + id: `stealth:${equipment.id}`, + label: 'Stealth', value: heat, + group: EQUIPMENT_HEAT_SOURCE_GROUP, }] : []; } diff --git a/src/app/models/cbt-force-unit-c3.spec.ts b/src/app/models/cbt-force-unit-c3.spec.ts index c10b78c0d..9c30237e1 100644 --- a/src/app/models/cbt-force-unit-c3.spec.ts +++ b/src/app/models/cbt-force-unit-c3.spec.ts @@ -3,13 +3,14 @@ // Author: Drake import { CBTForceUnit } from './cbt-force-unit.model'; -import { C3_FLAGS, C3Network, C3NetworkType } from './c3-network.model'; +import { C3Capabilities, C3_FLAGS, C3Network, C3NetworkType } from './c3-network.model'; import type { Equipment } from './equipment.model'; import { MountedEquipment } from './mounted-equipment.model'; import type { SerializedC3NetworkGroup } from './force-serialization'; import type { InventoryControlRuntimeTarget } from './inventory-control-runtime-state.model'; import { CORE_2026_GAME_RULES, TW_GAME_RULES } from './rules/game-rules'; import { UnitTypeRulesBase } from './rules/unit-type-rules'; +import { NOVA_CEWS_OFF_STATE, NOVA_CEWS_STATE_KEY } from '../utils/ecm-state.util'; class C3BadgeRules extends UnitTypeRulesBase { override evaluateDestroyed(): void { } @@ -171,12 +172,47 @@ describe('CBTForceUnit C3 targeting resolution', () => { expect(unit.isEquipmentOperational(unit.getInventory()[0])).toBeTrue(); expect(unit.isEquipmentOperational(unit.getInventory()[1])).toBeTrue(); - expect(CBTForceUnit.prototype.canPerformEquipmentAction.call(unit, unit.getInventory()[0], 'configure-network')).toBeFalse(); - expect(CBTForceUnit.prototype.canPerformEquipmentAction.call(unit, unit.getInventory()[1], 'configure-network')).toBeFalse(); + expect(CBTForceUnit.prototype.canPerformEquipmentAction.call(unit, unit.getInventory()[0], 'configure-network')).toBeTrue(); + expect(CBTForceUnit.prototype.canPerformEquipmentAction.call(unit, unit.getInventory()[1], 'configure-network')).toBeTrue(); expect(unit.isC3ComponentOperational(0)).toBeFalse(); expect(unit.isC3ComponentOperational(1)).toBeFalse(); }); + it('keeps a switched-off Nova CEWS configurable while its endpoint is unavailable', () => { + const unit = c3BadgeUnit('nova-unit', [ + { id: 'nova', flag: C3_FLAGS.NOVA }, + ], new Set()); + const nova = unit.getInventory()[0]; + + expect(new C3Capabilities(unit).has(C3NetworkType.NOVA)).toBeTrue(); + expect(unit.isC3ComponentOperational(0)).toBeTrue(); + + nova.states.set(NOVA_CEWS_STATE_KEY, NOVA_CEWS_OFF_STATE); + + expect(new C3Capabilities(unit).has(C3NetworkType.NOVA)).toBeTrue(); + expect(unit.isC3ComponentOperational(0)).toBeFalse(); + expect(unit.canPerformEquipmentAction(nova, 'configure-network')).toBeTrue(); + }); + + it('keeps every C3 network type configurable regardless unit or component condition', () => { + const unavailable = new Set(['master', 'slave', 'c3i', 'naval', 'nova']); + const unit = c3BadgeUnit('damaged-c3-unit', [ + { id: 'master', flag: C3_FLAGS.C3M }, + { id: 'slave', flag: C3_FLAGS.C3S }, + { id: 'c3i', flag: C3_FLAGS.C3I }, + { id: 'naval', flag: C3_FLAGS.NAVAL_C3 }, + { id: 'nova', flag: C3_FLAGS.NOVA }, + ], unavailable); + Object.defineProperty(unit, 'destroyed', { value: true, configurable: true }); + + const components = new C3Capabilities(unit).components; + expect(components.length).toBe(5); + components.forEach(component => { + expect(unit.isC3ComponentOperational(component.index, component)).toBeFalse(); + expect(unit.canPerformEquipmentAction(component.mount!, 'configure-network')).toBeTrue(); + }); + }); + it('disconnects C3 for active Stealth Armor except Chameleon LPS and Null Signature', () => { const stealthUnit = c3BadgeUnit('stealth-unit', [ { id: 'c3', flag: C3_FLAGS.C3M }, @@ -247,7 +283,7 @@ describe('CBTForceUnit C3 targeting resolution', () => { expect(brokenEcmUnit.getCondition('stealth')).toBeFalse(); }); - it('uses C3 endpoint availability as the configure-network action authority', () => { + it('keeps C3 configuration available while runtime effects are suppressed', () => { const unit = c3BadgeUnit('stealth-unit', [ { id: 'c3', flag: C3_FLAGS.C3M }, { id: 'stealth', flag: 'F_STEALTH' }, @@ -262,7 +298,7 @@ describe('CBTForceUnit C3 targeting resolution', () => { expect(unit.isEquipmentOperational(c3)).toBeTrue(); expect(unit.isC3ComponentOperational(0)).toBeFalse(); - expect(unit.canPerformEquipmentAction(c3, 'configure-network')).toBeFalse(); + expect(unit.canPerformEquipmentAction(c3, 'configure-network')).toBeTrue(); }); it('does not disconnect C3 for an unavailable stealth system with stale active state', () => { diff --git a/src/app/models/cbt-force-unit.model.spec.ts b/src/app/models/cbt-force-unit.model.spec.ts index cfa68eb6d..5d32aee16 100644 --- a/src/app/models/cbt-force-unit.model.spec.ts +++ b/src/app/models/cbt-force-unit.model.spec.ts @@ -49,6 +49,8 @@ import { } from '../equipment-handlers/bombast-laser.handler'; import { applyMekCriticalRoll } from '../utils/mek-critical-hit.util'; import type { AutomationMode, CBTAutomationKey } from './options.model'; +import { NovaCewsHandler } from '../equipment-handlers/nova-cews.handler'; +import { NOVA_CEWS_OFF_STATE, NOVA_CEWS_STATE_KEY } from '../utils/ecm-state.util'; function createEquipment(): EquipmentMap { const ultraAc20 = new WeaponEquipment({ @@ -5489,6 +5491,64 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(damageText.getAttribute('data-mekbay-physical-base-damage-text')).toBe('10'); }); + it('renders Nova CEWS row heat while grouping its record-sheet summary as Equipment', () => { + const nova = new MiscEquipment({ + id: 'NovaCEWS', + name: 'Nova CEWS', + type: 'misc', + flags: ['F_NOVA', 'F_ECM', 'F_BAP'], + }); + equipment[nova.internalName] = nova; + TestBed.inject(EquipmentInteractionRegistryService).getRegistry().register(new NovaCewsHandler()); + const unit = createEmptyUnit({ + ...createMekUnit(), + heat: 20, + comp: [{ + id: nova.internalName, q: 1, q2: 0, n: nova.name, t: 'E', p: 1, + l: 'CT', c: '2', os: 0, eq: nova, + }], + }); + const svg = new DOMParser().parseFromString(` + + + + + Nova CEWS + + CT + + + + `, 'image/svg+xml').documentElement as unknown as SVGSVGElement; + const forceUnit = createForceUnit(unit); + initialize(forceUnit, svg); + const entry = forceUnit.getInventory().find(candidate => candidate.equipment === nova)!; + const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgService(forceUnit, unitInitializer)); + + svgService.refreshInventory(); + svgService.refreshTurnState(); + + expect(entry.el!.querySelector(':scope > .heat')?.textContent).toBe('2'); + expect(forceUnit.turnState().heatSources()).toContain(jasmine.objectContaining({ + id: `nova-cews:${entry.id}`, + label: 'Nova CEWS', + value: 2, + group: 'Equipment', + })); + expect(Array.from(svg.querySelectorAll('#damagedEngineHeatText > tspan')) + .map(line => line.textContent)).toContain('Equipment: +2'); + + entry.setState(NOVA_CEWS_STATE_KEY, NOVA_CEWS_OFF_STATE); + forceUnit.setInventoryEntry(entry); + const offEntry = forceUnit.getInventory().find(candidate => candidate.id === entry.id)!; + svgService.refreshInventory(); + svgService.refreshTurnState(); + + expect(offEntry.el!.querySelector(':scope > .heat')?.textContent).toBe('—'); + expect(forceUnit.turnState().heatSources().some(source => source.id === `nova-cews:${offEntry.id}`)).toBeFalse(); + expect(svg.querySelector('#damagedEngineHeatText > tspan')).toBeNull(); + }); + it('renders effective weapon types on selected-range SVG damage', () => { const forceUnit = createForceUnit(createVariableDamageUnit(equipment)); initialize(forceUnit, createVariableDamageSvg()); diff --git a/src/app/models/cbt-force-unit.model.ts b/src/app/models/cbt-force-unit.model.ts index 5b714f76f..25cef97cb 100644 --- a/src/app/models/cbt-force-unit.model.ts +++ b/src/app/models/cbt-force-unit.model.ts @@ -76,6 +76,7 @@ import { isConsciousnessRecoveryCheck, isConsciousnessSequenceCheck, } from '../utils/unit-check.util'; +import { isEcmModeActive } from '../utils/ecm-state.util'; export type EquipmentStatusSource = MountedEquipment | CriticalSlot; export type EquipmentAction = @@ -782,7 +783,9 @@ export class CBTForceUnit extends ForceUnit { isC3ComponentOperational(componentIndex: number, component?: C3Component): boolean { if (this.destroyed || this.getCondition('shutdown') || this.hasActiveC3DisruptingStealth()) return false; const mount = component?.mount ?? new C3Capabilities(this).component(componentIndex)?.mount; - return !!mount && this.isEquipmentOperational(mount); + return !!mount + && this.isEquipmentOperational(mount) + && (mount.equipment?.flags.has('F_NOVA') !== true || isEcmModeActive(mount)); } private hasActiveC3DisruptingStealth(): boolean { @@ -1494,15 +1497,16 @@ export class CBTForceUnit extends ForceUnit { } canPerformEquipmentAction(entry: MountedEquipment, action: EquipmentAction): boolean { + if (action === 'configure-network') { + // Topology remains editable even when this endpoint cannot currently participate. + return new C3Capabilities(this).components.some(component => component.mount === entry); + } if (this.hasActiveC3DisruptingStealth() && (entry.equipment?.flags.has('F_BAP') || entry.equipment?.flags.has('F_BLOODHOUND')) && (action === 'activate' || action === 'change-mode' || action === 'provide-passive-effect')) { return false; } - if (action === 'configure-network') { - const component = new C3Capabilities(this).components.find(candidate => candidate.mount === entry); - if (!component || !this.isC3ComponentOperational(component.index, component)) return false; - } else if (!this.isEquipmentOperational(entry) || this.destroyed || this.getCondition('shutdown')) { + if (!this.isEquipmentOperational(entry) || this.destroyed || this.getCondition('shutdown')) { return false; } if (action !== 'provide-passive-effect' && !this.canTakeActiveActions()) return false; diff --git a/src/app/models/rules/unit-type-rules.ts b/src/app/models/rules/unit-type-rules.ts index e68b0979e..10f0ac34b 100644 --- a/src/app/models/rules/unit-type-rules.ts +++ b/src/app/models/rules/unit-type-rules.ts @@ -131,6 +131,8 @@ export interface UnitHeatSource { id: string; label: string; value: number; + /** Optional label used to combine sources in compact heat summaries. */ + group?: string; /** Source is transient firing heat derived from selected inventory weapons. */ inventorySelection?: boolean; /** Source state that must reactivate heat even when its aggregate value is unchanged. */ @@ -144,6 +146,8 @@ export interface UnitHeatSource { replacedByFiringEntryId?: string; } +export const EQUIPMENT_HEAT_SOURCE_GROUP = 'Equipment'; + export interface ChargeDamage { damage: number | null; maxDamage: number | null; diff --git a/src/app/models/stealth-equipment.model.ts b/src/app/models/stealth-equipment.model.ts index 89a59a3ce..a953f5c4b 100644 --- a/src/app/models/stealth-equipment.model.ts +++ b/src/app/models/stealth-equipment.model.ts @@ -14,7 +14,7 @@ import { type TnRangeModifiers, type TnStealthModifiers, } from './target-number-calculator.model'; -import { ECM_MODE_STATE_KEY } from '../equipment-handlers/ecm.handler'; +import { getEffectiveEcmMode } from '../utils/ecm-state.util'; export const STEALTH_STATE_KEY = 'state'; export const STEALTH_ENABLED_STATE = 'enabled'; @@ -113,7 +113,7 @@ export function isStealthSystemActive(equipment: MountedEquipment): boolean { /** Only ECM-bearing modes power Stealth Armor; ECCM and plain Ghost do not. */ export function ecmModeSupportsStealth(equipment: MountedEquipment): boolean { - const mode = equipment.states.get(ECM_MODE_STATE_KEY) ?? ECMMode.ECM; + const mode = getEffectiveEcmMode(equipment); return mode === ECMMode.ECM || mode === ECMMode.ECM_ECCM || mode === ECMMode.ECM_GHOST; diff --git a/src/app/services/equipment-interaction-registry.service.ts b/src/app/services/equipment-interaction-registry.service.ts index 07bd8fff3..dbbe9c9d7 100644 --- a/src/app/services/equipment-interaction-registry.service.ts +++ b/src/app/services/equipment-interaction-registry.service.ts @@ -567,11 +567,19 @@ export class EquipmentInteractionRegistry { } getInventoryControlHeatEffect(equipment: MountedEquipment, context: HandlerQueryContext): InventoryControlHeatEffect | null { - for (const handler of this.getHandlers(equipment)) { + const handlers = this.getHandlers(equipment); + for (const handler of handlers) { const effect = handler.getInventoryControlHeatEffect?.(equipment, context); if (effect) return effect; } - return null; + const passiveHeat = handlers + .flatMap(handler => handler.getInventoryHeatSources?.( + equipment, + equipment.owner.turnState(), + context, + ) ?? []) + .reduce((total, source) => total + (Number.isFinite(source.value) ? Math.max(0, source.value) : 0), 0); + return passiveHeat > 0 ? { value: passiveHeat, weakened: false } : null; } matchesInventoryAmmo(equipment: MountedEquipment, ammo: AmmoEquipment, mode: string | null, context: HandlerQueryContext): boolean | null { diff --git a/src/app/services/unit-svg.service.ts b/src/app/services/unit-svg.service.ts index 0398cdd9e..beafd21f6 100644 --- a/src/app/services/unit-svg.service.ts +++ b/src/app/services/unit-svg.service.ts @@ -1802,6 +1802,7 @@ export class UnitSvgService { dissipationBalance, consumedDissipation, projectedHeat, + { groupSources: true }, ).map(row => ({ text: `${row.label}: ${this.formatSignedModifier(row.value)}`, fill: row.inventorySelection diff --git a/src/app/utils/ecm-state.util.ts b/src/app/utils/ecm-state.util.ts new file mode 100644 index 000000000..4b910dfa6 --- /dev/null +++ b/src/app/utils/ecm-state.util.ts @@ -0,0 +1,73 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { ECMMode } from '../models/common.model'; +import type { MountedEquipment } from '../models/mounted-equipment.model'; + +export const ECM_MODE_STATE_KEY = 'ecm_mode'; + +export const NOVA_CEWS_STATE_KEY = ECM_MODE_STATE_KEY; +export const NOVA_CEWS_ON_STATE = ECMMode.ECM; +export const NOVA_CEWS_TURNING_OFF_STATE = 'nova-cews-turning-off'; +export const NOVA_CEWS_OFF_STATE = ECMMode.OFF; +export const NOVA_CEWS_TURNING_ON_STATE = 'nova-cews-turning-on'; + +export type NovaCewsState = + | typeof NOVA_CEWS_ON_STATE + | typeof NOVA_CEWS_TURNING_OFF_STATE + | typeof NOVA_CEWS_OFF_STATE + | typeof NOVA_CEWS_TURNING_ON_STATE; + +function defaultNovaCewsState(equipment: MountedEquipment): NovaCewsState { + const firstNovaMount = equipment.owner.getInventory().find(candidate => ( + candidate.equipment?.flags.has('F_NOVA') + )); + return !firstNovaMount || firstNovaMount === equipment + ? NOVA_CEWS_ON_STATE + : NOVA_CEWS_OFF_STATE; +} + +/** Missing and legacy non-Off ECM modes preserve the rules-default active state. */ +export function novaCewsState(equipment: MountedEquipment | null | undefined): NovaCewsState { + switch (equipment?.states.get(NOVA_CEWS_STATE_KEY)?.trim().toLowerCase()) { + case NOVA_CEWS_TURNING_OFF_STATE: return NOVA_CEWS_TURNING_OFF_STATE; + case ECMMode.OFF: return NOVA_CEWS_OFF_STATE; + case NOVA_CEWS_TURNING_ON_STATE: return NOVA_CEWS_TURNING_ON_STATE; + default: + if (!equipment) return NOVA_CEWS_ON_STATE; + return equipment.states.has(NOVA_CEWS_STATE_KEY) + ? NOVA_CEWS_ON_STATE + : defaultNovaCewsState(equipment); + } +} + +/** A pending End-Phase transition does not change the system's effects during the current turn. */ +export function isNovaCewsEffectivelyActive(equipment: MountedEquipment | null | undefined): boolean { + if (!equipment) return false; + const state = novaCewsState(equipment); + if (state !== NOVA_CEWS_ON_STATE && state !== NOVA_CEWS_TURNING_OFF_STATE) return false; + + // Even malformed/legacy state containing multiple ON mounts must obey the + // rule that a unit can use only one Nova CEWS at a time. + const firstActiveMount = equipment.owner.getInventory().find(candidate => { + if (candidate.equipment?.flags.has('F_NOVA') !== true) return false; + const candidateState = novaCewsState(candidate); + return candidateState === NOVA_CEWS_ON_STATE + || candidateState === NOVA_CEWS_TURNING_OFF_STATE; + }); + return !firstActiveMount || firstActiveMount === equipment; +} + +/** Resolves the mode currently supplying effects, including delayed Nova CEWS transitions. */ +export function getEffectiveEcmMode(equipment: MountedEquipment): ECMMode | string { + if (equipment.equipment?.flags.has('F_NOVA')) { + return isNovaCewsEffectivelyActive(equipment) ? ECMMode.ECM : ECMMode.OFF; + } + return equipment.states.get(ECM_MODE_STATE_KEY) || ECMMode.ECM; +} + +/** Mode state only; callers remain responsible for equipment and unit availability. */ +export function isEcmModeActive(equipment: MountedEquipment): boolean { + return getEffectiveEcmMode(equipment) !== ECMMode.OFF; +} diff --git a/src/app/utils/force-viewer-electronics-display.util.spec.ts b/src/app/utils/force-viewer-electronics-display.util.spec.ts new file mode 100644 index 000000000..fc09842ca --- /dev/null +++ b/src/app/utils/force-viewer-electronics-display.util.spec.ts @@ -0,0 +1,121 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { ASForceUnit } from '../models/as-force-unit.model'; +import { CBTForceUnit } from '../models/cbt-force-unit.model'; +import { ECMMode } from '../models/common.model'; +import type { EquipmentFlag } from '../models/equipment-flags.type'; +import { MiscEquipment, WeaponEquipment } from '../models/equipment.model'; +import { MountedEquipment } from '../models/mounted-equipment.model'; +import { + ECM_MODE_STATE_KEY, + NOVA_CEWS_STATE_KEY, + NOVA_CEWS_TURNING_OFF_STATE, + NOVA_CEWS_TURNING_ON_STATE, +} from './ecm-state.util'; +import { getEcmDisplay, getTagDisplay } from './force-viewer-electronics-display.util'; + +interface MountDefinition { + readonly id: string; + readonly name: string; + readonly flags: EquipmentFlag[]; + readonly mode?: string; + readonly ranges?: number[]; +} + +function asUnit(specials: string[]): ASForceUnit { + const unit = Object.create(ASForceUnit.prototype) as ASForceUnit; + Object.assign(unit, { getUnit: () => ({ as: { specials } }) }); + return unit; +} + +function cbtUnit( + definitions: readonly MountDefinition[], + unavailable = new Set(), +): { unit: CBTForceUnit; mounts: MountedEquipment[] } { + let mounts: MountedEquipment[] = []; + const unit = Object.create(CBTForceUnit.prototype) as CBTForceUnit; + Object.defineProperties(unit, { + getInventory: { value: () => mounts }, + getMountedEquipmentByFlag: { + value: (flag: EquipmentFlag) => mounts.filter(mount => mount.equipment?.flags.has(flag)), + }, + canPerformEquipmentAction: { + value: (mount: MountedEquipment) => !unavailable.has(mount.id), + }, + }); + mounts = definitions.map(definition => { + const equipment = definition.flags.includes('F_TAG') + ? new WeaponEquipment({ + id: definition.id, + name: definition.name, + type: 'weapon', + flags: definition.flags, + weapon: { ranges: definition.ranges ?? [5, 9, 15, 18] }, + }) + : new MiscEquipment({ + id: definition.id, + name: definition.name, + type: 'misc', + flags: definition.flags, + }); + return new MountedEquipment({ + owner: unit, + id: definition.id, + name: definition.name, + equipment, + states: definition.mode ? new Map([[ECM_MODE_STATE_KEY, definition.mode]]) : undefined, + }); + }); + return { unit, mounts }; +} + +describe('force viewer electronics display', () => { + it('resolves Alpha Strike electronics directly from specials', () => { + const unit = asUnit(['AECM', 'LTAG']); + + expect(getEcmDisplay(unit)).toEqual({ mode: 'AECM', unavailable: false }); + expect(getTagDisplay(unit)).toEqual({ label: 'LTAG', unavailable: false }); + expect(getEcmDisplay(asUnit([]))).toBeNull(); + expect(getTagDisplay(asUnit([]))).toBeNull(); + }); + + it('selects an available TAG and identifies Light TAG from its range profile', () => { + const unavailable = new Set(['standard-tag']); + const { unit } = cbtUnit([ + { id: 'standard-tag', name: 'TAG', flags: ['F_TAG'], ranges: [5, 9, 15, 18] }, + { id: 'light-tag', name: 'Unlocalized Targeting Gear', flags: ['F_TAG'], ranges: [3, 6, 9, 12] }, + ], unavailable); + + expect(getTagDisplay(unit)).toEqual({ label: 'LTAG', unavailable: false }); + + unavailable.add('light-tag'); + expect(getTagDisplay(unit)).toEqual({ label: 'TAG', unavailable: true }); + }); + + it('prefers an active ECM mode instead of merely selecting the first available mount', () => { + const { unit } = cbtUnit([ + { id: 'off', name: 'ECM Suite', flags: ['F_ECM'], mode: ECMMode.OFF }, + { id: 'eccm', name: 'Angel ECM Suite', flags: ['F_ECM'], mode: ECMMode.ECCM }, + ]); + + expect(getEcmDisplay(unit)).toEqual({ mode: ECMMode.ECCM, unavailable: false }); + }); + + it('uses the shared effective ECM mode for Nova CEWS transitions', () => { + const { unit, mounts } = cbtUnit([ + { + id: 'nova', + name: 'Nova CEWS', + flags: ['F_ECM', 'F_NOVA'], + mode: NOVA_CEWS_TURNING_OFF_STATE, + }, + ]); + + expect(getEcmDisplay(unit)).toEqual({ mode: ECMMode.ECM, unavailable: false }); + + mounts[0].states.set(NOVA_CEWS_STATE_KEY, NOVA_CEWS_TURNING_ON_STATE); + expect(getEcmDisplay(unit)).toEqual({ mode: ECMMode.OFF, unavailable: false }); + }); +}); diff --git a/src/app/utils/force-viewer-electronics-display.util.ts b/src/app/utils/force-viewer-electronics-display.util.ts new file mode 100644 index 000000000..8a6beb66e --- /dev/null +++ b/src/app/utils/force-viewer-electronics-display.util.ts @@ -0,0 +1,92 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { ASForceUnit } from '../models/as-force-unit.model'; +import { CBTForceUnit } from '../models/cbt-force-unit.model'; +import type { ForceUnit } from '../models/force-unit.model'; +import type { MountedEquipment } from '../models/mounted-equipment.model'; +import { WeaponEquipment } from '../models/equipment.model'; +import { getEffectiveEcmMode, isEcmModeActive } from './ecm-state.util'; + +export interface TagDisplay { + readonly label: 'TAG' | 'LTAG'; + readonly unavailable: boolean; +} + +export interface EcmDisplay { + readonly mode: string; + readonly unavailable: boolean; +} + +interface MountedSystemSelection { + readonly mount: MountedEquipment; + readonly unavailable: boolean; +} + +function selectMountedSystem( + unit: CBTForceUnit, + mounts: readonly MountedEquipment[], + preferred: (mount: MountedEquipment) => boolean = () => true, +): MountedSystemSelection | null { + if (mounts.length === 0) return null; + + const available = mounts.filter(mount => unit.canPerformEquipmentAction(mount, 'activate')); + const mount = available.find(preferred) + ?? available[0] + ?? mounts.find(preferred) + ?? mounts[0]; + return { mount, unavailable: available.length === 0 }; +} + +function getTagLabel(mount: MountedEquipment): TagDisplay['label'] { + if (mount.equipment instanceof WeaponEquipment && mount.equipment.ranges[0] > 0) { + return mount.equipment.ranges[0] < 5 ? 'LTAG' : 'TAG'; + } + + const names = [ + mount.name, + mount.equipment?.name, + mount.equipment?.shortName, + mount.equipment?.sortingName, + ].filter((name): name is string => !!name); + return names.some(name => /\blight\b/i.test(name)) ? 'LTAG' : 'TAG'; +} + +export function getTagDisplay(unit: ForceUnit | null | undefined): TagDisplay | null { + if (unit instanceof ASForceUnit) { + const specials = unit.getUnit().as.specials; + if (specials.includes('TAG')) return { label: 'TAG', unavailable: false }; + if (specials.includes('LTAG')) return { label: 'LTAG', unavailable: false }; + return null; + } + if (!(unit instanceof CBTForceUnit)) return null; + + const selection = selectMountedSystem(unit, unit.getMountedEquipmentByFlag('F_TAG')); + if (!selection) return null; + return { + label: getTagLabel(selection.mount), + unavailable: selection.unavailable, + }; +} + +export function getEcmDisplay(unit: ForceUnit | null | undefined): EcmDisplay | null { + if (unit instanceof ASForceUnit) { + const mode = unit.getUnit().as.specials.find(special => ( + special === 'ECM' || special === 'AECM' || special === 'LECM' + )); + return mode ? { mode, unavailable: false } : null; + } + if (!(unit instanceof CBTForceUnit)) return null; + + const selection = selectMountedSystem( + unit, + unit.getMountedEquipmentByFlag('F_ECM'), + isEcmModeActive, + ); + if (!selection) return null; + return { + mode: getEffectiveEcmMode(selection.mount), + unavailable: selection.unavailable, + }; +} diff --git a/src/app/utils/heat-summary.util.spec.ts b/src/app/utils/heat-summary.util.spec.ts index 51b609a97..0f7b3e21f 100644 --- a/src/app/utils/heat-summary.util.spec.ts +++ b/src/app/utils/heat-summary.util.spec.ts @@ -23,4 +23,19 @@ describe('buildHeatSummaryRows', () => { { id: 'heat-sink', label: 'Sink (28)', value: -22, kind: 'sink' }, ]); }); + + it('groups marked sources only for compact summaries', () => { + const sources = [ + { id: 'nova', label: 'Nova CEWS', value: 2, group: 'Equipment' }, + { id: 'damaged-engine', label: 'Damaged Engine', value: 5 }, + { id: 'stealth', label: 'Stealth', value: 10, group: 'Equipment' }, + ]; + + expect(buildHeatSummaryRows(sources, 0, 0, 17).map(row => row.label)) + .toEqual(['Nova CEWS', 'Engine', 'Stealth']); + expect(buildHeatSummaryRows(sources, 0, 0, 17, { groupSources: true })).toEqual([ + { id: 'equipment', label: 'Equipment', value: 12, kind: 'source' }, + { id: 'damaged-engine', label: 'Engine', value: 5, kind: 'source' }, + ]); + }); }); diff --git a/src/app/utils/heat-summary.util.ts b/src/app/utils/heat-summary.util.ts index 6a61a90d0..8b157216b 100644 --- a/src/app/utils/heat-summary.util.ts +++ b/src/app/utils/heat-summary.util.ts @@ -14,22 +14,53 @@ export interface HeatSummaryRow { readonly inventorySelection?: boolean; } +export interface HeatSummaryOptions { + readonly groupSources?: boolean; +} + /** Builds the exact source/sink rows used to explain a heat projection. */ export function buildHeatSummaryRows( sources: readonly UnitHeatSource[], dissipationBalance: number, consumedDissipation: number, projectedHeat: number, + options: HeatSummaryOptions = {}, ): HeatSummaryRow[] { - const rows: HeatSummaryRow[] = sources - .filter(source => source.value > 0 && source.id !== HEAT_DISSIPATION_DEFICIT_SOURCE_ID) - .map(source => ({ + const rows: HeatSummaryRow[] = []; + const groupedRowIndexes = new Map(); + for (const source of sources) { + if (source.value <= 0 || source.id === HEAT_DISSIPATION_DEFICIT_SOURCE_ID) continue; + const group = options.groupSources ? source.group?.trim() : undefined; + const groupKey = group?.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + if (group && groupKey) { + const existingIndex = groupedRowIndexes.get(groupKey); + if (existingIndex !== undefined) { + const existing = rows[existingIndex]; + rows[existingIndex] = { + ...existing, + value: existing.value + source.value, + ...(source.inventorySelection ? { inventorySelection: true } : {}), + }; + continue; + } + groupedRowIndexes.set(groupKey, rows.length); + rows.push({ + id: groupKey, + label: group, + value: source.value, + kind: 'source', + ...(source.inventorySelection ? { inventorySelection: true } : {}), + }); + continue; + } + rows.push({ id: source.id, label: source.id === 'damaged-engine' ? 'Engine' : source.label, value: source.value, kind: 'source', ...(source.inventorySelection ? { inventorySelection: true } : {}), - })); + }); + } const balance = Number.isFinite(dissipationBalance) ? dissipationBalance : 0; const consumed = Number.isFinite(consumedDissipation) ? Math.max(0, consumedDissipation) : 0; const clippedAtZero = balance > 0 && consumed < balance && projectedHeat === 0; diff --git a/src/app/utils/turn-movement-indicator.util.spec.ts b/src/app/utils/turn-movement-indicator.util.spec.ts index 5a178ac1c..7e83bf761 100644 --- a/src/app/utils/turn-movement-indicator.util.spec.ts +++ b/src/app/utils/turn-movement-indicator.util.spec.ts @@ -20,7 +20,7 @@ describe('getTurnMovementIndicator', () => { ['walk', 'walk', 'W'], ['run', 'run', 'R'], ['jump', 'jump', 'J'], - ['sprint', 'sprint', 'T'], + ['sprint', 'sprint', 'Sp'], ]; for (const [mode, color, letter] of cases) { diff --git a/src/app/utils/turn-movement-indicator.util.ts b/src/app/utils/turn-movement-indicator.util.ts index bcac5555c..3f7be78d1 100644 --- a/src/app/utils/turn-movement-indicator.util.ts +++ b/src/app/utils/turn-movement-indicator.util.ts @@ -16,7 +16,7 @@ const TURN_MOVEMENT_INDICATORS: Readonly Date: Sat, 29 Aug 2026 10:33:13 +0200 Subject: [PATCH 71/87] as.specials --- .../multi-select-dropdown.component.css | 35 +- .../multi-select-dropdown.component.html | 45 +- .../multi-select-dropdown.component.spec.ts | 31 + .../multi-select-dropdown.component.ts | 82 +- .../as-ability-lookup.service.spec.ts | 49 + src/app/services/as-ability-lookup.service.ts | 130 +-- src/app/services/data.service.ts | 9 +- src/app/services/unit-search-filters.model.ts | 8 +- .../unit-search-filters.service.spec.ts | 134 +++ .../services/unit-search-filters.service.ts | 73 +- .../unit-search-index.service.spec.ts | 66 ++ src/app/services/unit-search-index.service.ts | 57 +- src/app/unit-search.worker.spec.ts | 67 ++ src/app/unit-search.worker.ts | 8 + src/app/utils/as-special-filter.util.spec.ts | 153 +++ src/app/utils/as-special-filter.util.ts | 943 ++++++++++++++++++ src/app/utils/semantic-filter-ast.util.ts | 498 +-------- src/app/utils/semantic-filter.util.ts | 55 +- src/app/utils/unit-filter-kernel.util.ts | 35 +- .../unit-search-adv-options-builder.util.ts | 13 +- .../utils/unit-search-executor.util.spec.ts | 85 ++ src/app/utils/unit-search-executor.util.ts | 5 +- src/app/utils/unit-search-shared.util.ts | 11 +- .../unit-search-url-filters.util.spec.ts | 39 +- src/app/utils/unit-search-url-filters.util.ts | 76 +- .../unit-search-worker-request.util.spec.ts | 83 ++ .../utils/unit-search-worker-request.util.ts | 12 +- 27 files changed, 2173 insertions(+), 629 deletions(-) create mode 100644 src/app/services/as-ability-lookup.service.spec.ts create mode 100644 src/app/utils/as-special-filter.util.spec.ts create mode 100644 src/app/utils/as-special-filter.util.ts diff --git a/src/app/components/multi-select-dropdown/multi-select-dropdown.component.css b/src/app/components/multi-select-dropdown/multi-select-dropdown.component.css index 6794d7e5a..6e05fbc86 100644 --- a/src/app/components/multi-select-dropdown/multi-select-dropdown.component.css +++ b/src/app/components/multi-select-dropdown/multi-select-dropdown.component.css @@ -174,9 +174,14 @@ text-overflow: ellipsis; } +.option-item.has-minimum-fields .option-label { + flex-grow: 0; +} + .options-viewport .custom-checkbox, .options-viewport .option-img, -.options-viewport .quantity-input { +.options-viewport .quantity-input, +.options-viewport .minimum-inputs { align-self: center; } @@ -340,6 +345,34 @@ text-align: center; } +.minimum-inputs { + display: flex; + flex: 0 0 auto; + gap: 3px; +} + +.minimum-field { + width: 34px; + min-width: 0; + border: 1px solid #777; + padding: 2px 1px; + box-sizing: border-box; + background-color: transparent; + outline: none; + color: var(--text-color); + text-align: center; + font-size: 0.78rem; +} + +.minimum-field:focus { + border-color: var(--bt-yellow); +} + +.minimum-field::placeholder { + color: var(--text-color-secondary); + opacity: 0.8; +} + /* Semantic-only mode */ .multi-select-container.disabled { cursor: default; diff --git a/src/app/components/multi-select-dropdown/multi-select-dropdown.component.html b/src/app/components/multi-select-dropdown/multi-select-dropdown.component.html index 8a9ce23c3..717ab4cce 100644 --- a/src/app/components/multi-select-dropdown/multi-select-dropdown.component.html +++ b/src/app/components/multi-select-dropdown/multi-select-dropdown.component.html @@ -65,6 +65,9 @@ 'pill-not': option.state === 'not' }"> {{ getDisplayName(option.name) }}{{ (option.count > 1) ? ' (' + option.count + ')' : '' }} + @if (formatMinimumSummary(option.minimumValues); as minimumSummary) { + @if (minimumSummary) { ({{ minimumSummary }}) } + } } @@ -157,6 +160,7 @@ [class.option-section-start]="optionSectionBreakIndexes().has(optionIndex)" [class.unavailable]="option.available === false" [class.selected-single]="!multiselect() && isSelected(option.name)" + [class.has-minimum-fields]="!!option.minimumFieldLabels?.length && (getState(option.name) === 'and' || getState(option.name) === 'or')" (pointerenter)="onOptionPointerHover(option.name)" (pointermove)="onOptionPointerHover(option.name)" (pointerleave)="onOptionPointerLeave(option.name)" @@ -196,6 +200,25 @@ class="option-label" [style.fontSize.px]="getVirtualOptionLabelFontSize(option)" [innerHTML]="highlight(option.displayName ?? option.name)"> + @if (option.minimumFieldLabels; as fieldLabels) { + @if (getState(option.name) === 'and' || getState(option.name) === 'or') { +
+ @for (fieldLabel of fieldLabels; let fieldIndex = $index; track fieldIndex) { + + } +
+ } + } @if (countable() && (getState(option.name) === 'and' || getState(option.name) === 'or')) {
} + @if (option.minimumFieldLabels; as fieldLabels) { + @if (getState(option.name) === 'and' || getState(option.name) === 'or') { +
+ @for (fieldLabel of fieldLabels; let fieldIndex = $index; track fieldIndex) { + + } +
+ } + } @if (countable() && (getState(option.name) === 'and' || getState(option.name) === 'or')) {
- { expect(emittedSelection).toEqual({}); }); + it('renders contextual minimum fields and emits neutral slot values', () => { + const fixture = TestBed.createComponent(MultiSelectDropdownComponent); + let emittedSelection: MultiStateSelection | undefined; + fixture.componentInstance.selectionChange.subscribe(selection => { + emittedSelection = selection as MultiStateSelection; + }); + + fixture.componentRef.setInput('multistate', true); + fixture.componentRef.setInput('options', [{ + name: 'AC', + minimumFieldLabels: ['S', 'M', 'L'], + }]); + fixture.componentRef.setInput('selected', { + AC: { name: 'AC', state: 'or', count: 1 }, + }); + fixture.componentInstance.isOpen.set(true); + fixture.detectChanges(); + + const fields = Array.from(overlayContainerElement.querySelectorAll('.minimum-field')); + expect(fields.map(field => field.placeholder)).toEqual(['S', 'M', 'L']); + + const optionRow = overlayContainerElement.querySelector('.option-item'); + const optionLabel = optionRow?.querySelector('.option-label'); + const minimumInputs = optionRow?.querySelector('.minimum-inputs'); + expect(optionRow?.classList.contains('has-minimum-fields')).toBeTrue(); + expect(optionLabel?.nextElementSibling).toBe(minimumInputs); + + fixture.componentInstance.setMinimumValue('AC', 2, '3', 3); + expect(emittedSelection?.['AC'].minimumValues).toEqual([null, null, 3]); + }); + it('keeps always-visible options in filtered results', () => { const fixture = TestBed.createComponent(MultiSelectDropdownComponent); fixture.componentRef.setInput('options', [ diff --git a/src/app/components/multi-select-dropdown/multi-select-dropdown.component.ts b/src/app/components/multi-select-dropdown/multi-select-dropdown.component.ts index 20ad55b3e..37af6b92a 100644 --- a/src/app/components/multi-select-dropdown/multi-select-dropdown.component.ts +++ b/src/app/components/multi-select-dropdown/multi-select-dropdown.component.ts @@ -20,6 +20,8 @@ export interface DropdownOption { alwaysVisible?: boolean; exclusive?: boolean; stateCycle?: readonly ('or' | 'and' | 'not')[]; + /** Contextual minimum-value inputs shown when this option is selected. */ + minimumFieldLabels?: readonly string[]; } export type MultiState = false | 'or' | 'and' | 'not'; @@ -40,6 +42,8 @@ export interface MultiStateOption { countIncludeRanges?: [number, number][]; /** Exclude ranges for quantity (merged from multiple constraints) */ countExcludeRanges?: [number, number][]; + /** Per-slot inclusive minima. A null entry leaves that slot unconstrained. */ + minimumValues?: (number | null)[]; } export interface MultiStateSelection { @@ -163,11 +167,28 @@ export class MultiSelectDropdownComponent { const sel = (this.selected() as MultiStateSelection) || {}; return Object.entries(sel) .filter(([_, selection]) => selection.state !== false) - .map(([name, selection]) => ({ name, state: selection.state, count: selection.count })); - } - return (this.selected() as readonly string[] || []).map((name: string) => ({ name, state: 'or' as MultiState, count: 1 })); + .map(([name, selection]) => ({ + name, + state: selection.state, + count: selection.count, + minimumValues: selection.minimumValues, + })); + } + return (this.selected() as readonly string[] || []).map((name: string) => ({ + name, + state: 'or' as MultiState, + count: 1, + minimumValues: undefined, + })); }); + formatMinimumSummary(values: readonly (number | null)[] | undefined): string { + if (!values?.some(value => value !== null && value !== undefined)) { + return ''; + } + return values.map(value => value === null || value === undefined ? '–' : `≥${value}`).join('/'); + } + /** When more than 5 pills, compress into summary pills grouped by state */ private static readonly COMPRESS_THRESHOLD = 5; compressedPills = computed<{ state: MultiState; count: number }[] | null>(() => { @@ -996,7 +1017,13 @@ export class MultiSelectDropdownComponent { } } const count = nextState === 'not' ? 1 : current.count; - currentSelection[optionName] = { name: optionName, state: nextState, count }; + currentSelection[optionName] = { + ...current, + name: optionName, + state: nextState, + count, + ...(nextState === 'not' ? { minimumValues: undefined } : {}), + }; } this.selectionChange.emit(currentSelection); } else { @@ -1146,6 +1173,7 @@ export class MultiSelectDropdownComponent { if (current && (current.state === 'and' || current.state === 'or')) { currentSelection[optionName] = { + ...current, name: optionName, state: current.state, count: Math.max(1, count) @@ -1155,6 +1183,52 @@ export class MultiSelectDropdownComponent { this.restoreScrollPosition(restoreState); } + getMinimumValue(optionName: string, index: number): number | '' { + if (!this.multistate()) { + return ''; + } + const selection = this.selected() as MultiStateSelection; + return selection[optionName]?.minimumValues?.[index] ?? ''; + } + + setMinimumValue(optionName: string, index: number, rawValue: string, fieldCount: number): void { + if (!this.multistate()) { + return; + } + + const selection = this.selected() as MultiStateSelection; + const current = selection[optionName]; + if (!current || (current.state !== 'and' && current.state !== 'or')) { + return; + } + + const parsedValue = rawValue.trim() === '' ? null : Number(rawValue); + if (parsedValue !== null && (!Number.isFinite(parsedValue) || parsedValue < 0)) { + return; + } + + const minimumValues: (number | null)[] = Array(fieldCount) + .fill(null) + .map((_, fieldIndex) => current.minimumValues?.[fieldIndex] ?? null); + minimumValues[index] = parsedValue; + + const currentSelection: MultiStateSelection = { ...selection }; + currentSelection[optionName] = { + ...current, + minimumValues: minimumValues.some(value => value !== null) ? minimumValues : undefined, + }; + this.selectionChange.emit(currentSelection); + } + + onMinimumInput(optionName: string, index: number, fieldCount: number, event: Event): void { + this.setMinimumValue(optionName, index, (event.target as HTMLInputElement).value, fieldCount); + } + + onMinimumWheel(event: WheelEvent): void { + event.preventDefault(); + event.stopPropagation(); + } + trackOptionName = (_index: number, option: DropdownOption) => option.name; onQuantityInput(optionName: string, event: Event) { diff --git a/src/app/services/as-ability-lookup.service.spec.ts b/src/app/services/as-ability-lookup.service.spec.ts new file mode 100644 index 000000000..746719a25 --- /dev/null +++ b/src/app/services/as-ability-lookup.service.spec.ts @@ -0,0 +1,49 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { TestBed } from '@angular/core/testing'; +import { AsAbilityLookupService } from './as-ability-lookup.service'; +import { LoggerService } from './logger.service'; + +describe('AsAbilityLookupService shared specials AST', () => { + let service: AsAbilityLookupService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + AsAbilityLookupService, + { + provide: LoggerService, + useValue: { + info: jasmine.createSpy('info'), + warn: jasmine.createSpy('warn'), + error: jasmine.createSpy('error'), + }, + }, + ], + }); + service = TestBed.inject(AsAbilityLookupService); + }); + + it('projects TUR damage and nested abilities from the shared parser', () => { + const parsed = service.parseAbility('TUR(3/3/3,IF2,LRM3/3/2,SNARC)'); + + expect(parsed.ability).not.toBeNull(); + expect(parsed.turretDamage).toBe('3/3/3'); + expect(parsed.subAbilities?.map(ability => ability.originalText)).toEqual([ + 'IF2', + 'LRM3/3/2', + 'SNARC', + ]); + expect(parsed.subAbilities?.every(ability => ability.ability !== null)).toBeTrue(); + }); + + it('keeps non-TUR parenthesized values as parameters rather than child abilities', () => { + const parsed = service.parseAbility('LAM(6"g/12a)'); + + expect(parsed.ability).not.toBeNull(); + expect(parsed.subAbilities).toEqual([]); + expect(parsed.turretDamage).toBeUndefined(); + }); +}); diff --git a/src/app/services/as-ability-lookup.service.ts b/src/app/services/as-ability-lookup.service.ts index e7ad24988..95aa8b4a1 100644 --- a/src/app/services/as-ability-lookup.service.ts +++ b/src/app/services/as-ability-lookup.service.ts @@ -6,6 +6,11 @@ import { inject, Injectable } from '@angular/core'; import { AS_SPECIAL_ABILITIES, type ASSpecialAbility } from '../models/as-abilities.model'; import { type AlternateMunition, getAlternateMunitionsForAbility } from '../models/as-alternate-munitions.model'; import type { UnitSummary } from '../models/unit-summary.model'; +import { + isASSpecialDamageValue, + parseASSpecialAbility, + type ASSpecialAbilityNode, +} from '../utils/as-special-filter.util'; import { LoggerService } from './logger.service'; /** @@ -212,14 +217,6 @@ export class AsAbilityLookupService { return result; } - /** - * Checks if the content represents a damage pattern (#/#/# or similar) - */ - private isDamagePattern(content: string): boolean { - // Damage patterns are like 0*/1/1 or 2/2/- (numbers/dashes separated by /) - return /^[\d*]+(?:\/[\d*-]+)+$/.test(content.replace(/\s+/g, '')); - } - /** * Extracts the consumable count from an ability text. * Handles patterns like: BOMB4 -> 4, MDS2 -> 2, BTAS3 -> 3, TSEMP2-O4 -> 4, FUEL120 -> 120 @@ -245,107 +242,42 @@ export class AsAbilityLookupService { * Returns the main ability and any sub-abilities. */ parseCompositeAbility(abilityText: string): ParsedAbility { - const result: ParsedAbility = { - originalText: abilityText, - ability: null, - subAbilities: [] - }; - - // Check for parentheses - const parenMatch = abilityText.match(/^([A-Z]+[\dA-Z]*)\s*\((.+)\)$/i); - - if (!parenMatch) { - // Not a composite ability, just look it up directly - result.ability = this.lookupAbility(abilityText); - // Extract consumable max if this is a consumable ability - if (result.ability?.consumable) { - result.consumableMax = this.extractConsumableMax(abilityText); - } - if (result.ability) { - result.alternateMunitions = getAlternateMunitionsForAbility(result.ability); - } - return result; + const node = parseASSpecialAbility(abilityText); + if (!node) { + return { + originalText: abilityText, + ability: null, + subAbilities: [], + }; } - const mainAbilityName = parenMatch[1]; - const innerContent = parenMatch[2]; + return this.toParsedAbility(node); + } - // Look up the main ability (e.g., TUR -> TUR#) - result.ability = this.lookupAbility(mainAbilityName); - if (result.ability) { - result.alternateMunitions = getAlternateMunitionsForAbility(result.ability); - } + /** Project the shared structural specials AST into lookup metadata. */ + private toParsedAbility(node: ASSpecialAbilityNode): ParsedAbility { + const ability = this.lookupAbility(node.lookupText); + const result: ParsedAbility = { + originalText: node.rawText, + ability, + subAbilities: [], + ...(node.turretDamage ? { turretDamage: node.turretDamage } : {}), + }; - // Only TUR has true composite sub-abilities - // Other abilities with parentheses (BIM, LAM, etc.) just have parameters - if (mainAbilityName.toUpperCase() !== 'TUR') { - return result; + if (ability?.consumable) { + result.consumableMax = this.extractConsumableMax(node.rawText); + } + if (ability) { + result.alternateMunitions = getAlternateMunitionsForAbility(ability); } - // Parse inner content for TUR composite abilities - // Split by comma, but handle nested abilities carefully - const parts = this.splitPreservingParentheses(innerContent); - - for (const part of parts) { - const trimmedPart = part.trim(); - - // Check if this part is a damage pattern - if (this.isDamagePattern(trimmedPart)) { - result.turretDamage = trimmedPart; - continue; - } - - // Try to parse as an ability - const subAbility = this.parseCompositeAbility(trimmedPart); + for (const child of node.children) { + const subAbility = this.toParsedAbility(child); if (subAbility.ability || subAbility.subAbilities?.length) { result.subAbilities!.push(subAbility); - } else { - // If we couldn't find an ability, check if it's an ability without a number - // e.g., SNARC, TAG (implicitly SNARC1, but shown without the 1) - const implicitAbility = this.lookupAbility(trimmedPart + '1') || - this.lookupAbility(trimmedPart); - if (implicitAbility) { - result.subAbilities!.push({ - originalText: trimmedPart, - ability: implicitAbility, - alternateMunitions: getAlternateMunitionsForAbility(implicitAbility) - }); - } - } - } - - return result; - } - - /** - * Splits a string by commas but preserves content within parentheses. - */ - private splitPreservingParentheses(content: string): string[] { - const result: string[] = []; - let current = ''; - let depth = 0; - - for (const char of content) { - if (char === '(') { - depth++; - current += char; - } else if (char === ')') { - depth--; - current += char; - } else if (char === ',' && depth === 0) { - if (current.trim()) { - result.push(current.trim()); - } - current = ''; - } else { - current += char; } } - if (current.trim()) { - result.push(current.trim()); - } - return result; } @@ -406,7 +338,7 @@ export class AsAbilityLookupService { for (const sub of parsed.subAbilities) { if (!sub.ability && sub.originalText) { // Only log if it's not a damage pattern - if (!this.isDamagePattern(sub.originalText)) { + if (!isASSpecialDamageValue(sub.originalText)) { const key = `${abilityText} -> ${sub.originalText}`; const existing = unmatchedAbilities.get(key) || []; existing.push(unit.name); diff --git a/src/app/services/data.service.ts b/src/app/services/data.service.ts index fb4deee71..b3e605ed4 100644 --- a/src/app/services/data.service.ts +++ b/src/app/services/data.service.ts @@ -39,6 +39,7 @@ import type { MegaMekRulesetRecord } from '../models/megamek/rulesets.model'; import type { ForceNameWords } from '../models/force-name-words.model'; import { getForcePacks } from '../models/forcepacks.model'; import type { UnitSearchWorkerFactionEraSnapshot, UnitSearchWorkerIndexSnapshot } from '../utils/unit-search-worker-protocol.util'; +import type { ParsedASSpecials } from '../utils/as-special-filter.util'; import { MegaMekAvailabilityCatalogService } from './catalogs/megamek-availability-catalog.service'; import { MegaMekFactionsCatalogService } from './catalogs/megamek-factions-catalog.service'; import { MegaMekRulesetsCatalogService } from './catalogs/megamek-rulesets-catalog.service'; @@ -47,7 +48,7 @@ import { FactionsCatalogService } from './catalogs/mulfactions-catalog.service'; import { QuirksCatalogService } from './catalogs/quirks-catalog.service'; import { SarnaPageTitlesCatalogService } from './catalogs/sarna-page-titles-catalog.service'; import { SourcebooksCatalogService } from './catalogs/sourcebooks-catalog.service'; -import { UnitSearchIndexService } from './unit-search-index.service'; +import { UnitSearchIndexService, type UnitSearchDropdownOption } from './unit-search-index.service'; import { UnitRuntimeService } from './unit-runtime.service'; import { UnitsCatalogService } from './catalogs/units-catalog.service'; import { UnitsFluffCatalogService } from './catalogs/units-fluff-catalog.service'; @@ -431,6 +432,10 @@ export class DataService { return this.unitSearchIndexService.getIndexedFilterValues(filterKey); } + public getIndexedASSpecials(unitName: string): ParsedASSpecials | undefined { + return this.unitSearchIndexService.getIndexedASSpecials(unitName); + } + public getSearchWorkerIndexSnapshot(): UnitSearchWorkerIndexSnapshot { return this.unitSearchIndexService.getSearchWorkerIndexSnapshot(); } @@ -439,7 +444,7 @@ export class DataService { return this.unitSearchIndexService.getSearchWorkerFactionEraSnapshot(); } - public getDropdownOptionUniverse(filterKey: string): Array<{ name: string; img?: string }> { + public getDropdownOptionUniverse(filterKey: string): UnitSearchDropdownOption[] { return this.unitSearchIndexService.getDropdownOptionUniverse(filterKey); } diff --git a/src/app/services/unit-search-filters.model.ts b/src/app/services/unit-search-filters.model.ts index 89680db33..5364197b5 100644 --- a/src/app/services/unit-search-filters.model.ts +++ b/src/app/services/unit-search-filters.model.ts @@ -137,7 +137,13 @@ export interface SemanticDisplayItem { export type DropdownFilterOptions = { type: 'dropdown'; label: string; - options: { name: string, img?: string, displayName?: string, available?: boolean }[]; + options: { + name: string; + img?: string; + displayName?: string; + available?: boolean; + minimumFieldLabels?: readonly string[]; + }[]; value: string[] | MultiStateSelection; interacted: boolean; semanticOnly?: boolean; // True if this filter has semantic-only constraints (values not in options) diff --git a/src/app/services/unit-search-filters.service.spec.ts b/src/app/services/unit-search-filters.service.spec.ts index 81adae4c0..a28f2481b 100644 --- a/src/app/services/unit-search-filters.service.spec.ts +++ b/src/app/services/unit-search-filters.service.spec.ts @@ -4804,6 +4804,114 @@ describe('UnitSearchFiltersService search telemetry', () => { expect(sourceB).toEqual(jasmine.objectContaining({ name: 'SRC-B', available: false })); }); + it('applies Alpha Strike special minima when calculating self-filter drilldowns', () => { + const bundle = createStandaloneBundle(); + bundle.units.units[0].as.specials = ['AC1/3/1', 'AFC']; + bundle.units.units[1].as.specials = ['AC1/4/1', 'TAG']; + bundle.units.units.push(createTestUnit({ + id: 3, + name: 'Nested AC', + as: { + ...createTestUnit({}).as, + specials: ['TUR(2/2/2,AC1/5/1)', 'TSM'], + }, + })); + + const { service, gameServiceStub } = createService(bundle); + gameServiceStub.currentGameSystem.set(GameSystem.ALPHA_STRIKE); + service.setFilter('as.specials', { + AC: { + name: 'AC', + state: 'and', + count: 1, + minimumValues: [null, 4, null], + }, + }); + + expect(service.filteredUnits().map(unit => unit.name).sort()).toEqual(['Nested AC', 'Test Tank']); + + const options = (service.advOptions()['as.specials']?.options ?? []) + .filter((option): option is { name: string; available?: boolean } => typeof option !== 'number'); + expect(options.find(option => option.name === 'AC')).toEqual(jasmine.objectContaining({ available: true })); + expect(options.find(option => option.name === 'TAG')).toEqual(jasmine.objectContaining({ available: true })); + expect(options.find(option => option.name === 'TSM')).toEqual(jasmine.objectContaining({ available: true })); + expect(options.find(option => option.name === 'AFC')).toEqual(jasmine.objectContaining({ available: false })); + + service.setFilter('as.specials', { + AC: { + name: 'AC', + state: 'and', + count: 1, + minimumValues: [null, 4, null], + }, + TAG: { + name: 'TAG', + state: 'and', + count: 1, + }, + }); + expect(service.filteredUnits().map(unit => unit.name)).toEqual(['Test Tank']); + }); + + it('preserves repeated specials clauses when building the equivalent worker query', () => { + const bundle = createStandaloneBundle(); + bundle.units.units[0].as.specials = ['AC1/5/1']; + bundle.units.units[1].as.specials = ['AC1/1/4']; + bundle.units.units.push(createTestUnit({ + id: 3, + name: 'Both AC Ranges', + as: { + ...createTestUnit({}).as, + specials: ['AC1/5/4'], + }, + })); + + const { service, gameServiceStub } = createService(bundle); + gameServiceStub.currentGameSystem.set(GameSystem.ALPHA_STRIKE); + service.searchText.set('specials&="AC*/>=4/*" specials&="AC*/*/>=3"'); + + expect(service.filteredUnits().map(unit => unit.name)).toEqual(['Both AC Ranges']); + + const request = (service as any).buildWorkerSearchRequest((service as any).getWorkerCorpusVersion()); + expect(request.executionQuery).toBe('specials&="AC*/>=4/*" specials&="AC*/*/>=3"'); + }); + + it('calculates specials drilldowns against NOT-only selections', () => { + const bundle = createStandaloneBundle(); + bundle.units.units[0].as.specials = ['ECM', 'TAG']; + bundle.units.units[1].as.specials = ['AC2/2/2']; + + const { service, gameServiceStub } = createService(bundle); + gameServiceStub.currentGameSystem.set(GameSystem.ALPHA_STRIKE); + service.setFilter('as.specials', { + ECM: { + name: 'ECM', + state: 'not', + count: 1, + }, + }); + + expect(service.filteredUnits().map(unit => unit.name)).toEqual(['Test Tank']); + const options = (service.advOptions()['as.specials']?.options ?? []) + .filter((option): option is { name: string; available?: boolean } => typeof option !== 'number'); + expect(options.find(option => option.name === 'AC')).toEqual(jasmine.objectContaining({ available: true })); + expect(options.find(option => option.name === 'TAG')).toEqual(jasmine.objectContaining({ available: false })); + + service.setFilter('as.specials', { + ECM: { + name: 'ECM', + state: 'not', + count: 1, + }, + TAG: { + name: 'TAG', + state: 'or', + count: 1, + }, + }); + expect(service.filteredUnits()).toEqual([]); + }); + it('does not throw when stale multistate era state is present', () => { if (!benchmarkBundle || benchmarkBundle.units.units.length < 2) { pending('Real unit data could not be loaded for the era state regression test.'); @@ -4965,6 +5073,32 @@ describe('UnitSearchFiltersService search telemetry', () => { expect(service.queryParameters()['filters']).toBe(`as.specials:"${special}"`); }); + it('filters canonical Alpha Strike specials by populated minimum damage bands', () => { + const bundle = createStandaloneBundle(); + bundle.units.units[0].as.specials = ['AC2/2/2']; + bundle.units.units[1].as.specials = ['TUR(3/3/3,AC1/1/4)']; + + const { service, gameServiceStub } = createService(bundle); + gameServiceStub.currentGameSystem.set(GameSystem.ALPHA_STRIKE); + + const acOption = (service.advOptions()['as.specials']?.options ?? []) + .filter(option => typeof option !== 'number') + .find(option => option.name === 'AC'); + expect(acOption?.minimumFieldLabels).toEqual(['S', 'M', 'L']); + + service.setFilter('as.specials', { + AC: { + name: 'AC', + state: 'or', + count: 1, + minimumValues: [null, null, 3], + }, + }); + + expect(service.filteredUnits().map(unit => unit.name)).toEqual(['Test Tank']); + expect(service.queryParameters()['filters']).toBe('as.specials:AC^//3'); + }); + it('matches units when the selected rulebooks cover a complete bucket', () => { const bundle = createStandaloneBundle(); bundle.units.units[0].name = 'Unit A'; diff --git a/src/app/services/unit-search-filters.service.ts b/src/app/services/unit-search-filters.service.ts index 6f4908517..568f74095 100644 --- a/src/app/services/unit-search-filters.service.ts +++ b/src/app/services/unit-search-filters.service.ts @@ -44,7 +44,12 @@ import { getSnapshotForcePackNames, type AdvOptionsContextSnapshot } from '../ut import { buildUnitSearchAdvOptions } from '../utils/unit-search-adv-options-builder.util'; import type { UnitSearchDropdownValuesDependencies } from '../utils/unit-search-dropdown-values.util'; import { applyFilterStateToUnits, type UnitFilterKernelDependencies } from '../utils/unit-filter-kernel.util'; -import { getAdvancedFilterConfigByKey, isFilterAvailableForAvailabilitySource, normalizeUnitSearchPropertyKey } from '../utils/unit-search-filter-config.util'; +import { + getAdvancedFilterConfigByKey, + getAdvancedFilterConfigBySemanticField, + isFilterAvailableForAvailabilitySource, + normalizeUnitSearchPropertyKey, +} from '../utils/unit-search-filter-config.util'; import { buildUnitSearchQueryParameters, parseAndValidateCompactFiltersFromUrl, parseUnitSearchScalarUrlState, resolveInitialUnitSearchViewMode } from '../utils/unit-search-url-filters.util'; import type { UnitSearchViewMode } from '../models/options.model'; import { generatePublicTagsParam, mergePublicTagReferences, parsePublicTagsParam } from '../utils/unit-search-public-tags-url.util'; @@ -94,6 +99,7 @@ import { } from '../utils/filter-name-resolution.util'; import { sortAvailableDropdownOptions, sortDropdownOptionObjects } from '../utils/unit-search-dropdown-sort.util'; import { compareUnitsByName } from '../utils/sort.util'; +import { unitMatchesASSpecialSelections } from '../utils/as-special-filter.util'; import type { UnitSearchWorkerCorpusSnapshot, UnitSearchWorkerQueryRequest, UnitSearchWorkerResultMessage } from '../utils/unit-search-worker-protocol.util'; import { ADVANCED_FILTERS, @@ -180,7 +186,7 @@ export class UnitSearchFiltersService { contextUnits: UnitSummary[], displayNameFn?: (value: string) => string | undefined, contextUnitIds?: ReadonlySet, - ): { name: string; img?: string; displayName?: string; available?: boolean }[] { + ): DropdownOption[] { const universe = this.dataService.getDropdownOptionUniverse(conf.key); if (universe.length === 0) { return []; @@ -194,6 +200,7 @@ export class UnitSearchFiltersService { return { name: option.name, ...(option.img ? { img: option.img } : {}), + ...(option.minimumFieldLabels ? { minimumFieldLabels: option.minimumFieldLabels } : {}), ...(displayNameFn ? { displayName: displayNameFn(option.name) } : {}), available, }; @@ -1978,11 +1985,19 @@ export class UnitSearchFiltersService { private buildWorkerSearchRequest(corpusVersion: string): UnitSearchWorkerQueryRequest { const gameSystem = this.gameService.currentGameSystem(); const workerFilterState = this.getWorkerFilterState(this.getApplicableFilterState(this.effectiveFilterState())); + const workerUiOnlyFilterState = this.getUiOnlyFilterState(workerFilterState, this.semanticFilterKeys()); + const workerSemanticTokenTexts = getCommittedSemanticTokens(this.semanticParsedAST().tokens) + .filter(token => { + const conf = getAdvancedFilterConfigBySemanticField(token.field); + return !conf || !this.shouldStripFilterFromWorker(conf.key); + }) + .map(token => token.rawText); const executionQuery = this.isComplexQuery() ? this.searchText().trim() : buildWorkerExecutionQuery({ - effectiveFilterState: workerFilterState, + effectiveFilterState: workerUiOnlyFilterState, effectiveTextSearch: this.effectiveTextSearch(), + semanticTokenTexts: workerSemanticTokenTexts, gameSystem, totalRangesCache: this.totalRangesCache, }); @@ -2751,7 +2766,8 @@ export class UnitSearchFiltersService { isCountableFilter: boolean, ): Set | null { const andEntries = Object.entries(selection).filter(([, sel]) => sel.state === 'and'); - if (andEntries.length === 0) { + const notEntries = Object.entries(selection).filter(([, sel]) => sel.state === 'not'); + if (andEntries.length === 0 && notEntries.length === 0) { return null; } @@ -2760,9 +2776,7 @@ export class UnitSearchFiltersService { sel.count, ])); const notSet = new Set( - Object.entries(selection) - .filter(([, sel]) => sel.state === 'not') - .map(([name]) => name.toLowerCase()), + notEntries.map(([name]) => name.toLowerCase()), ); const availableNames = new Set(); @@ -2770,7 +2784,9 @@ export class UnitSearchFiltersService { const universeNames = this.getIndexedUniverseNames(filterKey); if (universeNames.length > 0) { const contextUnitIds = new Set(units.map(unit => unit.name)); - let constrainedUnitIds: Set | null = null; + let constrainedUnitIds: Set | null = andEntries.length === 0 + ? new Set(contextUnitIds) + : null; for (const [selectedName] of andEntries) { const indexedIds = this.dataService.getIndexedUnitIds(filterKey, selectedName); @@ -2799,20 +2815,38 @@ export class UnitSearchFiltersService { return availableNames; } - for (const excludedName of notSet) { - const universeMatch = universeNames.find(name => name.toLowerCase() === excludedName); - if (!universeMatch) { - continue; - } - const excludedIds = this.dataService.getIndexedUnitIds(filterKey, universeMatch); - if (!excludedIds) { - continue; - } + if (filterKey === 'as.specials') { + const constrainedSelections = Object.values(selection).filter(selected => ( + selected.state === 'and' || selected.state === 'not' + )); + const unitsByName = new Map(units.map(unit => [unit.name, unit])); + for (const unitId of Array.from(constrainedUnitIds)) { - if (excludedIds.has(unitId)) { + const unit = unitsByName.get(unitId); + if (!unit || !unitMatchesASSpecialSelections( + getProperty(unit, filterKey), + constrainedSelections, + this.dataService.getIndexedASSpecials(unitId), + )) { constrainedUnitIds.delete(unitId); } } + } else { + for (const excludedName of notSet) { + const universeMatch = universeNames.find(name => name.toLowerCase() === excludedName); + if (!universeMatch) { + continue; + } + const excludedIds = this.dataService.getIndexedUnitIds(filterKey, universeMatch); + if (!excludedIds) { + continue; + } + for (const unitId of Array.from(constrainedUnitIds)) { + if (excludedIds.has(unitId)) { + constrainedUnitIds.delete(unitId); + } + } + } } if (constrainedUnitIds.size === 0) { @@ -3425,6 +3459,8 @@ export class UnitSearchFiltersService { return getPositiveDropdownNamesFromFilter(selectedFactionEntries, allFactionNames, wildcardPatterns); }, getAvailabilityLookupKey: unit => this.unitAvailabilitySource.getUnitAvailabilityKey(unit), + getIndexedUnitIds: (filterKey, value) => this.dataService.getIndexedUnitIds(filterKey, value), + getIndexedASSpecials: unitName => this.dataService.getIndexedASSpecials(unitName), unitMatchesAvailabilityFrom: (unit, availabilityFromName, scope) => this.unitMatchesAvailabilityFrom(unit, availabilityFromName, scope), unitMatchesAvailabilityRarity: (unit, rarityName, scope) => @@ -3497,6 +3533,7 @@ export class UnitSearchFiltersService { }, getIndexedUnitIds: (filterKey: string, value: string, scope?: AvailabilityFilterScope) => this.getSemanticIndexedUnitIds(filterKey, value, scope), getIndexedFilterValues: (filterKey: string) => this.getSemanticIndexedFilterValues(filterKey), + getIndexedASSpecials: (unitId: string) => this.dataService.getIndexedASSpecials(unitId), availabilitySortScope: megaMekRaritySortScope, getMegaMekRaritySortScore: megaMekRaritySortScoreResolver ? (unit: UnitSummary) => megaMekRaritySortScoreResolver(unit) diff --git a/src/app/services/unit-search-index.service.spec.ts b/src/app/services/unit-search-index.service.spec.ts index b5461ca63..179ed696c 100644 --- a/src/app/services/unit-search-index.service.spec.ts +++ b/src/app/services/unit-search-index.service.spec.ts @@ -35,6 +35,72 @@ function createUnit(overrides: TestUnitOverrides): UnitSummary { } describe('UnitSearchIndexService', () => { + it('indexes canonical Alpha Strike special tokens, nested turret abilities, and observed parameter shapes', () => { + const service = new UnitSearchIndexService(); + service.rebuildIndexes([ + createUnit({ + name: 'Special Unit', + as: { + specials: ['AC2/2/2', 'TAG', 'TSM', 'TUR(3/3/3,IF2,LRM3/3/2)'], + }, + }), + ], [], []); + + expect(service.getIndexedFilterValues('as.specials')).toEqual([ + 'AC', + 'IF', + 'LRM', + 'TAG', + 'TSM', + 'TUR', + ]); + expect(service.getIndexedUnitIds('as.specials', 'IF')).toEqual(new Set(['Special Unit'])); + expect(service.getIndexedUnitIds('as.specials', 'TUR(3/3/3,IF2,LRM3/3/2)')).toBeUndefined(); + const indexedSpecials = service.getIndexedASSpecials('Special Unit'); + expect(indexedSpecials?.occurrences.find(occurrence => occurrence.token === 'AC')?.values) + .toEqual([ + { text: '2', rank: 2 }, + { text: '2', rank: 2 }, + { text: '2', rank: 2 }, + ]); + expect(indexedSpecials?.occurrences.find(occurrence => occurrence.token === 'IF')) + .toEqual(jasmine.objectContaining({ + token: 'IF', + values: [{ text: '2', rank: 2 }], + topLevel: false, + })); + expect(service.getDropdownOptionUniverse('as.specials')).toEqual([ + { name: 'AC', minimumFieldLabels: ['S', 'M', 'L'] }, + { name: 'IF', minimumFieldLabels: [''] }, + { name: 'LRM', minimumFieldLabels: ['S', 'M', 'L'] }, + { name: 'TAG' }, + { name: 'TSM' }, + { name: 'TUR', minimumFieldLabels: ['S', 'M', 'L'] }, + ]); + }); + + it('indexes implicit values and digit-bearing artillery tokens with contextual fields', () => { + const service = new UnitSearchIndexService(); + service.rebuildIndexes([ + createUnit({ + name: 'Implicit Unit', + as: { specials: ['SNARC', 'CNARC', 'ARTCM5-1', 'TAG'] }, + }), + ], [], []); + + expect(service.getIndexedUnitIds('as.specials', 'SNARC')).toEqual(new Set(['Implicit Unit'])); + expect(service.getIndexedUnitIds('as.specials', 'ARTCM5')).toEqual(new Set(['Implicit Unit'])); + expect(service.getIndexedASSpecials('Implicit Unit')?.occurrences + .find(occurrence => occurrence.token === 'SNARC')?.values) + .toEqual([{ text: '1', rank: 1 }]); + expect(service.getDropdownOptionUniverse('as.specials')).toEqual([ + { name: 'ARTCM5', minimumFieldLabels: [''] }, + { name: 'CNARC', minimumFieldLabels: [''] }, + { name: 'SNARC', minimumFieldLabels: [''] }, + { name: 'TAG' }, + ]); + }); + it('indexes mixed and nonmixed units as distinct tech-base filter values', () => { const service = new UnitSearchIndexService(); diff --git a/src/app/services/unit-search-index.service.ts b/src/app/services/unit-search-index.service.ts index 8bad8d35c..e8300c28e 100644 --- a/src/app/services/unit-search-index.service.ts +++ b/src/app/services/unit-search-index.service.ts @@ -17,6 +17,17 @@ import type { UnitSearchWorkerFactionEraSnapshot, UnitSearchWorkerIndexSnapshot import { MULFACTION_EXTINCT } from '../models/mulfactions.model'; import { WeaponEquipment } from '../models/equipment.model'; import { WEAPON_TYPES, type WeaponType } from '../models/weapon-types.model'; +import { + buildASSpecialsByUnitIndex, + getASSpecialMinimumFieldLabels, + type ParsedASSpecials, +} from '../utils/as-special-filter.util'; + +export interface UnitSearchDropdownOption { + name: string; + img?: string; + minimumFieldLabels?: readonly string[]; +} interface ASUnitTypeMaxStats { [asUnitType: string]: MinMaxStatsRange; @@ -107,7 +118,9 @@ export class UnitSearchIndexService { private searchFilterIndex = new Map>>(); private componentCountIndex = new Map>(); private searchFilterValues = new Map(); - private dropdownOptionUniverse = new Map>(); + private dropdownOptionUniverse = new Map(); + private asSpecialFieldCounts = new Map(); + private asSpecialsByUnit = new Map(); private factionEraSnapshot: UnitSearchWorkerFactionEraSnapshot = {}; public prepareUnits(units: UnitSummary[]): void { @@ -282,6 +295,12 @@ export class UnitSearchIndexService { this.searchFilterIndex = new Map>>(); this.componentCountIndex = new Map>(); this.searchFilterValues = new Map(); + this.asSpecialFieldCounts = new Map(); + this.asSpecialsByUnit = buildASSpecialsByUnitIndex( + units, + unit => unit.name, + unit => unit.as?.specials, + ); const unitNamesByMulId = this.createUnitNamesByMulId(units); @@ -295,7 +314,7 @@ export class UnitSearchIndexService { this.addSearchIndexValue('c3', unit.c3, unit.name); this.addSearchIndexValue('moveType', unit.moveType, unit.name); this.addSearchIndexValue('as.TP', unit.as?.TP, unit.name); - this.addSearchIndexValues('as.specials', unit.as?.specials ?? [], unit.name); + this.addASSpecialIndexValues(this.asSpecialsByUnit.get(unit.name), unit.name); this.addSearchIndexValues('as._motive', this.getASMotiveDisplayNames(unit), unit.name); this.addSearchIndexValues('source', getUnitSourceFilterValues(unit), unit.name); this.addSearchIndexValues('rulesRefs', unit.rulesRefs?.flat() ?? [], unit.name); @@ -380,6 +399,10 @@ export class UnitSearchIndexService { return this.searchFilterValues.get(filterKey) ?? []; } + public getIndexedASSpecials(unitName: string): ParsedASSpecials | undefined { + return this.asSpecialsByUnit.get(unitName); + } + public getSearchWorkerIndexSnapshot(): UnitSearchWorkerIndexSnapshot { const snapshot: UnitSearchWorkerIndexSnapshot = {}; @@ -399,7 +422,7 @@ export class UnitSearchIndexService { ); } - public getDropdownOptionUniverse(filterKey: string): Array<{ name: string; img?: string }> { + public getDropdownOptionUniverse(filterKey: string): UnitSearchDropdownOption[] { return this.dropdownOptionUniverse.get(filterKey)?.map(option => ({ ...option })) ?? []; } @@ -416,7 +439,7 @@ export class UnitSearchIndexService { } private rebuildDropdownOptionUniverse(eras: Era[], factions: Faction[]): void { - this.dropdownOptionUniverse = new Map>(); + this.dropdownOptionUniverse = new Map(); for (const filterKey of [ 'type', 'subtype', @@ -437,13 +460,37 @@ export class UnitSearchIndexService { 'quirks', '_tags', ]) { - this.dropdownOptionUniverse.set(filterKey, this.getIndexedFilterValues(filterKey).map(name => ({ name }))); + this.dropdownOptionUniverse.set(filterKey, this.getIndexedFilterValues(filterKey).map(name => ({ + name, + ...(filterKey === 'as.specials' && (this.asSpecialFieldCounts.get(name) ?? 0) > 0 + ? { + minimumFieldLabels: getASSpecialMinimumFieldLabels( + name, + this.asSpecialFieldCounts.get(name) ?? 0, + ), + } + : {}), + }))); } this.dropdownOptionUniverse.set('era', eras.map(era => ({ name: era.name, img: era.img }))); this.dropdownOptionUniverse.set('faction', factions.map(faction => ({ name: faction.name, img: faction.img }))); } + private addASSpecialIndexValues(parsedSpecials: ParsedASSpecials | undefined, unitName: string): void { + for (const occurrence of parsedSpecials?.occurrences ?? []) { + if (!occurrence.token) { + continue; + } + + this.addSearchIndexValue('as.specials', occurrence.token, unitName); + const currentFieldCount = this.asSpecialFieldCounts.get(occurrence.token) ?? 0; + if (occurrence.values.length > currentFieldCount) { + this.asSpecialFieldCounts.set(occurrence.token, occurrence.values.length); + } + } + } + private createFactionEraSnapshot(unitNamesByMulId: Map, eras: Era[], factions: Faction[]): UnitSearchWorkerFactionEraSnapshot { const snapshot: UnitSearchWorkerFactionEraSnapshot = {}; const erasById = new Map(eras.map(era => [era.id, era])); diff --git a/src/app/unit-search.worker.spec.ts b/src/app/unit-search.worker.spec.ts index 511fcba89..7d558b8f1 100644 --- a/src/app/unit-search.worker.spec.ts +++ b/src/app/unit-search.worker.spec.ts @@ -253,4 +253,71 @@ describe('unit-search worker', () => { expect(getEntries('rulesRefs=Shrap01')).toEqual([{ unitName: 'Unit B' }]); expect(getEntries('rulesRefs=AAA')).toEqual([]); }); + + it('uses the canonical token and pre-parsed value indexes for numeric minima', () => { + const lowAC = createUnit('Low AC'); + lowAC.as.specials = ['AC2/2/2']; + const nestedHighAC = createUnit('Nested High AC'); + nestedHighAC.as.specials = ['TUR(3/3/3,AC1/1/4)']; + const noAC = createUnit('No AC'); + noAC.as.specials = ['TAG']; + + const runtime = __test__.hydrateCorpus({ + corpusVersion: '1:0', + units: [lowAC, nestedHighAC, noAC], + indexes: { + 'as.specials': { + AC: ['Low AC', 'Nested High AC'], + TAG: ['No AC'], + TUR: ['Nested High AC'], + }, + }, + factionEraIndex: {}, + }); + // Prove execution reads the hydrated tuple index rather than reparsing + // the mutable raw unit payload on every query. + nestedHighAC.as.specials = ['TAG']; + const query = 'specials="AC*/*/>=3"'; + + expect(__test__.buildResultMessage(runtime, { + ...createRequest(), + executionQuery: query, + telemetryQuery: query, + gameSystem: GameSystem.ALPHA_STRIKE, + }).entries).toEqual([{ unitName: 'Nested High AC' }]); + }); + + it('keeps repeated specials constraints and implicit values identical in worker execution', () => { + const mediumOnly = createUnit('Medium Only'); + mediumOnly.as.specials = ['AC1/5/1']; + const longOnly = createUnit('Long Only'); + longOnly.as.specials = ['AC1/1/4']; + const both = createUnit('Both Ranges'); + both.as.specials = ['AC1/5/4']; + const implicitSnarc = createUnit('Implicit SNARC'); + implicitSnarc.as.specials = ['SNARC']; + + const runtime = __test__.hydrateCorpus({ + corpusVersion: '1:0', + units: [mediumOnly, longOnly, both, implicitSnarc], + indexes: { + 'as.specials': { + AC: ['Medium Only', 'Long Only', 'Both Ranges'], + SNARC: ['Implicit SNARC'], + }, + }, + factionEraIndex: {}, + }); + const execute = (executionQuery: string) => __test__.buildResultMessage(runtime, { + ...createRequest(), + executionQuery, + telemetryQuery: executionQuery, + gameSystem: GameSystem.ALPHA_STRIKE, + }).entries; + + expect(execute('specials&="AC*/>=4/*" specials&="AC*/*/>=3"')) + .toEqual([{ unitName: 'Both Ranges' }]); + expect(execute('specials="SNARC>=1"')) + .toEqual([{ unitName: 'Implicit SNARC' }]); + }); }); diff --git a/src/app/unit-search.worker.ts b/src/app/unit-search.worker.ts index 1db907e30..da24ba730 100644 --- a/src/app/unit-search.worker.ts +++ b/src/app/unit-search.worker.ts @@ -29,6 +29,7 @@ import type { UnitSearchWorkerResultMessage, } from './utils/unit-search-worker-protocol.util'; import { getUnitVariantGroupKey } from './utils/unit-variant.util'; +import { buildASSpecialsByUnitIndex, type ParsedASSpecials } from './utils/as-special-filter.util'; interface WorkerCorpusRuntime { corpusVersion: string; @@ -36,6 +37,7 @@ interface WorkerCorpusRuntime { allUnitNames: ReadonlySet; indexedUnitIds: Map>>; indexedFilterValues: Map; + indexedASSpecials: Map; factionEraUnitIds: Map>>; forcePackToLookupKey: Map>; } @@ -131,6 +133,11 @@ function hydrateCorpus(snapshot: UnitSearchWorkerCorpusSnapshot): WorkerCorpusRu allUnitNames: new Set(snapshot.units.map((unit) => unit.name)), indexedUnitIds: buildIndexedUnitIds(snapshot.indexes), indexedFilterValues: buildIndexedFilterValues(snapshot.indexes), + indexedASSpecials: buildASSpecialsByUnitIndex( + snapshot.units, + unit => unit.name, + unit => unit.as?.specials, + ), factionEraUnitIds: buildFactionEraUnitIds(snapshot.factionEraIndex), forcePackToLookupKey: buildForcePackIndex(snapshot.units), }; @@ -279,6 +286,7 @@ function buildResultMessage(runtime: WorkerCorpusRuntime, request: UnitSearchWor getDisplayName: (filterKey: string, value: string) => workerDisplayNameFns.get(filterKey)?.(value), getIndexedUnitIds, getIndexedFilterValues, + getIndexedASSpecials: unitId => runtime.indexedASSpecials.get(unitId), }); const parseStage: SearchTelemetryStage = { diff --git a/src/app/utils/as-special-filter.util.spec.ts b/src/app/utils/as-special-filter.util.spec.ts new file mode 100644 index 000000000..6f98d4d30 --- /dev/null +++ b/src/app/utils/as-special-filter.util.spec.ts @@ -0,0 +1,153 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { + evaluateASSpecialsFilter, + formatASSpecialMinimumQuery, + getASSpecialMinimumFieldLabels, + getASSpecialToken, + parseASSpecialAbility, + parseASSpecialMinimumQuery, + parseASSpecials, + unitMatchesASSpecialSelections, +} from './as-special-filter.util'; + +describe('Alpha Strike special filtering', () => { + const specials = [ + 'AC2/2/2', + 'TAG', + 'TSM', + 'TUR(3/3/3,IF2,LRM3/3/2)', + ]; + + it('tokenizes top-level and turret-contained abilities without exposing turret damage as an ability', () => { + const parsed = parseASSpecials(specials); + + expect(parsed.occurrences.map(occurrence => occurrence.token)).toEqual([ + 'AC', + 'TAG', + 'TSM', + 'TUR', + 'IF', + 'LRM', + ]); + expect(parsed.occurrences.find(occurrence => occurrence.token === 'TUR')?.values.map(value => value?.rank ?? null)) + .toEqual([3, 3, 3]); + expect(parsed.occurrences.find(occurrence => occurrence.token === 'LRM')?.values.map(value => value?.rank ?? null)) + .toEqual([3, 3, 2]); + expect(parsed.abilities.find(ability => ability.token === 'TUR')).toEqual(jasmine.objectContaining({ + lookupText: 'TUR', + turretDamage: '3/3/3', + children: [ + jasmine.objectContaining({ token: 'IF', rawText: 'IF2' }), + jasmine.objectContaining({ token: 'LRM', rawText: 'LRM3/3/2' }), + ], + })); + }); + + it('also accepts a comma-delimited specials string while preserving TUR parentheses', () => { + const parsed = parseASSpecials('AC2/2/2, TAG, TSM, TUR(3/3/3, IF2, LRM3/3/2)'); + + expect(parsed.occurrences.map(occurrence => occurrence.token)).toEqual([ + 'AC', + 'TAG', + 'TSM', + 'TUR', + 'IF', + 'LRM', + ]); + }); + + it('keeps digits that belong to ability names while removing numeric parameters', () => { + expect(getASSpecialToken('C3M2')).toBe('C3M'); + expect(getASSpecialToken('C3I')).toBe('C3I'); + expect(getASSpecialToken('BHJ2')).toBe('BHJ2'); + expect(getASSpecialToken('ARTCM5-1')).toBe('ARTCM5'); + expect(getASSpecialToken('TSEMP-O1')).toBe('TSEMP'); + }); + + it('uses one shared structural parser for simple, parameterized, and nested abilities', () => { + expect(parseASSpecialAbility('LAM(6"g/12a)')).toEqual(jasmine.objectContaining({ + lookupText: 'LAM', + token: 'LAM', + children: [], + })); + expect(parseASSpecialAbility('TUR(2/2/1,TUR(1/1/-,TAG))')).toEqual(jasmine.objectContaining({ + token: 'TUR', + children: [jasmine.objectContaining({ + token: 'TUR', + children: [jasmine.objectContaining({ token: 'TAG' })], + })], + })); + }); + + it('formats and parses neutral contextual slots as inclusive minimum queries', () => { + expect(formatASSpecialMinimumQuery('AC', [null, null, 3])).toBe('AC*/*/>=3'); + expect(parseASSpecialMinimumQuery('AC*/*/>=3')).toEqual({ + token: 'AC', + minimumValues: [null, null, 3], + }); + expect(parseASSpecialMinimumQuery('AC2/2/2')).toBeNull(); + }); + + it('round-trips artillery tokens whose type names contain digits', () => { + const formatted = formatASSpecialMinimumQuery('ARTCM5', [1]); + + expect(formatted).toBe('ARTCM5>=1'); + expect(getASSpecialToken(formatted)).toBe('ARTCM5'); + expect(parseASSpecialMinimumQuery(formatted)).toEqual({ + token: 'ARTCM5', + minimumValues: [1], + }); + expect(evaluateASSpecialsFilter(['ARTCM5-1'], '=', [formatted])).toBeTrue(); + }); + + it('materializes declared implicit-one values without adding fields to flag abilities', () => { + const parsed = parseASSpecials(['SNARC', 'CNARC', 'TAG']); + + expect(parsed.occurrences.find(occurrence => occurrence.token === 'SNARC')?.values) + .toEqual([{ text: '1', rank: 1 }]); + expect(parsed.occurrences.find(occurrence => occurrence.token === 'CNARC')?.values) + .toEqual([{ text: '1', rank: 1 }]); + expect(parsed.occurrences.find(occurrence => occurrence.token === 'TAG')?.values).toEqual([]); + expect(evaluateASSpecialsFilter(['SNARC'], '=', ['SNARC>=1'])).toBeTrue(); + expect(evaluateASSpecialsFilter(['SNARC'], '=', ['SNARC>=2'])).toBeFalse(); + }); + + it('applies only populated minima and matches nested turret abilities', () => { + expect(unitMatchesASSpecialSelections(specials, [{ + name: 'AC', + state: 'or', + minimumValues: [null, null, 2], + }])).toBeTrue(); + expect(unitMatchesASSpecialSelections(specials, [{ + name: 'AC', + state: 'or', + minimumValues: [null, null, 3], + }])).toBeFalse(); + expect(unitMatchesASSpecialSelections(specials, [{ + name: 'TUR', + state: 'or', + minimumValues: [null, null, 3], + }])).toBeTrue(); + expect(unitMatchesASSpecialSelections(specials, [{ + name: 'IF', + state: 'or', + minimumValues: [2], + }])).toBeTrue(); + }); + + it('retains legacy exact, comparison, wildcard, and zero-star semantics', () => { + expect(evaluateASSpecialsFilter(['FLK2/3/1'], '=', ['FLK2/>2'])).toBeTrue(); + expect(evaluateASSpecialsFilter(['FLK2/2/2'], '=', ['FLK2/2/2'])).toBeTrue(); + expect(evaluateASSpecialsFilter(['FLK0*/0*/0*'], '=', ['FLK*/*/0*'])).toBeTrue(); + expect(evaluateASSpecialsFilter(['TUR(0*/0*/0*,FLK2/1/0)'], '=', ['FLK>=2'])).toBeTrue(); + }); + + it('provides range labels only for observed numeric fields', () => { + expect(getASSpecialMinimumFieldLabels('AC', 3)).toEqual(['S', 'M', 'L']); + expect(getASSpecialMinimumFieldLabels('IF', 1)).toEqual(['']); + expect(getASSpecialMinimumFieldLabels('TSM', 0)).toEqual([]); + }); +}); diff --git a/src/app/utils/as-special-filter.util.ts b/src/app/utils/as-special-filter.util.ts new file mode 100644 index 000000000..ea868acac --- /dev/null +++ b/src/app/utils/as-special-filter.util.ts @@ -0,0 +1,943 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +/** + * Pure parsing and matching helpers for the Alpha Strike specials search field. + * + * Keep the grammar here: the index, main-thread filter kernel, semantic AST + * evaluator, and search worker all need to interpret specials identically. + */ + +export type ASSpecialSelectionState = false | 'or' | 'and' | 'not'; + +export interface ASSpecialMinimumSelection { + name: string; + state: ASSpecialSelectionState; + minimumValues?: readonly (number | null)[]; +} + +export interface ASSpecialSlotValue { + /** Original normalized value. `0*` is significant for exact matching. */ + text: string; + /** Numeric ordering value. Alpha Strike's `0*` ranks between 0 and 1. */ + rank: number; +} + +export interface ASSpecialOccurrence { + /** Canonical dropdown/index token, such as AC, IF, TAG, or TUR. */ + token: string; + /** Numeric parameters in their displayed order; `null` is a `-` slot. */ + values: readonly (ASSpecialSlotValue | null)[]; + /** Original ability text used to preserve legacy semantic matching. */ + rawText: string; + /** Whether this is an actual top-level `as.specials` entry. */ + topLevel: boolean; +} + +/** + * Structural representation shared by search indexing and ability lookup. + * Only TUR owns child abilities; parentheses on BIM/LAM-style abilities are + * parameters and remain on the node itself. + */ +export interface ASSpecialAbilityNode { + /** Original ability text, trimmed but otherwise unchanged. */ + rawText: string; + /** Text used to resolve the ability definition (the composite head for TUR). */ + lookupText: string; + /** Canonical dropdown/index token. */ + token: string; + /** Numeric parameters, including schema-defined implicit values. */ + values: readonly (ASSpecialSlotValue | null)[]; + /** TUR damage text, when present. */ + turretDamage?: string; + /** Nested TUR abilities. */ + children: readonly ASSpecialAbilityNode[]; +} + +export interface ParsedASSpecials { + topLevelValues: readonly string[]; + abilities: readonly ASSpecialAbilityNode[]; + occurrences: readonly ASSpecialOccurrence[]; +} + +type SpecialSlotOperator = '=' | '!=' | '>' | '<' | '>=' | '<='; + +type SpecialSlotMatcher = + | { type: 'any' } + | { type: 'missing' } + | { type: 'comparison'; operator: SpecialSlotOperator; value: ASSpecialSlotValue } + | { type: 'set'; values: readonly ASSpecialSlotValue[] }; + +type SpecialQueryToken = + | { type: 'literal'; text: string } + | { type: 'slot'; matcher: SpecialSlotMatcher }; + +type SpecialTargetToken = + | { type: 'literal'; text: string } + | { type: 'slot'; value: ASSpecialSlotValue | null }; + +interface ParsedSpecialQuery { + tokens: SpecialQueryToken[]; +} + +const SPECIAL_EXPLICIT_NUMERIC_QUERY_PATTERN = /(?:>=|<=|!=|>|<|=)\s*-?\d|\[[^\]]+\]/; +const DAMAGE_VALUE_PATTERN = /^(?:-|0\*|\d+(?:\.\d+)?)(?:\/(?:-|0\*|\d+(?:\.\d+)?))+$/i; +interface ASSpecialTokenSchema { + /** Digits are part of the ability name, not numeric parameters. */ + literalDigits?: boolean; + /** Values supplied by the rules when the card omits the numeric suffix. */ + implicitValues?: readonly number[]; + /** Contextual minimum input labels. */ + fieldLabels?: readonly string[]; +} + +/** + * The small set of filter-specific exceptions to the general specials grammar. + * Keep them here so parsing, indexing, matching, and UI metadata cannot drift. + */ +const AS_SPECIAL_TOKEN_SCHEMAS = new Map([ + ['AC', { fieldLabels: ['S', 'M', 'L', 'E'] }], + ['AT', { fieldLabels: ['Cap', 'Doors'] }], + ['BHJ2', { literalDigits: true }], + ['BHJ3', { literalDigits: true }], + ['C3BSM', { implicitValues: [1] }], + ['C3M', { implicitValues: [1] }], + ['CK', { fieldLabels: ['Cap', 'Doors'] }], + ['CNARC', { implicitValues: [1] }], + ['CT', { fieldLabels: ['Cap', 'Doors'] }], + ['FLK', { fieldLabels: ['S', 'M', 'L', 'E'] }], + ['HT', { fieldLabels: ['S', 'M', 'L', 'E'] }], + ['IATM', { fieldLabels: ['S', 'M', 'L', 'E'] }], + ['INARC', { implicitValues: [1] }], + ['LAM', { fieldLabels: ['Ground', 'Aero'] }], + ['LRM', { fieldLabels: ['S', 'M', 'L', 'E'] }], + ['MFB', { implicitValues: [1] }], + ['MT', { fieldLabels: ['Cap', 'Doors'] }], + ['NC3', { literalDigits: true }], + ['PT', { fieldLabels: ['Cap', 'Doors'] }], + ['REAR', { fieldLabels: ['S', 'M', 'L', 'E'] }], + ['SDS-C', { fieldLabels: ['S', 'M', 'L', 'E'] }], + ['SDS-CM', { fieldLabels: ['S', 'M', 'L', 'E'] }], + ['SDS-SC', { fieldLabels: ['S', 'M', 'L', 'E'] }], + ['SNARC', { implicitValues: [1] }], + ['SRM', { fieldLabels: ['S', 'M', 'L', 'E'] }], + ['ST', { fieldLabels: ['Cap', 'Doors'] }], + ['TOR', { fieldLabels: ['S', 'M', 'L', 'E'] }], + ['TUR', { fieldLabels: ['S', 'M', 'L', 'E'] }], + ['VTH', { fieldLabels: ['Cap', 'Doors'] }], + ['VTM', { fieldLabels: ['Cap', 'Doors'] }], + ['VTS', { fieldLabels: ['Cap', 'Doors'] }], +]); + +const parsedSpecialQueryCache = new Map(); +const parsedAbilityCache = new Map(); +const parsedTopLevelValueCache = new Map(); +const parsedSpecialCollectionCache = new Map(); + +function normalizeSpecialText(value: string): string { + return value.replace(/\s+/g, '').toUpperCase(); +} + +export function splitASSpecialArguments(content: string): string[] { + const result: string[] = []; + let current = ''; + let depth = 0; + + for (const char of content) { + if (char === '(') { + depth++; + current += char; + continue; + } + + if (char === ')') { + depth--; + current += char; + continue; + } + + if (char === ',' && depth === 0) { + if (current.trim()) { + result.push(current.trim()); + } + current = ''; + continue; + } + + current += char; + } + + if (current.trim()) { + result.push(current.trim()); + } + + return result; +} + +export function isASSpecialDamageValue(value: string): boolean { + return DAMAGE_VALUE_PATTERN.test(normalizeSpecialText(value)); +} + +/** + * Return the stable ability token from a concrete value or numeric query. + * Digits embedded in C3 names, artillery types, and the BHJ2/BHJ3 abilities + * are names rather than parameters and are deliberately retained. + */ +export function getASSpecialToken(value: string): string | null { + const text = normalizeSpecialText(value); + if (!text) { + return null; + } + + if (AS_SPECIAL_TOKEN_SCHEMAS.get(text)?.literalDigits) { + return text; + } + + // Artillery type digits belong to the token. Accept both card syntax + // (`ARTCM5-1`) and the contextual formatter syntax (`ARTCM5>=1`). + const artilleryMatch = text.match(/^(ART[A-Z0-9]+?)(?=-(?:0\*|\d)|>=|<=|!=|>|<|=|\/|\*|\[|$)/); + if (artilleryMatch) { + return artilleryMatch[1]; + } + + if (text.startsWith('C3')) { + const c3Match = text.match(/^C3[A-Z]+/); + if (c3Match) { + return c3Match[0]; + } + } + + const prefixMatch = text.match(/^[A-Z]+(?:-[A-Z]+)*/); + if (!prefixMatch) { + return null; + } + + // `-O` marks a one-shot variant; it does not describe another ability. + return prefixMatch[0].endsWith('-O') + ? prefixMatch[0].slice(0, -2) + : prefixMatch[0]; +} + +function parseSpecialSlotValue(text: string, start: number): { value: ASSpecialSlotValue; end: number } | null { + const match = text.slice(start).match(/^-?\d+(?:\.\d+)?/); + if (!match) { + return null; + } + + const numericValue = Number(match[0]); + if (!Number.isFinite(numericValue)) { + return null; + } + + const end = start + match[0].length; + if (match[0] === '0' && text[end] === '*') { + return { value: { text: '0*', rank: 0.5 }, end: end + 1 }; + } + + return { value: { text: match[0], rank: numericValue }, end }; +} + +function parseConcreteSlotValue(text: string): ASSpecialSlotValue | null { + if (text === '0*') { + return { text, rank: 0.5 }; + } + + const rank = Number(text); + return Number.isFinite(rank) ? { text, rank } : null; +} + +function extractOccurrenceValues(text: string, token: string): readonly (ASSpecialSlotValue | null)[] { + const normalized = normalizeSpecialText(text); + const parameterText = normalized.startsWith(token) ? normalized.slice(token.length) : normalized; + + if (isASSpecialDamageValue(parameterText)) { + return parameterText.split('/').map(part => ( + part === '-' ? null : parseConcreteSlotValue(part) + )); + } + + const values: ASSpecialSlotValue[] = []; + for (const match of parameterText.matchAll(/0\*|\d+(?:\.\d+)?/g)) { + const value = parseConcreteSlotValue(match[0]); + if (value) { + values.push(value); + } + } + if (values.length > 0) { + return values; + } + + return (AS_SPECIAL_TOKEN_SCHEMAS.get(token)?.implicitValues ?? []).map(value => ({ + text: String(value), + rank: value, + })); +} + +/** Parse one ability into the structural AST used everywhere else. */ +export function parseASSpecialAbility(value: string): ASSpecialAbilityNode | null { + const cached = parsedAbilityCache.get(value); + if (cached !== undefined) { + return cached; + } + + const trimmedValue = value.trim(); + if (!trimmedValue) { + parsedAbilityCache.set(value, null); + return null; + } + + const compositeMatch = trimmedValue.match(/^([^()]+?)\s*\((.*)\)$/i); + const lookupText = compositeMatch?.[1].trim() ?? trimmedValue; + const token = getASSpecialToken(lookupText) ?? normalizeSpecialText(lookupText); + + if (compositeMatch && token === 'TUR') { + const parts = splitASSpecialArguments(compositeMatch[2]); + const turretDamage = parts.find(isASSpecialDamageValue); + const node: ASSpecialAbilityNode = { + rawText: trimmedValue, + lookupText, + token: 'TUR', + values: turretDamage ? extractOccurrenceValues(turretDamage, '') : [], + ...(turretDamage ? { turretDamage: turretDamage.trim() } : {}), + children: parts + .filter(part => !isASSpecialDamageValue(part)) + .map(parseASSpecialAbility) + .filter((child): child is ASSpecialAbilityNode => child !== null), + }; + parsedAbilityCache.set(value, node); + return node; + } + + const node: ASSpecialAbilityNode = { + rawText: trimmedValue, + lookupText, + token, + values: extractOccurrenceValues(trimmedValue, token), + children: [], + }; + parsedAbilityCache.set(value, node); + return node; +} + +function flattenASSpecialAbility(node: ASSpecialAbilityNode, topLevel: boolean): ASSpecialOccurrence[] { + return [ + { + token: node.token, + values: node.values, + rawText: node.rawText, + topLevel, + }, + ...node.children.flatMap(child => flattenASSpecialAbility(child, false)), + ]; +} + +function parseTopLevelValue(value: string): ParsedASSpecials { + const cached = parsedTopLevelValueCache.get(value); + if (cached) { + return cached; + } + + const topLevelValues = splitASSpecialArguments(value); + const abilities = topLevelValues + .map(parseASSpecialAbility) + .filter((ability): ability is ASSpecialAbilityNode => ability !== null); + const parsed: ParsedASSpecials = { + topLevelValues, + abilities, + occurrences: abilities.flatMap(ability => flattenASSpecialAbility(ability, true)), + }; + parsedTopLevelValueCache.set(value, parsed); + return parsed; +} + +/** Parse top-level and TUR-contained specials once per raw value/array. */ +export function parseASSpecials(unitValue: unknown): ParsedASSpecials { + if (unitValue == null) { + return { topLevelValues: [], abilities: [], occurrences: [] }; + } + + if (Array.isArray(unitValue)) { + const values = unitValue.map(value => String(value)); + const cacheKey = values.join('\u0000'); + const cached = parsedSpecialCollectionCache.get(cacheKey); + if (cached) { + return cached; + } + + const parts = values.map(value => parseTopLevelValue(value)); + const parsed: ParsedASSpecials = { + topLevelValues: parts.flatMap(part => part.topLevelValues), + abilities: parts.flatMap(part => part.abilities), + occurrences: parts.flatMap(part => part.occurrences), + }; + parsedSpecialCollectionCache.set(cacheKey, parsed); + return parsed; + } + + return parseTopLevelValue(String(unitValue)); +} + +/** Build the per-unit parsed tuple index used by both sync and worker search. */ +export function buildASSpecialsByUnitIndex( + units: readonly T[], + getUnitId: (unit: T) => string, + getSpecials: (unit: T) => unknown, +): Map { + const index = new Map(); + for (const unit of units) { + index.set(getUnitId(unit), parseASSpecials(getSpecials(unit))); + } + return index; +} + +export function getASSpecialMinimumFieldLabels(token: string, count: number): readonly string[] { + if (count <= 0) { + return []; + } + + const schemaLabels = AS_SPECIAL_TOKEN_SCHEMAS.get(token)?.fieldLabels; + if (schemaLabels) { + return schemaLabels.slice(0, count); + } + + return count === 1 + ? [''] + : Array.from({ length: count }, (_, index) => `#${index + 1}`); +} + +export function isASSpecialNumericQuery(value: string): boolean { + const normalized = normalizeSpecialText(value); + if (SPECIAL_EXPLICIT_NUMERIC_QUERY_PATTERN.test(normalized) || normalized.includes('0*')) { + return true; + } + + if (normalized.includes('*')) { + return false; + } + + return /-?\d/.test(normalized); +} + +function flushSpecialLiteral(tokens: T[], literal: string): void { + if (literal) { + tokens.push({ type: 'literal', text: literal } as T); + } +} + +function readSpecialSlotOperator(text: string, start: number): { operator: SpecialSlotOperator; end: number } | null { + const twoCharOperator = text.slice(start, start + 2); + if (twoCharOperator === '>=' || twoCharOperator === '<=' || twoCharOperator === '!=') { + return { operator: twoCharOperator, end: start + 2 }; + } + + const oneCharOperator = text[start]; + if (oneCharOperator === '>' || oneCharOperator === '<' || oneCharOperator === '=') { + return { operator: oneCharOperator, end: start + 1 }; + } + + return null; +} + +function parseSpecialNumberSet(text: string, start: number): { values: ASSpecialSlotValue[]; end: number } | null { + if (text[start] !== '[') { + return null; + } + + const end = text.indexOf(']', start + 1); + if (end === -1) { + return null; + } + + const values: ASSpecialSlotValue[] = []; + for (const part of text.slice(start + 1, end).split(',')) { + const trimmedPart = part.trim(); + const slotValue = parseSpecialSlotValue(trimmedPart, 0); + if (!trimmedPart || !slotValue || slotValue.end !== trimmedPart.length) { + return null; + } + values.push(slotValue.value); + } + + return values.length > 0 ? { values, end: end + 1 } : null; +} + +function isMissingSpecialSlot(text: string, index: number): boolean { + if (text[index] !== '-') { + return false; + } + + const previous = index === 0 ? '' : text[index - 1]; + const next = index + 1 >= text.length ? '' : text[index + 1]; + const hasSlotBoundaryBefore = index === 0 || previous === '/' || previous === '(' || previous === ','; + const hasSlotBoundaryAfter = index + 1 >= text.length || next === '/' || next === ')' || next === ','; + return hasSlotBoundaryBefore && hasSlotBoundaryAfter; +} + +function parseSpecialQuery(value: string): ParsedSpecialQuery | null { + if (!isASSpecialNumericQuery(value)) { + return null; + } + + const cached = parsedSpecialQueryCache.get(value); + if (cached !== undefined) { + return cached; + } + + const text = normalizeSpecialText(value); + const tokens: SpecialQueryToken[] = []; + let literal = ''; + let index = 0; + + while (index < text.length) { + const set = parseSpecialNumberSet(text, index); + if (set) { + flushSpecialLiteral(tokens, literal); + literal = ''; + tokens.push({ type: 'slot', matcher: { type: 'set', values: set.values } }); + index = set.end; + continue; + } + + const operator = readSpecialSlotOperator(text, index); + if (operator) { + const slotValue = parseSpecialSlotValue(text, operator.end); + if (!slotValue) { + parsedSpecialQueryCache.set(value, null); + return null; + } + + flushSpecialLiteral(tokens, literal); + literal = ''; + tokens.push({ + type: 'slot', + matcher: { type: 'comparison', operator: operator.operator, value: slotValue.value }, + }); + index = slotValue.end; + continue; + } + + if (text[index] === '*') { + flushSpecialLiteral(tokens, literal); + literal = ''; + tokens.push({ type: 'slot', matcher: { type: 'any' } }); + index++; + continue; + } + + if (isMissingSpecialSlot(text, index)) { + flushSpecialLiteral(tokens, literal); + literal = ''; + tokens.push({ type: 'slot', matcher: { type: 'missing' } }); + index++; + continue; + } + + const slotValue = parseSpecialSlotValue(text, index); + if (slotValue) { + flushSpecialLiteral(tokens, literal); + literal = ''; + tokens.push({ + type: 'slot', + matcher: { type: 'comparison', operator: '=', value: slotValue.value }, + }); + index = slotValue.end; + continue; + } + + literal += text[index]; + index++; + } + + flushSpecialLiteral(tokens, literal); + const parsed = tokens.some(token => token.type === 'slot') ? { tokens } : null; + parsedSpecialQueryCache.set(value, parsed); + return parsed; +} + +function parseSpecialTarget(value: string): SpecialTargetToken[] { + const text = normalizeSpecialText(value); + const tokens: SpecialTargetToken[] = []; + let literal = ''; + let index = 0; + + while (index < text.length) { + if (isMissingSpecialSlot(text, index)) { + flushSpecialLiteral(tokens, literal); + literal = ''; + tokens.push({ type: 'slot', value: null }); + index++; + continue; + } + + const slotValue = parseSpecialSlotValue(text, index); + if (slotValue) { + flushSpecialLiteral(tokens, literal); + literal = ''; + tokens.push({ type: 'slot', value: slotValue.value }); + index = slotValue.end; + continue; + } + + literal += text[index]; + index++; + } + + flushSpecialLiteral(tokens, literal); + return tokens; +} + +function specialSlotValuesEqual(left: ASSpecialSlotValue, right: ASSpecialSlotValue): boolean { + if (left.text === '0*' || right.text === '0*') { + return left.text === right.text; + } + return left.rank === right.rank; +} + +function compareSpecialSlotValues(left: ASSpecialSlotValue, right: ASSpecialSlotValue, operator: SpecialSlotOperator): boolean { + switch (operator) { + case '=': return specialSlotValuesEqual(left, right); + case '!=': return !specialSlotValuesEqual(left, right); + case '>': return left.rank > right.rank; + case '<': return left.rank < right.rank; + case '>=': return left.rank >= right.rank; + case '<=': return left.rank <= right.rank; + } +} + +function specialSlotMatches(slotValue: ASSpecialSlotValue | null, matcher: SpecialSlotMatcher): boolean { + if (matcher.type === 'any') { + return true; + } + if (matcher.type === 'missing') { + return slotValue === null; + } + if (slotValue === null) { + return false; + } + if (matcher.type === 'set') { + return matcher.values.some(value => specialSlotValuesEqual(value, slotValue)); + } + return compareSpecialSlotValues(slotValue, matcher.value, matcher.operator); +} + +function hasOnlyTrailingSpecialSlots(tokens: SpecialTargetToken[], start: number): boolean { + let index = start; + while (index < tokens.length) { + const separator = tokens[index]; + if (separator?.type !== 'literal' || separator.text !== '/') { + return false; + } + index++; + if (tokens[index]?.type !== 'slot') { + return false; + } + index++; + } + return true; +} + +function legacyNumericQueryMatches(value: string, query: ParsedSpecialQuery): boolean { + const targetTokens = parseSpecialTarget(value); + let targetIndex = 0; + + for (const queryToken of query.tokens) { + const targetToken = targetTokens[targetIndex]; + if (!targetToken) { + return false; + } + + if (queryToken.type === 'literal') { + if (targetToken.type !== 'literal' || targetToken.text !== queryToken.text) { + return false; + } + } else if (targetToken.type !== 'slot' || !specialSlotMatches(targetToken.value, queryToken.matcher)) { + return false; + } + targetIndex++; + } + + return targetIndex === targetTokens.length || hasOnlyTrailingSpecialSlots(targetTokens, targetIndex); +} + +function parseAbstractSlotMatcher(part: string): SpecialSlotMatcher | null { + if (part === '*') { + return { type: 'any' }; + } + if (part === '-') { + return { type: 'missing' }; + } + + const set = parseSpecialNumberSet(part, 0); + if (set?.end === part.length) { + return { type: 'set', values: set.values }; + } + + const operator = readSpecialSlotOperator(part, 0); + const slotValue = parseSpecialSlotValue(part, operator?.end ?? 0); + if (!slotValue || slotValue.end !== part.length) { + return null; + } + + return { + type: 'comparison', + operator: operator?.operator ?? '=', + value: slotValue.value, + }; +} + +function parseAbstractSlotMatchers(value: string, token: string): SpecialSlotMatcher[] | null { + const text = normalizeSpecialText(value); + if (!text.startsWith(token)) { + return null; + } + + const suffix = text.slice(token.length); + if (!suffix || suffix.startsWith('(')) { + return suffix ? null : []; + } + + const matchers: SpecialSlotMatcher[] = []; + for (const part of suffix.split('/')) { + const matcher = parseAbstractSlotMatcher(part); + if (!matcher) { + return null; + } + matchers.push(matcher); + } + return matchers; +} + +function occurrenceMatchesQuery(occurrence: ASSpecialOccurrence, queryValue: string): boolean { + const normalizedQuery = normalizeSpecialText(queryValue); + if (normalizedQuery === occurrence.token) { + return true; + } + + const abstractMatchers = parseAbstractSlotMatchers(queryValue, occurrence.token); + if (abstractMatchers && abstractMatchers.length > 0 && abstractMatchers.length <= occurrence.values.length) { + if (abstractMatchers.every((matcher, index) => specialSlotMatches(occurrence.values[index] ?? null, matcher))) { + return true; + } + } + + const numericQuery = parseSpecialQuery(queryValue); + if (numericQuery) { + return legacyNumericQueryMatches(occurrence.rawText, numericQuery); + } + + if (queryValue.includes('*')) { + const escaped = queryValue.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + return new RegExp(`^${escaped}$`, 'i').test(occurrence.rawText); + } + + return normalizeSpecialText(occurrence.rawText) === normalizedQuery; +} + +export type ASSpecialSemanticOperator = '=' | '==' | '!=' | '&=' | '>' | '<' | '>=' | '<='; + +/** Shared evaluator used by both direct AST execution and UI-state filtering. */ +export function evaluateASSpecialsFilter( + unitValue: unknown, + operator: ASSpecialSemanticOperator, + values: readonly string[], + parsedSpecials?: ParsedASSpecials, +): boolean { + const parsed = parsedSpecials ?? parseASSpecials(unitValue); + + if (parsed.occurrences.length === 0) { + return operator === '!='; + } + + if (operator === '&=') { + return values.every(value => parsed.occurrences.some(occurrence => occurrenceMatchesQuery(occurrence, value))); + } + + if (operator === '==') { + const topLevelOccurrences = parsed.occurrences.filter(occurrence => occurrence.topLevel); + return topLevelOccurrences.length > 0 && topLevelOccurrences.every(occurrence => ( + values.some(value => occurrenceMatchesQuery(occurrence, value)) + )); + } + + for (const value of values) { + const matches = parsed.occurrences.some(occurrence => occurrenceMatchesQuery(occurrence, value)); + if (operator === '!=') { + if (matches) { + return false; + } + } else if (matches) { + return true; + } + } + + return operator === '!='; +} + +function formatMinimumValue(value: number): string { + return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(6))); +} + +/** Convert contextual UI minima into the canonical semantic slot query. */ +export function formatASSpecialMinimumQuery( + token: string, + minimumValues: readonly (number | null)[] | undefined, +): string { + if (!minimumValues || minimumValues.length === 0) { + return token; + } + + let lastValueIndex = -1; + for (let index = 0; index < minimumValues.length; index++) { + if (minimumValues[index] !== null && minimumValues[index] !== undefined) { + lastValueIndex = index; + } + } + if (lastValueIndex === -1) { + return token; + } + + const slots = minimumValues.slice(0, lastValueIndex + 1).map(value => ( + value === null || value === undefined ? '*' : `>=${formatMinimumValue(value)}` + )); + return token + slots.join('/'); +} + +/** + * Parse the simple `>=`/wildcard form emitted by the contextual UI. Other + * numeric semantic expressions remain semantic-only and retain exact behavior. + */ +export function parseASSpecialMinimumQuery(value: string): { token: string; minimumValues: (number | null)[] } | null { + const token = getASSpecialToken(value); + if (!token) { + return null; + } + + const normalized = normalizeSpecialText(value); + if (normalized === token) { + return { token, minimumValues: [] }; + } + + const matchers = parseAbstractSlotMatchers(value, token); + if (!matchers || matchers.length === 0) { + return null; + } + + const minimumValues: (number | null)[] = []; + for (const matcher of matchers) { + if (matcher.type === 'any') { + minimumValues.push(null); + } else if (matcher.type === 'comparison' && matcher.operator === '>=') { + minimumValues.push(matcher.value.rank); + } else { + return null; + } + } + + return minimumValues.some(value => value !== null) + ? { token, minimumValues } + : null; +} + +/** + * Build a safe token-posting prefilter for positive specials selections. + * Numeric constraints are deliberately checked later against the tuple index; + * this only removes units that cannot contain the requested ability token. + */ +export function buildIndexedASSpecialSelectionCandidates( + selections: readonly Pick[], + getIndexedUnitIds: (token: string) => ReadonlySet | undefined, +): Set | null { + const resolve = (name: string): Set | null => { + if (name.includes('*') && !isASSpecialNumericQuery(name)) { + return null; + } + + const token = getASSpecialToken(name); + if (!token) { + return null; + } + + const indexedUnitIds = getIndexedUnitIds(token); + return indexedUnitIds === undefined ? null : new Set(indexedUnitIds); + }; + + let andCandidates: Set | null = null; + for (const selection of selections) { + if (selection.state !== 'and') { + continue; + } + + const candidates = resolve(selection.name); + if (candidates === null) { + // Other resolved AND clauses are still a safe prefilter. + continue; + } + + if (andCandidates === null) { + andCandidates = candidates; + continue; + } + + for (const unitId of andCandidates) { + if (!candidates.has(unitId)) { + andCandidates.delete(unitId); + } + } + } + + const orSelections = selections.filter(selection => selection.state === 'or'); + if (orSelections.length === 0) { + return andCandidates; + } + + const orCandidates = new Set(); + for (const selection of orSelections) { + const candidates = resolve(selection.name); + if (candidates === null) { + // An unresolved OR branch may match outside all resolved postings. + return andCandidates; + } + for (const unitId of candidates) { + orCandidates.add(unitId); + } + } + + if (andCandidates === null) { + return orCandidates; + } + + for (const unitId of andCandidates) { + if (!orCandidates.has(unitId)) { + andCandidates.delete(unitId); + } + } + return andCandidates; +} + +export function unitMatchesASSpecialSelections( + unitValue: unknown, + selections: readonly ASSpecialMinimumSelection[], + parsedSpecials?: ParsedASSpecials, +): boolean { + const activeSelections = selections.filter(selection => selection.state !== false); + const orSelections = activeSelections.filter(selection => selection.state === 'or'); + const andSelections = activeSelections.filter(selection => selection.state === 'and'); + const notSelections = activeSelections.filter(selection => selection.state === 'not'); + const queryFor = (selection: ASSpecialMinimumSelection) => ( + formatASSpecialMinimumQuery(selection.name, selection.minimumValues) + ); + + if (notSelections.some(selection => evaluateASSpecialsFilter(unitValue, '=', [queryFor(selection)], parsedSpecials))) { + return false; + } + if (andSelections.some(selection => !evaluateASSpecialsFilter(unitValue, '=', [queryFor(selection)], parsedSpecials))) { + return false; + } + if (orSelections.length > 0 && !orSelections.some(selection => ( + evaluateASSpecialsFilter(unitValue, '=', [queryFor(selection)], parsedSpecials) + ))) { + return false; + } + return true; +} diff --git a/src/app/utils/semantic-filter-ast.util.ts b/src/app/utils/semantic-filter-ast.util.ts index 996f7b706..d449512ad 100644 --- a/src/app/utils/semantic-filter-ast.util.ts +++ b/src/app/utils/semantic-filter-ast.util.ts @@ -35,6 +35,11 @@ import { normalizeLooseText, wildcardToRegex } from './string.util'; import { usesIndexedDropdownUniverse } from './unit-search-filter-config.util'; import { checkQuantityConstraint as checkQuantityConstraintCore, isEmbeddedApostrophe, unitMatchesRulesRefsSelection } from './unit-search-shared.util'; import { isASDamageSemanticKey, parseASDamageValue } from './as-damage.util'; +import { + buildIndexedASSpecialSelectionCandidates, + evaluateASSpecialsFilter, + type ParsedASSpecials, +} from './as-special-filter.util'; // ============================================================================ // Helpers @@ -1129,44 +1134,19 @@ export interface EvaluatorContext { getIndexedUnitIds?: (filterKey: string, value: string, scope?: AvailabilityFilterScope) => ReadonlySet | undefined; /** Get all stored values available in an index for a filter key. */ getIndexedFilterValues?: (filterKey: string) => readonly string[]; + /** Get pre-parsed Alpha Strike special tuples for a unit. */ + getIndexedASSpecials?: (unitId: string) => ParsedASSpecials | undefined; } type ParsedRangeValue = | { type: 'range'; min: number; max: number } | { type: 'single'; num: number }; -type SpecialSlotOperator = '=' | '!=' | '>' | '<' | '>=' | '<='; - -interface SpecialSlotValue { - text: string; - rank: number; -} - -type SpecialQueryToken = - | { type: 'literal'; text: string } - | { type: 'slot'; matcher: SpecialSlotMatcher }; - -type SpecialTargetToken = - | { type: 'literal'; text: string } - | { type: 'slot'; value: SpecialSlotValue | null }; - -type SpecialSlotMatcher = - | { type: 'any' } - | { type: 'missing' } - | { type: 'comparison'; operator: SpecialSlotOperator; value: SpecialSlotValue } - | { type: 'set'; values: readonly SpecialSlotValue[] }; - -interface ParsedSpecialQuery { - tokens: SpecialQueryToken[]; -} - const RANGE_VALUE_PATTERN = /^(-?\d+(?:\.\d+)?)[-~](-?\d+(?:\.\d+)?)$/; const AS_DAMAGE_RANGE_VALUE_PATTERN = /^(0\*|-?\d+(?:\.\d+)?)[-~](0\*|-?\d+(?:\.\d+)?)$/i; -const SPECIAL_EXPLICIT_NUMERIC_QUERY_PATTERN = /(?:>=|<=|!=|>|<|=)\s*-?\d|\[[^\]]+\]/; const FILTER_CONFIGS_BY_SEMANTIC_KEY = new Map(); const sortedFilterConfigsCache = new WeakMap>(); const parsedRangeValuesCache = new WeakMap(); -const parsedSpecialQueryCache = new Map(); for (const filterConfig of ADVANCED_FILTERS) { const semanticKey = (filterConfig.semanticKey || filterConfig.key).toLowerCase(); @@ -1799,6 +1779,27 @@ function matchIndexedStoredValues( return matchedValues; } +function buildIndexedASSpecialCandidateSet( + operator: SemanticOperator, + values: string[], + context: EvaluatorContext, + activeScope?: AvailabilityFilterScope, +): Set | null { + if (operator === '!=' || (operator !== '=' && operator !== '==' && operator !== '&=')) { + return null; + } + + if ((context.getIndexedFilterValues?.('as.specials') ?? []).length === 0) { + return null; + } + + const state = operator === '&=' ? 'and' : 'or'; + return buildIndexedASSpecialSelectionCandidates( + values.map(name => ({ name, state })), + token => context.getIndexedUnitIds?.('as.specials', token, activeScope), + ); +} + function buildIndexedCandidateSetForConfig( conf: AdvFilterConfig, operator: SemanticOperator, @@ -1810,7 +1811,7 @@ function buildIndexedCandidateSetForConfig( return null; } if (conf.key === 'as.specials') { - return null; + return buildIndexedASSpecialCandidateSet(operator, values, context, activeScope); } if (conf.type === AdvFilterType.BOOLEAN) { return buildIndexedBooleanCandidateSet(conf, operator, values, context, activeScope); @@ -2302,440 +2303,6 @@ function checkQuantityConstraint( ); } -function splitASSpecialArguments(content: string): string[] { - const result: string[] = []; - let current = ''; - let depth = 0; - - for (const char of content) { - if (char === '(') { - depth++; - current += char; - continue; - } - - if (char === ')') { - depth--; - current += char; - continue; - } - - if (char === ',' && depth === 0) { - if (current.trim()) { - result.push(current.trim()); - } - current = ''; - continue; - } - - current += char; - } - - if (current.trim()) { - result.push(current.trim()); - } - - return result; -} - -function isTurretDamagePattern(content: string): boolean { - return /^(?:-|\d+(?:\.\d+)?\*?)(?:\/(?:-|\d+(?:\.\d+)?\*?))+$/.test(content.replace(/\s+/g, '')); -} - -function addASSpecialSearchValues(value: string, target: string[]): void { - const trimmedValue = value.trim(); - if (!trimmedValue) { - return; - } - - target.push(trimmedValue); - - const compositeMatch = trimmedValue.match(/^TUR\s*\((.*)\)$/i); - if (!compositeMatch) { - return; - } - - for (const part of splitASSpecialArguments(compositeMatch[1])) { - const trimmedPart = part.trim(); - if (!trimmedPart || isTurretDamagePattern(trimmedPart)) { - continue; - } - target.push(trimmedPart); - } -} - -function getASSpecialSearchValues(unitValue: any): string[] { - if (unitValue == null) { - return []; - } - - const rawValues = Array.isArray(unitValue) ? unitValue : [unitValue]; - const searchValues: string[] = []; - for (const rawValue of rawValues) { - addASSpecialSearchValues(String(rawValue), searchValues); - } - return searchValues; -} - -function normalizeSpecialNumericText(value: string): string { - return value.replace(/\s+/g, '').toUpperCase(); -} - -function shouldParseSpecialNumericQuery(value: string): boolean { - const text = normalizeSpecialNumericText(value); - if (SPECIAL_EXPLICIT_NUMERIC_QUERY_PATTERN.test(text) || text.includes('0*')) { - return true; - } - - if (text.includes('*')) { - return false; - } - - return /-?\d/.test(text); -} - -function flushSpecialLiteral( - tokens: T[], - literal: string, -): void { - if (literal) { - tokens.push({ type: 'literal', text: literal } as T); - } -} - -function parseSpecialSlotValue(text: string, start: number): { value: SpecialSlotValue; end: number } | null { - const match = text.slice(start).match(/^-?\d+(?:\.\d+)?/); - if (!match) { - return null; - } - - const numericValue = Number(match[0]); - if (!Number.isFinite(numericValue)) { - return null; - } - - const end = start + match[0].length; - if (match[0] === '0' && text[end] === '*') { - return { value: { text: '0*', rank: 0.5 }, end: end + 1 }; - } - - return { value: { text: match[0], rank: numericValue }, end }; -} - -function readSpecialSlotOperator(text: string, start: number): { operator: SpecialSlotOperator; end: number } | null { - const twoCharOperator = text.slice(start, start + 2); - if (twoCharOperator === '>=' || twoCharOperator === '<=' || twoCharOperator === '!=') { - return { operator: twoCharOperator, end: start + 2 }; - } - - const oneCharOperator = text[start]; - if (oneCharOperator === '>' || oneCharOperator === '<' || oneCharOperator === '=') { - return { operator: oneCharOperator, end: start + 1 }; - } - - return null; -} - -function parseSpecialNumberSet(text: string, start: number): { values: SpecialSlotValue[]; end: number } | null { - if (text[start] !== '[') { - return null; - } - - const end = text.indexOf(']', start + 1); - if (end === -1) { - return null; - } - - const values: SpecialSlotValue[] = []; - for (const part of text.slice(start + 1, end).split(',')) { - const trimmedPart = part.trim(); - if (!trimmedPart) { - return null; - } - const slotValue = parseSpecialSlotValue(trimmedPart, 0); - if (!slotValue || slotValue.end !== trimmedPart.length) { - return null; - } - values.push(slotValue.value); - } - - return values.length > 0 ? { values, end: end + 1 } : null; -} - -function isMissingSpecialSlot(text: string, index: number): boolean { - if (text[index] !== '-') { - return false; - } - - const previous = index === 0 ? '' : text[index - 1]; - const next = index + 1 >= text.length ? '' : text[index + 1]; - const hasSlotBoundaryBefore = index === 0 || previous === '/' || previous === '(' || previous === ','; - const hasSlotBoundaryAfter = index + 1 >= text.length || next === '/' || next === ')' || next === ','; - return hasSlotBoundaryBefore && hasSlotBoundaryAfter; -} - -function parseSpecialQuery(value: string): ParsedSpecialQuery | null { - if (!shouldParseSpecialNumericQuery(value)) { - return null; - } - - const cached = parsedSpecialQueryCache.get(value); - if (cached !== undefined) { - return cached; - } - - const text = normalizeSpecialNumericText(value); - const tokens: SpecialQueryToken[] = []; - let literal = ''; - let index = 0; - - while (index < text.length) { - const set = parseSpecialNumberSet(text, index); - if (set) { - flushSpecialLiteral(tokens, literal); - literal = ''; - tokens.push({ type: 'slot', matcher: { type: 'set', values: set.values } }); - index = set.end; - continue; - } - - const operator = readSpecialSlotOperator(text, index); - if (operator) { - const slotValue = parseSpecialSlotValue(text, operator.end); - if (!slotValue) { - parsedSpecialQueryCache.set(value, null); - return null; - } - - flushSpecialLiteral(tokens, literal); - literal = ''; - tokens.push({ - type: 'slot', - matcher: { - type: 'comparison', - operator: operator.operator, - value: slotValue.value, - }, - }); - index = slotValue.end; - continue; - } - - if (text[index] === '*') { - flushSpecialLiteral(tokens, literal); - literal = ''; - tokens.push({ type: 'slot', matcher: { type: 'any' } }); - index++; - continue; - } - - if (isMissingSpecialSlot(text, index)) { - flushSpecialLiteral(tokens, literal); - literal = ''; - tokens.push({ type: 'slot', matcher: { type: 'missing' } }); - index++; - continue; - } - - const slotValue = parseSpecialSlotValue(text, index); - if (slotValue) { - flushSpecialLiteral(tokens, literal); - literal = ''; - tokens.push({ - type: 'slot', - matcher: { - type: 'comparison', - operator: '=', - value: slotValue.value, - }, - }); - index = slotValue.end; - continue; - } - - literal += text[index]; - index++; - } - - flushSpecialLiteral(tokens, literal); - - const hasSlotMatcher = tokens.some(token => token.type === 'slot'); - const parsed = hasSlotMatcher ? { tokens } : null; - parsedSpecialQueryCache.set(value, parsed); - return parsed; -} - -function parseSpecialTarget(value: string): SpecialTargetToken[] { - const text = normalizeSpecialNumericText(value); - const tokens: SpecialTargetToken[] = []; - let literal = ''; - let index = 0; - - while (index < text.length) { - if (isMissingSpecialSlot(text, index)) { - flushSpecialLiteral(tokens, literal); - literal = ''; - tokens.push({ type: 'slot', value: null }); - index++; - continue; - } - - const slotValue = parseSpecialSlotValue(text, index); - if (slotValue) { - flushSpecialLiteral(tokens, literal); - literal = ''; - tokens.push({ type: 'slot', value: slotValue.value }); - index = slotValue.end; - continue; - } - - literal += text[index]; - index++; - } - - flushSpecialLiteral(tokens, literal); - return tokens; -} - -function specialSlotValuesEqual(left: SpecialSlotValue, right: SpecialSlotValue): boolean { - if (left.text === '0*' || right.text === '0*') { - return left.text === right.text; - } - - return left.rank === right.rank; -} - -function compareSpecialSlotValues(left: SpecialSlotValue, right: SpecialSlotValue, operator: SpecialSlotOperator): boolean { - switch (operator) { - case '=': - return specialSlotValuesEqual(left, right); - case '!=': - return !specialSlotValuesEqual(left, right); - case '>': - return left.rank > right.rank; - case '<': - return left.rank < right.rank; - case '>=': - return left.rank >= right.rank; - case '<=': - return left.rank <= right.rank; - } -} - -function specialSlotMatches(slotValue: SpecialSlotValue | null, matcher: SpecialSlotMatcher): boolean { - if (matcher.type === 'any') { - return true; - } - - if (matcher.type === 'missing') { - return slotValue === null; - } - - if (slotValue === null) { - return false; - } - - if (matcher.type === 'set') { - return matcher.values.some(value => specialSlotValuesEqual(value, slotValue)); - } - - return compareSpecialSlotValues(slotValue, matcher.value, matcher.operator); -} - -function hasOnlyTrailingSpecialSlots(tokens: SpecialTargetToken[], start: number): boolean { - let index = start; - while (index < tokens.length) { - const separator = tokens[index]; - if (separator?.type !== 'literal' || separator.text !== '/') { - return false; - } - index++; - - if (tokens[index]?.type !== 'slot') { - return false; - } - index++; - } - - return true; -} - -function specialNumericQueryMatches(value: string, query: ParsedSpecialQuery): boolean { - const targetTokens = parseSpecialTarget(value); - let targetIndex = 0; - - for (const queryToken of query.tokens) { - const targetToken = targetTokens[targetIndex]; - if (!targetToken) { - return false; - } - - if (queryToken.type === 'literal') { - if (targetToken.type !== 'literal' || targetToken.text !== queryToken.text) { - return false; - } - targetIndex++; - continue; - } - - if (targetToken.type !== 'slot' || !specialSlotMatches(targetToken.value, queryToken.matcher)) { - return false; - } - targetIndex++; - } - - return targetIndex === targetTokens.length || hasOnlyTrailingSpecialSlots(targetTokens, targetIndex); -} - -function asSpecialMatchesQuery(value: string, queryValue: string): boolean { - const numericQuery = parseSpecialQuery(queryValue); - if (numericQuery) { - return specialNumericQueryMatches(value, numericQuery); - } - - if (queryValue.includes('*')) { - return wildcardToRegex(queryValue).test(value); - } - - return value.toLowerCase() === queryValue.toLowerCase(); -} - -function evaluateASSpecialsFilter( - unitValue: any, - operator: SemanticOperator, - values: string[], -): boolean { - const topLevelValues = unitValue == null ? [] : (Array.isArray(unitValue) ? unitValue : [unitValue]).map(value => String(value)); - const searchValues = getASSpecialSearchValues(unitValue); - - if (searchValues.length === 0) { - return operator === '!='; - } - - if (operator === '&=') { - return values.every(value => searchValues.some(special => asSpecialMatchesQuery(special, value))); - } - - if (operator === '==') { - return topLevelValues.length > 0 && topLevelValues.every(special => ( - values.some(value => asSpecialMatchesQuery(special, value)) - )); - } - - for (const value of values) { - const matches = searchValues.some(special => asSpecialMatchesQuery(special, value)); - if (operator === '!=') { - if (matches) { - return false; - } - } else if (matches) { - return true; - } - } - - return operator === '!='; -} - /** * Evaluate a dropdown filter (string matching with quantity support). */ @@ -2748,7 +2315,12 @@ function evaluateDropdownFilter( context: EvaluatorContext ): boolean { if (conf.key === 'as.specials') { - return evaluateASSpecialsFilter(unitValue, operator, values); + return evaluateASSpecialsFilter( + unitValue, + operator, + values, + context.getIndexedASSpecials?.(context.getUnitId(unit)), + ); } if (conf.key === 'rulesRefs' && (operator === '=' || operator === '==')) { diff --git a/src/app/utils/semantic-filter.util.ts b/src/app/utils/semantic-filter.util.ts index bd4d96827..96c6db49e 100644 --- a/src/app/utils/semantic-filter.util.ts +++ b/src/app/utils/semantic-filter.util.ts @@ -8,23 +8,14 @@ import type { CountOperator, MultiStateSelection } from '../components/multi-sel import { getAdvancedFilterConfigByKey } from './unit-search-filter-config.util'; import { isEmbeddedApostrophe, normalizeMultiStateSelection } from './unit-search-shared.util'; import { formatASDamageValue, isASDamageFilterKey, parseASDamageValue } from './as-damage.util'; +import { + formatASSpecialMinimumQuery, + isASSpecialNumericQuery, + parseASSpecialMinimumQuery, +} from './as-special-filter.util'; // Cache for semantic key maps const semanticKeyMapCache = new Map>(); -const AS_SPECIALS_EXPLICIT_NUMERIC_SEMANTIC_PATTERN = /(?:>=|<=|!=|>|<|=)\s*-?\d|\[[^\]]+\]/; - -function isASSpecialsNumericSemanticValue(value: string): boolean { - const normalized = value.replace(/\s+/g, '').toUpperCase(); - if (AS_SPECIALS_EXPLICIT_NUMERIC_SEMANTIC_PATTERN.test(normalized) || normalized.includes('0*')) { - return true; - } - - if (normalized.includes('*')) { - return false; - } - - return /-?\d/.test(normalized); -} /* * @@ -721,7 +712,10 @@ export function tokensToFilterState( } for (const val of token.values) { - const isASSpecialsNumericSemantic = conf.key === 'as.specials' && isASSpecialsNumericSemanticValue(val); + const isASSpecialsNumericSemantic = conf.key === 'as.specials' && isASSpecialNumericQuery(val); + const asSpecialMinimum = conf.key === 'as.specials' + ? parseASSpecialMinimumQuery(val) + : null; // Check if this is a wildcard pattern if (val.includes('*') && !isASSpecialsNumericSemantic) { wildcardPatterns.push({ pattern: val, state }); @@ -752,19 +746,40 @@ export function tokensToFilterState( // If no constraint, it means "has at least one" which is the default } else { // Regular value (non-countable) - if (isASSpecialsNumericSemantic) { + if (isASSpecialsNumericSemantic && !asSpecialMinimum) { semanticOnly = true; } - const normalizedVal = normalizeValue(val); + const normalizedVal = normalizeValue(asSpecialMinimum?.token ?? val); // If already exists, update state with priority: not > and > or if (selection[normalizedVal]) { + if (conf.key === 'as.specials' && asSpecialMinimum) { + const existingMinimumValues = selection[normalizedVal].minimumValues ?? []; + const nextMinimumValues = asSpecialMinimum.minimumValues; + const sameMinimumValues = existingMinimumValues.length === nextMinimumValues.length + && existingMinimumValues.every((value, index) => value === nextMinimumValues[index]); + if (selection[normalizedVal].state !== state || !sameMinimumValues) { + // Flat controls cannot represent multiple independent clauses + // for one ability without changing their boolean semantics. + semanticOnly = true; + } + } if (state === 'not') { selection[normalizedVal].state = 'not'; } else if (state === 'and' && selection[normalizedVal].state === 'or') { selection[normalizedVal].state = 'and'; } + if (asSpecialMinimum) { + selection[normalizedVal].minimumValues = asSpecialMinimum.minimumValues; + } } else { - selection[normalizedVal] = { name: normalizedVal, state, count: 1 }; + selection[normalizedVal] = { + name: normalizedVal, + state, + count: 1, + ...(asSpecialMinimum && asSpecialMinimum.minimumValues.length > 0 + ? { minimumValues: asSpecialMinimum.minimumValues } + : {}), + }; } } } @@ -1038,7 +1053,9 @@ export function filterStateToSemanticText( for (const [name, sel] of Object.entries(selection)) { // Format value with quantity constraint if present let formattedValue = name; - if (conf.countable && (sel.count > 1 || sel.countOperator || sel.countMax !== undefined)) { + if (conf.key === 'as.specials') { + formattedValue = formatASSpecialMinimumQuery(name, sel.minimumValues); + } else if (conf.countable && (sel.count > 1 || sel.countOperator || sel.countMax !== undefined)) { formattedValue = formatValueWithQuantity(name, sel.countOperator, sel.count, sel.countMax); } diff --git a/src/app/utils/unit-filter-kernel.util.ts b/src/app/utils/unit-filter-kernel.util.ts index 892f72f17..958560bed 100644 --- a/src/app/utils/unit-filter-kernel.util.ts +++ b/src/app/utils/unit-filter-kernel.util.ts @@ -25,6 +25,11 @@ import { } from './unit-search-shared.util'; import { getUnitVariantGroupKey } from './unit-variant.util'; import { isCountableBackedDropdown } from './unit-search-filter-config.util'; +import { + buildIndexedASSpecialSelectionCandidates, + unitMatchesASSpecialSelections, + type ParsedASSpecials, +} from './as-special-filter.util'; export interface UnitFilterKernelDependencies { getProperty: (unit: UnitSummary, key?: string) => unknown; @@ -42,6 +47,8 @@ export interface UnitFilterKernelDependencies { unitMatchesAvailabilityRarity: (unit: UnitSummary, rarityName: string, scope?: AvailabilityFilterScope) => boolean; getForcePackLookupSet: (packName: string) => ReadonlySet | undefined; getAvailabilityLookupKey: (unit: UnitSummary) => string; + getIndexedUnitIds?: (filterKey: string, value: string) => ReadonlySet | undefined; + getIndexedASSpecials?: (unitName: string) => ParsedASSpecials | undefined; } interface ApplyUnitFilterStateRequest { @@ -299,10 +306,36 @@ export function applyFilterStateToUnits(request: ApplyUnitFilterStateRequest): U } if (conf.type === AdvFilterType.DROPDOWN && conf.multistate) { + const selection = normalizeMultiStateSelection(val); + if (conf.key === 'as.specials') { + const specialSelections = [ + ...Object.values(selection), + ...(wildcardPatterns ?? []).map(pattern => ({ + name: pattern.pattern, + state: pattern.state, + })), + ]; + const indexedCandidates = dependencies.getIndexedUnitIds + ? buildIndexedASSpecialSelectionCandidates( + specialSelections, + token => dependencies.getIndexedUnitIds?.('as.specials', token), + ) + : null; + if (indexedCandidates) { + results = results.filter(unit => indexedCandidates.has(unit.name)); + } + results = results.filter(unit => unitMatchesASSpecialSelections( + dependencies.getProperty(unit, conf.key), + specialSelections, + dependencies.getIndexedASSpecials?.(unit.name), + )); + continue; + } + results = filterUnitsByMultiState( results, conf.key, - normalizeMultiStateSelection(val), + selection, dependencies.getProperty, wildcardPatterns, ); diff --git a/src/app/utils/unit-search-adv-options-builder.util.ts b/src/app/utils/unit-search-adv-options-builder.util.ts index 7987b698c..ecfbff93b 100644 --- a/src/app/utils/unit-search-adv-options-builder.util.ts +++ b/src/app/utils/unit-search-adv-options-builder.util.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import type { MultiStateSelection } from '../components/multi-select-dropdown/multi-select-dropdown.component'; +import type { DropdownOption, MultiStateSelection } from '../components/multi-select-dropdown/multi-select-dropdown.component'; import type { GameSystem } from '../models/common.model'; import type { UnitSummary } from '../models/unit-summary.model'; import type { WildcardPattern } from './semantic-filter.util'; @@ -32,7 +32,7 @@ interface BuildUnitSearchAdvOptionsRequest { contextUnits: UnitSummary[], displayNameFn?: (value: string) => string | undefined, contextUnitIds?: ReadonlySet, - ) => { name: string; img?: string; displayName?: string; available?: boolean }[]; + ) => DropdownOption[]; buildForcePackDropdownOptions: ( snapshot: AdvOptionsContextSnapshot, contextUnits: UnitSummary[], @@ -41,7 +41,7 @@ interface BuildUnitSearchAdvOptionsRequest { conf: AdvFilterConfig, contextUnits: UnitSummary[], state: FilterState, - ) => { name: string; img?: string; displayName?: string; available?: boolean }[] | null; + ) => DropdownOption[] | null; getIndexedUniverseNames: (filterKey: string) => string[]; getSortedIndexedUniverseNames: (conf: AdvFilterConfig) => string[]; collectIndexedAvailabilityNames: ( @@ -304,7 +304,7 @@ export function buildUnitSearchAdvOptions(request: BuildUnitSearchAdvOptionsRequ } const contextDerivationMs = getNowMs() - contextDerivationStartedAt; - let availableOptions: { name: string; img?: string; displayName?: string; available?: boolean }[] = []; + let availableOptions: DropdownOption[] = []; if (conf.type === AdvFilterType.BOOLEAN) { const value = normalizeTriStateBooleanFilterValue( @@ -384,10 +384,11 @@ export function buildUnitSearchAdvOptions(request: BuildUnitSearchAdvOptionsRequ const optionsWithAvailability = sortedNames.map(name => { const normalizedName = isCountableFilter ? name.toLowerCase() : name; const metadata = indexedOptionMetadata?.get(name); - const option: { name: string; img?: string; displayName?: string; available: boolean; count?: number } = { + const option: DropdownOption = { name, ...(metadata?.img ? { img: metadata.img } : {}), ...(metadata?.displayName ? { displayName: metadata.displayName } : {}), + ...(metadata?.minimumFieldLabels ? { minimumFieldLabels: metadata.minimumFieldLabels } : {}), available: availableNameSet.has(normalizedName) || availableNameSet.has(name), }; @@ -587,4 +588,4 @@ export function buildUnitSearchAdvOptions(request: BuildUnitSearchAdvOptionsRequ totalMs: getNowMs() - advOptionsStartedAt, }, }; -} \ No newline at end of file +} diff --git a/src/app/utils/unit-search-executor.util.spec.ts b/src/app/utils/unit-search-executor.util.spec.ts index a3769ce4c..087710238 100644 --- a/src/app/utils/unit-search-executor.util.spec.ts +++ b/src/app/utils/unit-search-executor.util.spec.ts @@ -7,6 +7,8 @@ import type { UnitSummary } from '../models/unit-summary.model'; import { createEmptyUnit } from '../testing/unit-test-helpers'; import { parseSemanticQueryAST } from './semantic-filter-ast.util'; import { executeUnitSearch } from './unit-search-executor.util'; +import { parseASSpecials } from './as-special-filter.util'; +import { applyFilterStateToUnits } from './unit-filter-kernel.util'; function createUnit(overrides: Pick): UnitSummary { return createEmptyUnit(overrides); @@ -193,6 +195,89 @@ describe('unit-search-executor', () => { .toEqual(['One AI', 'Two AI']); }); + it('uses the sync pre-parsed specials index for numeric minima', () => { + const unit = createEmptyUnit({ + name: 'Indexed AC', + as: { ...createEmptyUnit().as, specials: ['AC1/1/1'] }, + }); + const indexedSpecials = parseASSpecials(['TUR(3/3/3,AC1/4/1)']); + const execution = executeUnitSearch({ + units: [unit], + parsedQuery: parseSemanticQueryAST('specials="AC*/>=4/*"', GameSystem.ALPHA_STRIKE), + searchTokens: [], + gameSystem: GameSystem.ALPHA_STRIKE, + sortKey: 'name', + sortDirection: 'asc', + bvPvLimit: 0, + forceTotalBvPv: 0, + getAdjustedBV: value => value.bv, + getAdjustedPV: value => value.as.PV, + unitBelongsToEra: () => false, + unitBelongsToFaction: () => false, + unitBelongsToForcePack: () => false, + getAllEraNames: () => [], + getAllFactionNames: () => [], + getIndexedASSpecials: unitId => unitId === unit.name ? indexedSpecials : undefined, + }); + + expect(execution.results.map(result => result.name)).toEqual(['Indexed AC']); + }); + + it('uses specials token postings before sync UI tuple evaluation', () => { + const matching = createEmptyUnit({ + name: 'Matching AC', + as: { ...createEmptyUnit().as, specials: ['AC1/4/1'] }, + }); + const unrelated = createEmptyUnit({ + name: 'Unrelated TAG', + as: { ...createEmptyUnit().as, specials: ['TAG'] }, + }); + const parsedByUnit = new Map([ + [matching.name, parseASSpecials(matching.as.specials)], + [unrelated.name, parseASSpecials(unrelated.as.specials)], + ]); + const getIndexedUnitIds = jasmine.createSpy('getIndexedUnitIds') + .and.callFake((_filterKey: string, token: string) => ( + token === 'AC' ? new Set([matching.name]) : undefined + )); + const getIndexedASSpecials = jasmine.createSpy('getIndexedASSpecials') + .and.callFake((unitName: string) => parsedByUnit.get(unitName)); + + const results = applyFilterStateToUnits({ + units: [matching, unrelated], + state: { + 'as.specials': { + interactedWith: true, + value: { + AC: { + name: 'AC', + state: 'or', + count: 1, + minimumValues: [null, 4, null], + }, + }, + }, + }, + dependencies: { + getProperty: (unit, key) => key === 'as.specials' ? unit.as.specials : undefined, + getAdjustedBV: unit => unit.bv, + getAdjustedPV: unit => unit.as.PV, + getUnitIdsForExternalFilters: () => null, + getPositiveFactionNames: () => [], + unitMatchesAvailabilityFrom: () => false, + unitMatchesAvailabilityRarity: () => false, + getForcePackLookupSet: () => undefined, + getAvailabilityLookupKey: unit => unit.name, + getIndexedUnitIds, + getIndexedASSpecials, + }, + }); + + expect(results).toEqual([matching]); + expect(getIndexedUnitIds).toHaveBeenCalledOnceWith('as.specials', 'AC'); + expect(getIndexedASSpecials).toHaveBeenCalledOnceWith(matching.name); + }); + it('evaluates selected weapon types independently for OR and AND queries', () => { const dualTyped = createEmptyUnit({ name: 'Dual Typed', diff --git a/src/app/utils/unit-search-executor.util.ts b/src/app/utils/unit-search-executor.util.ts index fcbc2097b..d90c1df64 100644 --- a/src/app/utils/unit-search-executor.util.ts +++ b/src/app/utils/unit-search-executor.util.ts @@ -22,6 +22,7 @@ import { applyFilterStateToUnits, type UnitFilterKernelDependencies } from './un import type { AvailabilityFilterScope } from '../services/unit-search-filters.model'; import { findBvNormalizationMatch } from './bv-normalization.util'; import { findPvNormalizationMatch } from './pv-normalization.util'; +import type { ParsedASSpecials } from './as-special-filter.util'; export interface UnitSearchExecutionRequest { units: UnitSummary[]; @@ -51,6 +52,7 @@ export interface UnitSearchExecutionRequest { getDisplayName?: (filterKey: string, value: string) => string | undefined; getIndexedUnitIds?: (filterKey: string, value: string, scope?: AvailabilityFilterScope) => ReadonlySet | undefined; getIndexedFilterValues?: (filterKey: string) => readonly string[]; + getIndexedASSpecials?: (unitId: string) => ParsedASSpecials | undefined; availabilitySortScope?: AvailabilityFilterScope; getMegaMekRaritySortScore?: (unit: UnitSummary, scope?: AvailabilityFilterScope) => number; } @@ -202,6 +204,7 @@ export function executeUnitSearch(request: UnitSearchExecutionRequest): UnitSear getDisplayName: request.getDisplayName, getIndexedUnitIds: request.getIndexedUnitIds, getIndexedFilterValues: request.getIndexedFilterValues, + getIndexedASSpecials: request.getIndexedASSpecials, }; let candidateUnits = allUnits; @@ -376,4 +379,4 @@ export function executeUnitSearch(request: UnitSearchExecutionRequest): UnitSear unitCount, isComplex, }; -} \ No newline at end of file +} diff --git a/src/app/utils/unit-search-shared.util.ts b/src/app/utils/unit-search-shared.util.ts index 068425070..233842ae4 100644 --- a/src/app/utils/unit-search-shared.util.ts +++ b/src/app/utils/unit-search-shared.util.ts @@ -209,13 +209,22 @@ export function normalizeMultiStateSelection(value: unknown): MultiStateSelectio continue; } + const minimumValues = Array.isArray(option.minimumValues) + ? option.minimumValues.map(value => ( + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null + )) + : undefined; + const optionWithoutMinimumValues = { ...option }; + delete optionWithoutMinimumValues.minimumValues; + selection[name] = { - ...option, + ...optionWithoutMinimumValues, name, state: isMultiState(option.state) ? option.state : false, count: typeof option.count === 'number' && Number.isFinite(option.count) && option.count > 0 ? option.count : 1, + ...(minimumValues?.some(value => value !== null) ? { minimumValues } : {}), }; } diff --git a/src/app/utils/unit-search-url-filters.util.spec.ts b/src/app/utils/unit-search-url-filters.util.spec.ts index e0b7301ef..ee3aaf329 100644 --- a/src/app/utils/unit-search-url-filters.util.spec.ts +++ b/src/app/utils/unit-search-url-filters.util.spec.ts @@ -14,7 +14,7 @@ function createDropdownDependencies(): UnitSearchDropdownValuesDependencies { return { getDropdownOptionUniverse: (filterKey: string) => { if (filterKey === 'as.specials') { - return [SPECIAL, 'TAG']; + return [SPECIAL, 'AC', 'TAG']; } if (filterKey === 'era') { @@ -528,6 +528,41 @@ describe('unit search URL filters', () => { }); }); + it('round-trips contextual Alpha Strike special minima in compact filters', () => { + const filterState: FilterState = { + 'as.specials': { + value: { + AC: { + name: 'AC', + state: 'and', + count: 1, + minimumValues: [null, null, 3], + }, + }, + interactedWith: true, + }, + }; + + const queryParameters = buildUnitSearchQueryParameters({ + searchText: '', + filterState, + semanticKeys: new Set(), + selectedSort: '', + selectedSortDirection: 'asc', + expanded: false, + gunnery: DEFAULT_GUNNERY_SKILL, + piloting: DEFAULT_PILOTING_SKILL, + bvLimit: 0, + publicTagsParam: null, + }); + + expect(queryParameters.filters).toBe('as.specials:AC.^//3'); + expect(parseAndValidateCompactFiltersFromUrl( + queryParameters.filters!, + createDropdownDependencies(), + )).toEqual(filterState); + }); + it('round-trips multistate era dropdown values in compact filters', () => { const filterState: FilterState = { era: { @@ -565,4 +600,4 @@ describe('unit search URL filters', () => { interactedWith: true, }); }); -}); \ No newline at end of file +}); diff --git a/src/app/utils/unit-search-url-filters.util.ts b/src/app/utils/unit-search-url-filters.util.ts index 222b9fbeb..439cfb50c 100644 --- a/src/app/utils/unit-search-url-filters.util.ts +++ b/src/app/utils/unit-search-url-filters.util.ts @@ -14,6 +14,7 @@ import type { UnitSearchViewMode } from '../models/options.model'; import { DEFAULT_CLASSIC_BV_NORMALIZATION_MAX_DELTA, type BvNormalizationSettings, type PvNormalizationSettings, type UnitSearchBudgetMode } from '../models/unit-search-result.model'; import { isValidBvNormalizationSettings } from './bv-normalization.util'; import { isValidPvNormalizationSettings } from './pv-normalization.util'; +import { getASSpecialToken } from './as-special-filter.util'; export interface ParsedUnitSearchScalarUrlState { searchText: string | null; @@ -97,6 +98,47 @@ function splitCompactFilterValues(valueStr: string): string[] { return parseValues(valueStr).filter(value => value.trim() !== ''); } +function serializeASSpecialMinimumSuffix(values: readonly (number | null)[] | undefined): string { + if (!values?.some(value => value !== null && value !== undefined)) { + return ''; + } + + let lastValueIndex = values.length - 1; + while (lastValueIndex >= 0 && (values[lastValueIndex] === null || values[lastValueIndex] === undefined)) { + lastValueIndex--; + } + + return '^' + values.slice(0, lastValueIndex + 1) + .map(value => value === null || value === undefined ? '' : String(value)) + .join('/'); +} + +function parseASSpecialMinimumSuffix(value: string): { name: string; minimumValues?: (number | null)[] } { + const markerIndex = value.lastIndexOf('^'); + if (markerIndex === -1) { + return { name: value }; + } + + const parts = value.slice(markerIndex + 1).split('/'); + const minimumValues: (number | null)[] = []; + for (const part of parts) { + if (part === '') { + minimumValues.push(null); + continue; + } + + const parsed = Number(part); + if (!Number.isFinite(parsed) || parsed < 0) { + return { name: value }; + } + minimumValues.push(parsed); + } + + return minimumValues.some(entry => entry !== null) + ? { name: value.slice(0, markerIndex), minimumValues } + : { name: value.slice(0, markerIndex) }; +} + function parseBoundedInteger(value: string | null | undefined, min: number, max: number): number | null { if (value === null || value === undefined || value === '') { return null; @@ -243,6 +285,9 @@ function generateCompactFiltersParam(state: FilterState): string | null { if (selectionValue.state === 'and') part += '.'; else if (selectionValue.state === 'not') part += '!'; if (selectionValue.count > 1) part += `~${selectionValue.count}`; + if (key === 'as.specials') { + part += serializeASSpecialMinimumSuffix(selectionValue.minimumValues); + } subParts.push(part); } } @@ -358,7 +403,10 @@ function parseCompactFiltersFromUrl( const availableValuesMap = dropdownValuesDependencies ? getAvailableDropdownValuesMap(conf, dropdownValuesDependencies) : null; - const exactValueMatch = availableValuesMap?.get(valueStr.toLowerCase()); + const legacyCompositeSpecial = key === 'as.specials' && /^TUR\s*\(.*\)$/i.test(valueStr) + ? valueStr + : undefined; + const exactValueMatch = availableValuesMap?.get(valueStr.toLowerCase()) ?? legacyCompositeSpecial; if (conf.multistate) { if (exactValueMatch) { @@ -378,6 +426,13 @@ function parseCompactFiltersFromUrl( let name = item; let state: MultiState = 'or'; let count = 1; + let minimumValues: (number | null)[] | undefined; + + if (key === 'as.specials') { + const parsedMinimum = parseASSpecialMinimumSuffix(name); + name = parsedMinimum.name; + minimumValues = parsedMinimum.minimumValues; + } const starIndex = name.indexOf('~'); if (starIndex !== -1) { @@ -395,7 +450,12 @@ function parseCompactFiltersFromUrl( name = conf.valueNormalizer?.(name) ?? name; - selection[name] = { name, state, count }; + selection[name] = { + name, + state, + count, + ...(minimumValues ? { minimumValues } : {}), + }; } if (Object.keys(selection).length > 0) { @@ -448,6 +508,16 @@ function validateParsedFiltersFromUrl( const properCase = availableValuesMap.get(normalizedName.toLowerCase()); if (properCase) { validSelection[properCase] = { ...selectionValue, name: properCase }; + continue; + } + + // Preserve old shared URLs that selected one concrete TUR + // string before the specials index switched to tokens. + if (key === 'as.specials') { + const token = getASSpecialToken(normalizedName); + if (token && availableValuesMap.has(token.toLowerCase())) { + validSelection[normalizedName] = { ...selectionValue, name: normalizedName }; + } } } if (Object.keys(validSelection).length > 0) { @@ -495,4 +565,4 @@ export function resolveInitialUnitSearchViewMode( const hasSearchState = params.has('q') || params.has('filters'); return hasSearchState || persistedViewMode === 'table' ? 'list' : persistedViewMode; -} \ No newline at end of file +} diff --git a/src/app/utils/unit-search-worker-request.util.spec.ts b/src/app/utils/unit-search-worker-request.util.spec.ts index 08a52be42..bc9d059e3 100644 --- a/src/app/utils/unit-search-worker-request.util.spec.ts +++ b/src/app/utils/unit-search-worker-request.util.spec.ts @@ -5,6 +5,7 @@ import { GameSystem } from '../models/common.model'; import { createEmptyUnit } from '../testing/unit-test-helpers'; import { parseSemanticQueryAST } from './semantic-filter-ast.util'; +import { tokensToFilterState } from './semantic-filter.util'; import { buildWorkerExecutionQuery, getWorkerCorpusSnapshot } from './unit-search-worker-request.util'; describe('buildWorkerExecutionQuery', () => { @@ -98,6 +99,88 @@ describe('buildWorkerExecutionQuery', () => { expect(parseSemanticQueryAST(executionQuery, GameSystem.CLASSIC).errors).toEqual([]); }); + it('serializes contextual Alpha Strike special minima for worker execution', () => { + const executionQuery = buildWorkerExecutionQuery({ + effectiveFilterState: { + 'as.specials': { + value: { + AC: { + name: 'AC', + state: 'or', + count: 1, + minimumValues: [null, null, 3], + }, + }, + interactedWith: true, + }, + }, + effectiveTextSearch: '', + gameSystem: GameSystem.ALPHA_STRIKE, + totalRangesCache: {}, + }); + + expect(executionQuery).toBe('specials="AC*/*/>=3"'); + expect(parseSemanticQueryAST(executionQuery, GameSystem.ALPHA_STRIKE).tokens).toEqual([ + jasmine.objectContaining({ + field: 'specials', + operator: '=', + values: ['AC*/*/>=3'], + }), + ]); + expect(tokensToFilterState( + parseSemanticQueryAST(executionQuery, GameSystem.ALPHA_STRIKE).tokens, + GameSystem.ALPHA_STRIKE, + {}, + )['as.specials']?.value).toEqual({ + AC: { + name: 'AC', + state: 'or', + count: 1, + minimumValues: [null, null, 3], + }, + }); + }); + + it('preserves repeated semantic clauses instead of flattening them through UI state', () => { + const executionQuery = buildWorkerExecutionQuery({ + effectiveFilterState: {}, + effectiveTextSearch: '', + semanticTokenTexts: [ + 'specials&="AC*/>=4/*"', + 'specials&="AC*/*/>=3"', + ], + gameSystem: GameSystem.ALPHA_STRIKE, + totalRangesCache: {}, + }); + + expect(executionQuery).toBe('specials&="AC*/>=4/*" specials&="AC*/*/>=3"'); + expect(parseSemanticQueryAST(executionQuery, GameSystem.ALPHA_STRIKE).tokens).toEqual([ + jasmine.objectContaining({ operator: '&=', values: ['AC*/>=4/*'] }), + jasmine.objectContaining({ operator: '&=', values: ['AC*/*/>=3'] }), + ]); + + expect(tokensToFilterState( + parseSemanticQueryAST(executionQuery, GameSystem.ALPHA_STRIKE).tokens, + GameSystem.ALPHA_STRIKE, + {}, + )['as.specials']?.semanticOnly).toBeTrue(); + }); + + it('keeps formatted digit-bearing artillery minima UI-representable', () => { + const parsed = parseSemanticQueryAST('specials="ARTCM5>=1"', GameSystem.ALPHA_STRIKE); + const state = tokensToFilterState(parsed.tokens, GameSystem.ALPHA_STRIKE, {})['as.specials']; + + expect(state?.semanticOnly).toBeUndefined(); + expect(state?.value).toEqual({ + ARTCM5: { + name: 'ARTCM5', + state: 'or', + count: 1, + minimumValues: [1], + }, + }); + }); + it('serializes plain rulebook selections for worker execution', () => { const executionQuery = buildWorkerExecutionQuery({ effectiveFilterState: { diff --git a/src/app/utils/unit-search-worker-request.util.ts b/src/app/utils/unit-search-worker-request.util.ts index 27f39d626..60e321f70 100644 --- a/src/app/utils/unit-search-worker-request.util.ts +++ b/src/app/utils/unit-search-worker-request.util.ts @@ -22,6 +22,8 @@ interface UnitSearchWorkerCorpusCache { interface BuildWorkerExecutionQueryArgs { effectiveFilterState: FilterState; effectiveTextSearch: string; + /** Original committed clauses; preserving these avoids flattening repeated constraints. */ + semanticTokenTexts?: readonly string[]; gameSystem: GameSystem; totalRangesCache: Record; } @@ -81,15 +83,21 @@ export function getWorkerCorpusSnapshot( export function buildWorkerExecutionQuery({ effectiveFilterState, effectiveTextSearch, + semanticTokenTexts = [], gameSystem, totalRangesCache, }: BuildWorkerExecutionQueryArgs): string { - return filterStateToSemanticText( + const uiFilterText = filterStateToSemanticText( effectiveFilterState, escapePlainTextForWorkerExecutionQuery(effectiveTextSearch), gameSystem, totalRangesCache, ).trim(); + + return [uiFilterText, ...semanticTokenTexts] + .map(part => part.trim()) + .filter(Boolean) + .join(' '); } export function buildWorkerSearchRequest(args: BuildWorkerSearchRequestArgs): UnitSearchWorkerQueryRequest { @@ -107,4 +115,4 @@ export function buildWorkerSearchRequest(args: BuildWorkerSearchRequestArgs): Un pilotPilotingSkill: args.pilotPilotingSkill, normalization: args.normalization, }; -} \ No newline at end of file +} From 7b08ddb4f33b70c18d958ceef2aa3e9d41cabe7a Mon Sep 17 00:00:00 2001 From: exeea Date: Sat, 29 Aug 2026 15:30:48 +0200 Subject: [PATCH 72/87] nova cews + stealth --- .../nova-cews.handler.spec.ts | 61 ++++++++++++++++++- .../equipment-handlers/nova-cews.handler.ts | 8 ++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/app/equipment-handlers/nova-cews.handler.spec.ts b/src/app/equipment-handlers/nova-cews.handler.spec.ts index 174398c44..3b3586ffe 100644 --- a/src/app/equipment-handlers/nova-cews.handler.spec.ts +++ b/src/app/equipment-handlers/nova-cews.handler.spec.ts @@ -3,7 +3,7 @@ // Author: Drake import type { PickerChoice } from '../components/picker/picker.interface'; -import { MiscEquipment } from '../models/equipment.model'; +import { ArmorEquipment, MiscEquipment } from '../models/equipment.model'; import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; import { MountedEquipment } from '../models/mounted-equipment.model'; import type { TurnState } from '../models/turn-state.model'; @@ -23,10 +23,16 @@ import { NOVA_CEWS_TURNING_OFF_STATE, NOVA_CEWS_TURNING_ON_STATE, } from '../utils/ecm-state.util'; +import { + isC3DisruptingStealthActive, + STEALTH_ENABLED_STATE, + STEALTH_STATE_KEY, +} from '../models/stealth-equipment.model'; import { BAPHandler } from './bap.handler'; import { C3Handler } from './c3.handler'; import { ECMHandler } from './ecm.handler'; import { NOVA_CEWS_HANDLER_ID, NovaCewsHandler } from './nova-cews.handler'; +import { StealthHandler } from './stealth.handler'; function fixture() { const test = createTestEquipmentOwner({ @@ -109,6 +115,59 @@ describe('NovaCewsHandler', () => { }]); }); + it('keeps Nova heat while active Stealth Armor suppresses its electronic effects', () => { + const test = fixture(); + const nova = test.add(); + const stealthEquipment = new ArmorEquipment({ + id: 'StealthArmor', + name: 'Stealth Armor', + type: 'armor', + flags: ['F_STEALTH'], + modes: ['Off', 'On'], + armor: { type: 'STEALTH' }, + }); + const stealth = new MountedEquipment({ + owner: test.owner, + id: 'stealth', + name: stealthEquipment.name, + equipment: stealthEquipment, + states: new Map([[STEALTH_STATE_KEY, STEALTH_ENABLED_STATE]]), + }); + test.owner.setInventoryEntry(stealth); + expect(isC3DisruptingStealthActive(stealth)).toBeTrue(); + + const canPerformEquipmentAction = test.owner.canPerformEquipmentAction.bind(test.owner); + spyOn(test.owner, 'canPerformEquipmentAction').and.callFake((equipment, action) => ( + action === 'provide-passive-effect' && equipment === nova + ? false + : canPerformEquipmentAction(equipment, action) + )); + expect(queryContext.canProvidePassiveEffect(nova)).toBeFalse(); + + const registry = new EquipmentInteractionRegistry(); + registry.register(handler); + registry.register(new StealthHandler()); + + expect(registry.getInventoryHeatSources( + [nova, stealth], + {} as TurnState, + queryContext, + )).toEqual([ + { + id: 'nova-cews:nova', + label: 'Nova CEWS', + value: 2, + group: 'Equipment', + }, + { + id: 'stealth:stealth', + label: 'Stealth', + value: 10, + group: 'Equipment', + }, + ]); + }); + it('keeps its effects and heat through a pending End-Phase shutdown', () => { const test = fixture(); const mounted = test.add(); diff --git a/src/app/equipment-handlers/nova-cews.handler.ts b/src/app/equipment-handlers/nova-cews.handler.ts index 55adab517..628ad8171 100644 --- a/src/app/equipment-handlers/nova-cews.handler.ts +++ b/src/app/equipment-handlers/nova-cews.handler.ts @@ -69,7 +69,13 @@ export class NovaCewsHandler extends ToggleHandler { _turnState: TurnState, context: HandlerQueryContext, ): UnitHeatSource[] { - if (!this.isActive(equipment) || !context.canProvidePassiveEffect(equipment)) return []; + // Stealth Armor suppresses Nova's ECM/probe/C3 effects without powering + // the suite down. Heat therefore follows power and operability, not the + // permission to expose a passive electronic effect. + if (!this.isActive(equipment) + || context.getStatus(equipment) !== 'available' + || equipment.owner.destroyed + || equipment.owner.getCondition('shutdown')) return []; return [{ id: `nova-cews:${equipment.id}`, From 8811334ae8b63063cf2e974ae9c9f14adc894b11 Mon Sep 17 00:00:00 2001 From: exeea Date: Sat, 29 Aug 2026 18:46:42 +0200 Subject: [PATCH 73/87] wildcard when locs>3 --- .../weapons-equipment-panel.component.spec.ts | 15 +++++++++++++++ src/app/utils/inventory-control.util.ts | 6 +++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts b/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts index f4f7e0b73..d65ad767a 100644 --- a/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts +++ b/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts @@ -457,6 +457,21 @@ describe('WeaponsEquipmentPanelComponent', () => { expect(row?.display.location).toBe('*'); }); + it('shows a wildcard location for equipment spanning more than three locations', () => { + const nullSignature = entry({ + id: 'null-signature', + equipment: misc('Null Signature System', ['F_NULL_SIG']), + locations: new Set(['CT', 'RT', 'LT', 'RA', 'LA', 'RL', 'LL']), + }); + const { component, fixture } = createComponent([nullSignature]); + + const row = component.groups().find(group => group.id === 'equipment')!.rows[0]; + const locationCell = fixture.nativeElement.querySelector('.location-cell') as HTMLElement; + + expect(row.display.location).toBe('*'); + expect(locationCell.textContent?.trim()).toBe('*'); + }); + it('shows active Nova CEWS heat in the Equipment row', () => { const nova = entry({ id: 'nova', diff --git a/src/app/utils/inventory-control.util.ts b/src/app/utils/inventory-control.util.ts index 53512edfc..0f73411e3 100644 --- a/src/app/utils/inventory-control.util.ts +++ b/src/app/utils/inventory-control.util.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { AmmoEquipment, ArmorEquipment, WeaponEquipment } from '../models/equipment.model'; +import { AmmoEquipment, WeaponEquipment } from '../models/equipment.model'; import type { WeaponType } from '../models/weapon-types.model'; import type { EquipmentRegistry } from '../models/equipment-lookup'; import type { CBTForceUnit, EquipmentAction } from '../models/cbt-force-unit.model'; @@ -819,10 +819,10 @@ function readTypedEquipmentDisplayData( const ranges = weapon && entry.owner.getUnit().type === 'Aero' ? STANDARD_AEROSPACE_RANGE_LIMITS : weapon?.ranges; - const wildcardLocation = equipment instanceof ArmorEquipment; + const locations = Array.from(entry.locations ?? []); return { name: displayName, - location: wildcardLocation ? '*' : normalizeCell(Array.from(entry.locations ?? []).join('/')), + location: locations.length > 3 ? '*' : normalizeCell(locations.join('/')), heat: weapon ? formatInventoryControlHeat(weapon.heat) : '—', damage: weapon ? '—' : physicalDamage, hit, From fcb64246ba94624c6bbfd0f01ae9940f5be14eb3 Mon Sep 17 00:00:00 2001 From: exeea Date: Sat, 29 Aug 2026 18:47:10 +0200 Subject: [PATCH 74/87] armor wildcard --- src/app/utils/inventory-control.util.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/utils/inventory-control.util.ts b/src/app/utils/inventory-control.util.ts index 0f73411e3..66f214e38 100644 --- a/src/app/utils/inventory-control.util.ts +++ b/src/app/utils/inventory-control.util.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { AmmoEquipment, WeaponEquipment } from '../models/equipment.model'; +import { AmmoEquipment, ArmorEquipment, WeaponEquipment } from '../models/equipment.model'; import type { WeaponType } from '../models/weapon-types.model'; import type { EquipmentRegistry } from '../models/equipment-lookup'; import type { CBTForceUnit, EquipmentAction } from '../models/cbt-force-unit.model'; @@ -820,9 +820,10 @@ function readTypedEquipmentDisplayData( ? STANDARD_AEROSPACE_RANGE_LIMITS : weapon?.ranges; const locations = Array.from(entry.locations ?? []); + const wildcardLocation = equipment instanceof ArmorEquipment || locations.length > 3; return { name: displayName, - location: locations.length > 3 ? '*' : normalizeCell(locations.join('/')), + location: wildcardLocation ? '*' : normalizeCell(locations.join('/')), heat: weapon ? formatInventoryControlHeat(weapon.heat) : '—', damage: weapon ? '—' : physicalDamage, hit, From 4b4af8fdc230b05fa3237e5ddfcc6b095ddafa40 Mon Sep 17 00:00:00 2001 From: exeea Date: Sat, 29 Aug 2026 19:29:56 +0200 Subject: [PATCH 75/87] UnitEngineType --- src/app/models/unit-summary.model.ts | 21 ++++++++++++++++++++- src/app/utils/unit-metadata-builder.spec.ts | 19 +++++++++++++++++++ src/app/utils/unit-metadata-builder.ts | 5 +++-- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/app/models/unit-summary.model.ts b/src/app/models/unit-summary.model.ts index 0ac0b8150..cf9e3e6ab 100644 --- a/src/app/models/unit-summary.model.ts +++ b/src/app/models/unit-summary.model.ts @@ -35,6 +35,25 @@ export const CBT_WEIGHT_CLASSES = [ export type WeightClass = typeof CBT_WEIGHT_CLASSES[number]; +/** Engine type names exported by MegaMekLab's SVGMassPrinter. */ +export type UnitEngineType = + | 'ICE' + | 'Fusion' + | 'XL (IS)' + | 'XL (Clan)' + | 'XXL (IS)' + | 'XXL (Clan)' + | 'Fuel Cell' + | 'Light' + | 'Compact' + | 'Fission' + | 'None' + | 'MagLev' + | 'Steam' + | 'Battery' + | 'Solar' + | 'External'; + export const CBT_WEIGHT_CLASS_ORDINALS = new Map( CBT_WEIGHT_CLASSES.map((weightClass, index) => [weightClass, index] as const) ); @@ -141,7 +160,7 @@ export interface UnitSummary { type: UnitType; subtype: UnitSubtype; omni: number; - engine: string; + engine: UnitEngineType | null; engineRating: number; engineHS: number; // Number of HeatSinks on the engine engineHSType: string | null; // Type of HeatSinks on the engine: "Heat Sink", "Double Heat Sink", "Laser Heat Sink", etc... diff --git a/src/app/utils/unit-metadata-builder.spec.ts b/src/app/utils/unit-metadata-builder.spec.ts index 861768712..7293ef9d2 100644 --- a/src/app/utils/unit-metadata-builder.spec.ts +++ b/src/app/utils/unit-metadata-builder.spec.ts @@ -152,6 +152,25 @@ describe('UnitMetadataBuilder', () => { expect(builder.build(entity).offSpeedFactor).toBe(1.12); }); + it('exports canonical SVGMassPrinter engine names', () => { + const entity = new BipedMekEntity(); + const cases = [ + ['Fusion', 'IS', 'Fusion'], + ['XL', 'IS', 'XL (IS)'], + ['XL', 'Clan', 'XL (Clan)'], + ['XXL', 'IS', 'XXL (IS)'], + ['XXL', 'Clan', 'XXL (Clan)'], + ['Maglev', 'IS', 'MagLev'], + ] as const; + + for (const [type, techBase, expected] of cases) { + entity.mountedEngine.set(new MountedEngine({ type, rating: 250, techBase })); + expect(builder.build(entity).engine).withContext(type).toBe(expected); + } + + expect(builder.build(new DropShipEntity()).engine).toBeNull(); + }); + it('uses BV jump conditions for TSM Meks with modular armor', () => { const entity = new BipedMekEntity(); entity.originalWalkMP.set(5); diff --git a/src/app/utils/unit-metadata-builder.ts b/src/app/utils/unit-metadata-builder.ts index 5d75b586c..d83bbf50d 100644 --- a/src/app/utils/unit-metadata-builder.ts +++ b/src/app/utils/unit-metadata-builder.ts @@ -195,12 +195,13 @@ export class UnitMetadataBuilder { } } - private buildEngineName(entity: BaseEntity): any { + private buildEngineName(entity: BaseEntity): UnitSummary['engine'] { if (!this.exportsEngine(entity)) return null; const engine = entity.mountedEngine(); const type = engine.type(); - return type === 'XL' || type === 'XXL' ? `${type} (${engine.techBase})` : type; + if (type === 'XL' || type === 'XXL') return `${type} (${engine.techBase})`; + return type === 'Maglev' ? 'MagLev' : type; } private exportsEngine(entity: BaseEntity): boolean { From 32a9e6623e9bd4b97a73c16f86fc91d980dcb20d Mon Sep 17 00:00:00 2001 From: exeea Date: Sat, 29 Aug 2026 22:09:37 +0200 Subject: [PATCH 76/87] TMM visible in badge --- ...page-interaction-overlay.component.spec.ts | 19 ++++++++++++++-- .../page-interaction-overlay.component.ts | 6 ++++- .../unit-block/unit-block.component.scss | 12 +++++----- .../unit-block/unit-block.component.spec.ts | 12 ++++++++-- .../unit-block/unit-block.component.ts | 10 ++++++--- src/app/models/unit-summary.model.ts | 17 ++++++++++++++ .../turn-movement-indicator.util.spec.ts | 22 +++++++++---------- src/app/utils/turn-movement-indicator.util.ts | 12 +++++++--- 8 files changed, 82 insertions(+), 28 deletions(-) diff --git a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.spec.ts b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.spec.ts index ac393e471..f4ac37167 100644 --- a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.spec.ts +++ b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.spec.ts @@ -3,7 +3,7 @@ // Author: Drake import { Overlay } from '@angular/cdk/overlay'; -import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { provideZonelessChangeDetection, signal, type WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import type { CBTForceUnit } from '../../../models/cbt-force-unit.model'; import { CBTEndTurnService } from '../../../services/cbt-end-turn.service'; @@ -25,15 +25,20 @@ describe('PageInteractionOverlayComponent pending work', () => { let resolvePhase: jasmine.Spy; let closeAllManagedOverlays: jasmine.Spy; let unit: CBTForceUnit; + let moveMode: WritableSignal<'jump' | null>; + let defenderModifier: WritableSignal; beforeEach(async () => { resumePendingChain = jasmine.createSpy('resumePendingChain').and.resolveTo(true); resolvePhase = jasmine.createSpy('endPhase').and.resolveTo(true); closeAllManagedOverlays = jasmine.createSpy('closeAllManagedOverlays'); + moveMode = signal(null); + defenderModifier = signal(0); const turnState = { dirty: () => false, dirtyPhase: () => false, - moveMode: () => null, + moveMode, + getTotalTargetModifierAsDefender: () => ({ modifier: defenderModifier() }), autoFall: () => false, actionablePSRRollsCount: () => 0, PSRRollsCount: () => 0, @@ -104,4 +109,14 @@ describe('PageInteractionOverlayComponent pending work', () => { expect(event.stopPropagation).toHaveBeenCalledTimes(1); expect(resolvePhase).toHaveBeenCalledOnceWith(unit); }); + + it('shows the defender modifier with assigned movement', () => { + moveMode.set('jump'); + defenderModifier.set(4); + fixture.detectChanges(); + + expect(fixture.componentInstance.movementIndicator()).toEqual({ color: 'jump', letter: 'J4' }); + const label = fixture.nativeElement.querySelector('.turn-tracker-button text') as SVGTextElement; + expect(label.textContent?.trim()).toBe('J4'); + }); }); diff --git a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.ts b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.ts index c4535386d..27c6c1186 100644 --- a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.ts +++ b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.ts @@ -105,7 +105,11 @@ export class PageInteractionOverlayComponent { movementIndicator = computed(() => { const unit = this.unit(); if (!unit) return null; - return getTurnMovementIndicator(unit.turnState().moveMode()); + const turnState = unit.turnState(); + return getTurnMovementIndicator( + turnState.moveMode(), + turnState.getTotalTargetModifierAsDefender().modifier, + ); }); endTurnButtonVisible = computed(() => { diff --git a/src/app/components/unit-block/unit-block.component.scss b/src/app/components/unit-block/unit-block.component.scss index f1eb711e0..b9c73954b 100644 --- a/src/app/components/unit-block/unit-block.component.scss +++ b/src/app/components/unit-block/unit-block.component.scss @@ -166,12 +166,12 @@ } .dirty-state { - border-width: 0 0 28px 28px; + border-width: 0 0 32px 32px; } .move-badge { - width: 22px; - height: 22px; + width: 26px; + height: 26px; padding: 0 2px 1px 0; font-size: 10px; } @@ -484,7 +484,7 @@ right: 0; z-index: 1; border-style: solid; - border-width: 0 0 36px 36px; + border-width: 0 0 40px 40px; border-color: transparent transparent #a00 transparent; } @@ -494,8 +494,8 @@ align-items: flex-end; justify-content: flex-end; box-sizing: border-box; - width: 30px; - height: 30px; + width: 34px; + height: 34px; bottom: 0; right: 0; z-index: 3; diff --git a/src/app/components/unit-block/unit-block.component.spec.ts b/src/app/components/unit-block/unit-block.component.spec.ts index 11e3809f5..cf326dc67 100644 --- a/src/app/components/unit-block/unit-block.component.spec.ts +++ b/src/app/components/unit-block/unit-block.component.spec.ts @@ -35,9 +35,14 @@ describe('UnitBlockComponent', () => { it('tracks phase-dirty state independently from assigned movement', () => { const dirtyPhase = signal(false); const moveMode = signal<'walk' | null>(null); + const defenderModifier = signal(4); const forceUnit = Object.create(CBTForceUnit.prototype) as CBTForceUnit; Object.assign(forceUnit, { - turnState: () => ({ dirtyPhase, moveMode }), + turnState: () => ({ + dirtyPhase, + moveMode, + getTotalTargetModifierAsDefender: () => ({ modifier: defenderModifier() }), + }), }); const fixture = TestBed.createComponent(UnitBlockComponent); @@ -48,7 +53,10 @@ describe('UnitBlockComponent', () => { moveMode.set('walk'); expect(fixture.componentInstance.dirty()).toBeFalse(); - expect(fixture.componentInstance.movementIndicator()).toEqual({ color: 'walk', letter: 'W' }); + expect(fixture.componentInstance.movementIndicator()).toEqual({ color: 'walk', letter: 'W4' }); + + defenderModifier.set(2); + expect(fixture.componentInstance.movementIndicator()).toEqual({ color: 'walk', letter: 'W2' }); dirtyPhase.set(true); moveMode.set(null); diff --git a/src/app/components/unit-block/unit-block.component.ts b/src/app/components/unit-block/unit-block.component.ts index eadb9ab42..80941f8c9 100644 --- a/src/app/components/unit-block/unit-block.component.ts +++ b/src/app/components/unit-block/unit-block.component.ts @@ -111,9 +111,13 @@ export class UnitBlockComponent { return null; } const unit = this.forceUnit(); - return unit instanceof CBTForceUnit - ? getTurnMovementIndicator(unit.turnState().moveMode()) - : null; + if (!(unit instanceof CBTForceUnit)) return null; + + const turnState = unit.turnState(); + return getTurnMovementIndicator( + turnState.moveMode(), + turnState.getTotalTargetModifierAsDefender().modifier, + ); }); notificationUnit = computed(() => { diff --git a/src/app/models/unit-summary.model.ts b/src/app/models/unit-summary.model.ts index cf9e3e6ab..3a9c8d35a 100644 --- a/src/app/models/unit-summary.model.ts +++ b/src/app/models/unit-summary.model.ts @@ -54,6 +54,23 @@ export type UnitEngineType = | 'Solar' | 'External'; +const FUSION_UNIT_ENGINE_TYPES: ReadonlySet = new Set([ + 'Fusion', + 'XL (IS)', + 'XL (Clan)', + 'XXL (IS)', + 'XXL (Clan)', + 'Light', + 'Compact', +]); + +/** Whether exported unit metadata describes a fusion-family engine. */ +export function isFusionUnitEngine( + engine: UnitEngineType | null | undefined, +): engine is UnitEngineType { + return engine !== null && engine !== undefined && FUSION_UNIT_ENGINE_TYPES.has(engine); +} + export const CBT_WEIGHT_CLASS_ORDINALS = new Map( CBT_WEIGHT_CLASSES.map((weightClass, index) => [weightClass, index] as const) ); diff --git a/src/app/utils/turn-movement-indicator.util.spec.ts b/src/app/utils/turn-movement-indicator.util.spec.ts index 7e83bf761..8ee55ff40 100644 --- a/src/app/utils/turn-movement-indicator.util.spec.ts +++ b/src/app/utils/turn-movement-indicator.util.spec.ts @@ -10,26 +10,26 @@ import { describe('getTurnMovementIndicator', () => { it('returns no indicator until movement is assigned', () => { - expect(getTurnMovementIndicator(null)).toBeNull(); - expect(getTurnMovementIndicator(undefined)).toBeNull(); + expect(getTurnMovementIndicator(null, 4)).toBeNull(); + expect(getTurnMovementIndicator(undefined, 4)).toBeNull(); }); - it('maps standard movement modes to their colors and letters', () => { + it('maps standard movement modes to their colors and defender-modifier labels', () => { const cases: ReadonlyArray = [ - ['stationary', 'stationary', 'S'], - ['walk', 'walk', 'W'], - ['run', 'run', 'R'], - ['jump', 'jump', 'J'], - ['sprint', 'sprint', 'Sp'], + ['stationary', 'stationary', 'St'], + ['walk', 'walk', 'W4'], + ['run', 'run', 'R4'], + ['jump', 'jump', 'J4'], + ['sprint', 'sprint', 'S4'], ]; for (const [mode, color, letter] of cases) { - expect(getTurnMovementIndicator(mode)).withContext(mode).toEqual({ color, letter }); + expect(getTurnMovementIndicator(mode, 4)).withContext(mode).toEqual({ color, letter }); } }); it('uses the jump color for special movement modes', () => { - expect(getTurnMovementIndicator('UMU')).toEqual({ color: 'jump', letter: 'U' }); - expect(getTurnMovementIndicator('VTOL')).toEqual({ color: 'jump', letter: 'V' }); + expect(getTurnMovementIndicator('UMU', 2)).toEqual({ color: 'jump', letter: 'U2' }); + expect(getTurnMovementIndicator('VTOL', 3)).toEqual({ color: 'jump', letter: 'V3' }); }); }); diff --git a/src/app/utils/turn-movement-indicator.util.ts b/src/app/utils/turn-movement-indicator.util.ts index 3f7be78d1..8b57b18ce 100644 --- a/src/app/utils/turn-movement-indicator.util.ts +++ b/src/app/utils/turn-movement-indicator.util.ts @@ -12,11 +12,11 @@ export interface TurnMovementIndicator { } const TURN_MOVEMENT_INDICATORS: Readonly> = { - stationary: { color: 'stationary', letter: 'S' }, + stationary: { color: 'stationary', letter: 'St' }, walk: { color: 'walk', letter: 'W' }, run: { color: 'run', letter: 'R' }, jump: { color: 'jump', letter: 'J' }, - sprint: { color: 'sprint', letter: 'Sp' }, + sprint: { color: 'sprint', letter: 'S' }, // These special modes use the jump movement category in the turn-state UI. UMU: { color: 'jump', letter: 'U' }, VTOL: { color: 'jump', letter: 'V' }, @@ -24,6 +24,12 @@ const TURN_MOVEMENT_INDICATORS: Readonly Date: Sat, 29 Aug 2026 22:13:02 +0200 Subject: [PATCH 77/87] TMM --- .../equipment-dialog/equipment-dialog.component.html | 2 +- .../equipment-dialog.component.spec.ts | 10 +++++++--- .../equipment-dialog/equipment-dialog.component.ts | 10 +++++++--- .../overlay/page-interaction-overlay.component.html | 2 +- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/app/components/equipment-dialog/equipment-dialog.component.html b/src/app/components/equipment-dialog/equipment-dialog.component.html index a5c21083a..1ebbe0881 100644 --- a/src/app/components/equipment-dialog/equipment-dialog.component.html +++ b/src/app/components/equipment-dialog/equipment-dialog.component.html @@ -16,7 +16,7 @@

{{ unitTitle() }}

[class.sprint]="movement?.color === 'sprint'" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> - {{ movement?.letter ?? 'M' }} + {{ movement?.letter ?? 'M' }} } diff --git a/src/app/components/equipment-dialog/equipment-dialog.component.spec.ts b/src/app/components/equipment-dialog/equipment-dialog.component.spec.ts index 20228aec6..5e490dc9a 100644 --- a/src/app/components/equipment-dialog/equipment-dialog.component.spec.ts +++ b/src/app/components/equipment-dialog/equipment-dialog.component.spec.ts @@ -58,6 +58,7 @@ function createUnit(id: string, entries: MountedEquipment[] = []): CBTForceUnit dirty: () => false, autoFall: () => false, PSRRollsCount: () => 0, + getTotalTargetModifierAsDefender: () => ({ modifier: 0 }), }); spyOn(harness.unit, 'setHeat').and.callThrough(); spyOn(harness.unit, 'setInventoryEntry').and.callThrough(); @@ -150,10 +151,13 @@ describe('EquipmentDialogComponent', () => { expect(registration.handle(new KeyboardEvent('keydown', { key: 'ArrowRight', ctrlKey: true }))).toBeFalse(); }); - it('shows M until movement is selected, then shows its letter and color', () => { + it('shows M until movement is selected, then shows its letter, defender modifier, and color', () => { const unit = createUnit('unit-a'); const moveMode = signal<'walk' | null>(null); - Object.assign(unit.turnState(), { moveMode }); + Object.assign(unit.turnState(), { + moveMode, + getTotalTargetModifierAsDefender: () => ({ modifier: 4 }), + }); const { fixture } = createDialog({ unit, context: createContext() }); const movementSvg = fixture.nativeElement.querySelector('.turn-tracker-title-button svg') as SVGElement; @@ -163,7 +167,7 @@ describe('EquipmentDialogComponent', () => { moveMode.set('walk'); fixture.detectChanges(); - expect(movementSvg.querySelector('text')?.textContent?.trim()).toBe('W'); + expect(movementSvg.querySelector('text')?.textContent?.trim()).toBe('W4'); expect(movementSvg.classList.contains('walk')).toBeTrue(); }); diff --git a/src/app/components/equipment-dialog/equipment-dialog.component.ts b/src/app/components/equipment-dialog/equipment-dialog.component.ts index ab10197a0..003d8ce97 100644 --- a/src/app/components/equipment-dialog/equipment-dialog.component.ts +++ b/src/app/components/equipment-dialog/equipment-dialog.component.ts @@ -59,9 +59,13 @@ export class EquipmentDialogComponent { readonly unitIndex = signal(this.initialUnitIndex()); readonly unitList = computed(() => this.resolveUnitList()); readonly unit = computed(() => this.unitList()[this.unitIndex()] ?? this.requiredUnit()); - readonly turnSummaryMovement = computed(() => - getTurnMovementIndicator(this.unit().turnState().moveMode()) - ); + readonly turnSummaryMovement = computed(() => { + const turnState = this.unit().turnState(); + return getTurnMovementIndicator( + turnState.moveMode(), + turnState.getTotalTargetModifierAsDefender().modifier, + ); + }); readonly targets = computed(() => { this.unit().getInventoryControlTargetsMap(); return this.unit().getInventoryControlTargets(); diff --git a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.html b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.html index 7cc30a44a..0025a0816 100644 --- a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.html +++ b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.html @@ -29,7 +29,7 @@ [class.sprint]="movement?.color === 'sprint'" width="40px" height="40px" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"> - + {{ movement?.letter ?? 'M' }} From f91911cf476f783a76c0f7394ffda6eddf70bc1c Mon Sep 17 00:00:00 2001 From: exeea Date: Sat, 29 Aug 2026 22:14:15 +0200 Subject: [PATCH 78/87] font 16 --- .../components/equipment-dialog/equipment-dialog.component.html | 2 +- .../page-viewer/overlay/page-interaction-overlay.component.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/equipment-dialog/equipment-dialog.component.html b/src/app/components/equipment-dialog/equipment-dialog.component.html index 1ebbe0881..6f9538e14 100644 --- a/src/app/components/equipment-dialog/equipment-dialog.component.html +++ b/src/app/components/equipment-dialog/equipment-dialog.component.html @@ -16,7 +16,7 @@

{{ unitTitle() }}

[class.sprint]="movement?.color === 'sprint'" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> - {{ movement?.letter ?? 'M' }} + {{ movement?.letter ?? 'M' }} } diff --git a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.html b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.html index 0025a0816..17cd9dd6f 100644 --- a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.html +++ b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.html @@ -29,7 +29,7 @@ [class.sprint]="movement?.color === 'sprint'" width="40px" height="40px" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"> - + {{ movement?.letter ?? 'M' }} From 5c85d56800ba63aab3b1b0188d97a80e4022e36c Mon Sep 17 00:00:00 2001 From: exeea Date: Sat, 29 Aug 2026 23:43:18 +0200 Subject: [PATCH 79/87] more equipment handlers and fixed MGA --- .../equipment-dialog.component.scss | 2 +- .../weapons-equipment-panel.component.html | 24 +- .../weapons-equipment-panel.component.scss | 75 +++- .../weapons-equipment-panel.component.spec.ts | 327 +++++++++++++++++- .../weapons-equipment-panel.component.ts | 114 +++++- .../svg-interaction.service.spec.ts | 80 ++++- .../page-viewer/svg-interaction.service.ts | 24 +- src/app/equipment-handlers/bap.handler.ts | 64 +++- .../booby-trap.handler.spec.ts | 77 +++++ .../equipment-handlers/booby-trap.handler.ts | 65 ++++ .../coolant-pod.handler.spec.ts | 154 +++++++++ .../equipment-handlers/coolant-pod.handler.ts | 132 +++++++ src/app/equipment-handlers/ecm.handler.ts | 42 ++- .../equipment-power.handler.spec.ts | 95 +++++ .../equipment-power.handler.ts | 42 +++ src/app/equipment-handlers/index.ts | 18 + .../mga-activation.handler.spec.ts | 136 ++++++++ .../mga-activation.handler.ts | 124 +++++++ .../mobile-hpg.handler.spec.ts | 223 ++++++++++++ .../equipment-handlers/mobile-hpg.handler.ts | 176 ++++++++++ .../nova-cews.handler.spec.ts | 19 + .../equipment-handlers/nova-cews.handler.ts | 34 +- .../prototype-laser.handler.spec.ts | 92 +++++ .../prototype-laser.handler.ts | 71 ++++ .../searchlight.handler.spec.ts | 84 +++++ .../equipment-handlers/searchlight.handler.ts | 31 ++ .../shield-mode.handler.spec.ts | 176 ++++++++++ .../equipment-handlers/shield-mode.handler.ts | 86 +++++ .../spot-welder.handler.spec.ts | 62 ++++ .../equipment-handlers/spot-welder.handler.ts | 73 ++++ .../stealth.handler.spec.ts | 102 +++++- src/app/equipment-handlers/stealth.handler.ts | 29 +- src/app/models/cbt-force-unit-c3.spec.ts | 5 +- src/app/models/cbt-force-unit.model.spec.ts | 45 +++ src/app/models/cbt-force-unit.model.ts | 13 + src/app/models/equipment.model.ts | 7 + src/app/models/rules/game-rules.spec.ts | 5 + src/app/models/rules/game-rules.ts | 4 + src/app/models/rules/mek-rules.spec.ts | 229 +++++++++++- src/app/models/rules/mek-rules.ts | 135 +++++++- src/app/models/rules/unit-type-rules.ts | 7 +- src/app/models/stealth-equipment.model.ts | 72 +++- src/app/models/turn-state.model.spec.ts | 29 +- src/app/models/turn-state.model.ts | 7 + .../equipment-interaction-registry.service.ts | 10 + .../services/unit-initializer.service.spec.ts | 54 ++- src/app/services/unit-initializer.service.ts | 31 +- src/app/testing/unit-test-helpers.spec.ts | 6 +- src/app/testing/unit-test-helpers.ts | 9 +- src/app/utils/ecm-state.util.spec.ts | 217 ++++++++++++ src/app/utils/ecm-state.util.ts | 305 ++++++++++++++-- src/app/utils/equipment-power-state.util.ts | 28 ++ ...ce-viewer-electronics-display.util.spec.ts | 5 + src/app/utils/hpg-state.util.ts | 56 +++ src/app/utils/inventory-control.util.ts | 4 +- src/app/utils/mga-state.util.spec.ts | 100 ++++++ src/app/utils/mga-state.util.ts | 144 ++++++++ src/app/utils/shield-mode.util.ts | 98 ++++++ 58 files changed, 4349 insertions(+), 129 deletions(-) create mode 100644 src/app/equipment-handlers/booby-trap.handler.spec.ts create mode 100644 src/app/equipment-handlers/booby-trap.handler.ts create mode 100644 src/app/equipment-handlers/coolant-pod.handler.spec.ts create mode 100644 src/app/equipment-handlers/coolant-pod.handler.ts create mode 100644 src/app/equipment-handlers/equipment-power.handler.spec.ts create mode 100644 src/app/equipment-handlers/equipment-power.handler.ts create mode 100644 src/app/equipment-handlers/mga-activation.handler.spec.ts create mode 100644 src/app/equipment-handlers/mga-activation.handler.ts create mode 100644 src/app/equipment-handlers/mobile-hpg.handler.spec.ts create mode 100644 src/app/equipment-handlers/mobile-hpg.handler.ts create mode 100644 src/app/equipment-handlers/prototype-laser.handler.spec.ts create mode 100644 src/app/equipment-handlers/prototype-laser.handler.ts create mode 100644 src/app/equipment-handlers/searchlight.handler.spec.ts create mode 100644 src/app/equipment-handlers/searchlight.handler.ts create mode 100644 src/app/equipment-handlers/shield-mode.handler.spec.ts create mode 100644 src/app/equipment-handlers/shield-mode.handler.ts create mode 100644 src/app/equipment-handlers/spot-welder.handler.spec.ts create mode 100644 src/app/equipment-handlers/spot-welder.handler.ts create mode 100644 src/app/utils/ecm-state.util.spec.ts create mode 100644 src/app/utils/equipment-power-state.util.ts create mode 100644 src/app/utils/hpg-state.util.ts create mode 100644 src/app/utils/mga-state.util.spec.ts create mode 100644 src/app/utils/mga-state.util.ts create mode 100644 src/app/utils/shield-mode.util.ts diff --git a/src/app/components/equipment-dialog/equipment-dialog.component.scss b/src/app/components/equipment-dialog/equipment-dialog.component.scss index 43ca668ea..ab0bc9a7c 100644 --- a/src/app/components/equipment-dialog/equipment-dialog.component.scss +++ b/src/app/components/equipment-dialog/equipment-dialog.component.scss @@ -1,5 +1,5 @@ .equipment-dialog { - max-width: 1000px; + max-width: 1100px; width: 100%; align-items: stretch; gap: 6px; diff --git a/src/app/components/equipment-dialog/weapons-equipment-panel.component.html b/src/app/components/equipment-dialog/weapons-equipment-panel.component.html index 600ccebd4..50d7f36ab 100644 --- a/src/app/components/equipment-dialog/weapons-equipment-panel.component.html +++ b/src/app/components/equipment-dialog/weapons-equipment-panel.component.html @@ -78,16 +78,18 @@

@for (row of group.rows; track row.id) { @let rowTarget = targetState(row); @let rowPresentation = rowPresentationState(row); -
+
- @if (group.sortable) { + @if (isRowSortable(group, row)) { }
- @if (isSelectable(row)) { + @if (isMachineGunArrayMemberControlled(row)) { + + } @else if (isSelectable(row)) { @if (hasTargets()) { + [disabled]="handlerChoiceDisabled(choice)" (click)="handleChoice(row, choice)">{{ choice.label }} } @default { + [disabled]="handlerChoiceDisabled(choice)" (click)="handleChoice(row, choice)">{{ choice.shortLabel || choice.label }} } } } diff --git a/src/app/components/equipment-dialog/weapons-equipment-panel.component.scss b/src/app/components/equipment-dialog/weapons-equipment-panel.component.scss index 00a8046fc..065946905 100644 --- a/src/app/components/equipment-dialog/weapons-equipment-panel.component.scss +++ b/src/app/components/equipment-dialog/weapons-equipment-panel.component.scss @@ -2,7 +2,7 @@ --weapon-equipment-title-height: 30px; --weapon-equipment-content-columns: 24px 28px fit-content(100%) 42px 42px 42px fit-content(200px) repeat(4, 42px) fit-content(420px); --weapon-equipment-columns: minmax(0, 1fr) var(--weapon-equipment-content-columns) minmax(0, 1fr); - max-width: 1000px; + max-width: 1100px; width: 100%; align-items: center; @@ -203,7 +203,7 @@ } } -.weapon-equipment-row.destroyed-entry .name-cell > span:first-child { +.weapon-equipment-row.destroyed-entry .equipment-name { text-decoration-line: line-through; } @@ -215,10 +215,24 @@ color: var(--text-color-secondary); } -.weapon-equipment-row.disabled-entry .name-cell > span:first-child { +.weapon-equipment-row.disabled-entry .equipment-name { text-decoration-line: line-through; } +.weapon-equipment-row.mga-array-row { + border-top: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bt-yellow-background) 28%, transparent); +} + +.weapon-equipment-row.mga-member-row { + border-left: 3px solid var(--border-color); + background: color-mix(in srgb, var(--background-color-light) 55%, transparent); +} + +.weapon-equipment-row.mga-member-controlled .equipment-name { + color: var(--text-color-secondary); +} + .weapon-equipment-row.cdk-drag-preview { grid-template-columns: var(--weapon-equipment-columns); width: 100%; @@ -248,6 +262,7 @@ .select-cell, .select-header { display: flex; + align-items: center; justify-content: center; } @@ -509,13 +524,47 @@ min-width: 100px; } -.name-cell > span:first-child { +.equipment-name { min-width: 0; overflow-wrap: anywhere; white-space: normal; text-align: left; } +.mga-branch { + flex: 0 0 14px; + width: 14px; + height: 24px; + margin-top: -24px; + margin-left: 14px; + border-bottom: 1px solid var(--text-color-secondary); + border-left: 1px solid var(--text-color-secondary); +} + +.weapon-equipment-row.mga-member-row + .weapon-equipment-row.mga-member-row .mga-branch { + height: 38px; + margin-top: -38px; +} + +.mga-membership-badge { + display: inline-flex; + align-items: center; + min-height: 16px; + padding: 1px 5px; + border: 1px solid var(--border-color); + background: var(--background-color-light); + color: var(--text-color-secondary); + font-size: 0.78rem; + font-weight: 500; + line-height: 1.2; + white-space: nowrap; +} + +.mga-array-summary { + border-color: color-mix(in srgb, var(--bt-yellow-strong) 60%, var(--border-color)); + color: var(--text-color); +} + .modifier { flex: 0 0 auto; color: var(--text-color-secondary); @@ -775,6 +824,24 @@ } } + .weapon-equipment-row.mga-member-row { + width: calc(100% - 20px); + margin-left: 18px; + } + + .mga-branch, + .weapon-equipment-row.mga-member-row + .weapon-equipment-row.mga-member-row .mga-branch { + flex: 0 0 14px; + width: 14px; + height: 14px; + margin-top: -14px; + margin-left: 0px; + } + + .mga-membership-badge { + font-size: 0.7rem; + } + .drag-cell { display: none; } diff --git a/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts b/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts index d65ad767a..ca3397773 100644 --- a/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts +++ b/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts @@ -41,6 +41,12 @@ import { EquipmentRegistry } from '../../models/equipment-lookup'; import { AmmoMunitionFlag } from '../../models/ammo-munition-flags.type'; import { NovaCewsHandler } from '../../equipment-handlers/nova-cews.handler'; import { NOVA_CEWS_OFF_STATE, NOVA_CEWS_STATE_KEY } from '../../utils/ecm-state.util'; +import { CoolantPodHandler } from '../../equipment-handlers/coolant-pod.handler'; +import { ShieldModeHandler } from '../../equipment-handlers/shield-mode.handler'; +import { SHIELD_INACTIVE_MODE, SHIELD_RAISED_MODE } from '../../utils/shield-mode.util'; +import { C3Handler } from '../../equipment-handlers/c3.handler'; +import { MgaActivationHandler } from '../../equipment-handlers/mga-activation.handler'; +import { MGA_ACTIVATION_STATE_KEY, MGA_OFF_STATE } from '../../utils/mga-state.util'; function weapon(id: string, ammoType: Extract = 'NA', rackSize = 0, ranges: number[] = [1, 2, 3, 4], toHitModifier = 0, heat = 0): WeaponEquipment { const flags: EquipmentFlag[] = ammoType === 'MRM' @@ -137,6 +143,7 @@ interface CreateComponentOptions { equipmentStatusesAtLocation?: ReadonlyMap>; applyUnitDisplayEffects?: (entry: MountedEquipment, display: InventoryControlDisplayData) => InventoryControlDisplayData; resolveEquipmentActionPermission?: (entry: MountedEquipment, action: EquipmentAction) => boolean; + resolveConfigureNetworkPermission?: (entry: MountedEquipment) => boolean; hasIndependentInventoryControlAction?: (entry: MountedEquipment) => boolean; } @@ -173,6 +180,7 @@ function createComponent( }; const dialogsService = { createDialog: jasmine.createSpy('createDialog').and.returnValue({ closed: { subscribe: jasmine.createSpy('subscribe') } }), + requestConfirmation: jasmine.createSpy('requestConfirmation').and.resolveTo(false), showNoticeHtml: jasmine.createSpy('showNoticeHtml').and.resolveTo(), showError: jasmine.createSpy('showError').and.resolveTo() }; @@ -203,6 +211,7 @@ function createComponent( hasDirectInventory: options.hasDirectInventory, applyInventoryControlDisplayEffects: options.applyUnitDisplayEffects, resolveEquipmentActionPermission: options.resolveEquipmentActionPermission, + resolveConfigureNetworkPermission: options.resolveConfigureNetworkPermission, hasIndependentInventoryControlAction: options.hasIndependentInventoryControlAction, }); const unit = unitHarness.unit; @@ -251,7 +260,311 @@ function createComponent( }; } +function machineGunArrayEntries(state?: string) { + const arrayType = new WeaponEquipment({ + id: 'ISMGA', + name: 'Machine Gun Array', + type: 'weapon', + flags: ['F_MGA'], + weapon: { ammoType: 'MG', rackSize: 2, damage: 2, ranges: [1, 2, 3, 4] }, + }); + const gunType = new WeaponEquipment({ + id: 'ISMachineGun', + name: 'Machine Gun', + type: 'weapon', + flags: ['F_MG'], + weapon: { ammoType: 'MG', rackSize: 2, damage: 2, ranges: [1, 2, 3, 4] }, + }); + const ammoType = new AmmoEquipment({ + id: 'ISMG Ammo', + name: 'MG Ammo', + shortName: 'MG Ammo', + type: 'ammo', + ammo: { type: 'MG', rackSize: 2, shots: 100 }, + }); + const array = entry({ + id: 'mga', + equipment: arrayType, + locations: new Set(['LT']), + states: state ? new Map([[MGA_ACTIVATION_STATE_KEY, state]]) : undefined, + }); + const members = Array.from({ length: 3 }, (_, index) => entry({ + id: `mg-${index + 1}`, + equipment: gunType, + locations: new Set(['LT']), + })); + const ammoBin = entry({ + id: 'mg-ammo', + equipment: ammoType, + locations: new Set(['LT']), + totalAmmo: 100, + consumed: 9, + }); + array.setLinkedEquipment(members); + return { + array, + members, + ammoBin, + // Deliberately flat and out of hierarchy order: presentation must regroup the bay. + entries: [members[0], array, members[1], members[2], ammoBin], + }; +} + describe('WeaponsEquipmentPanelComponent', () => { + it('renders an active MGA as one selectable controller with nested controlled guns', async () => { + const { entries } = machineGunArrayEntries(); + const { component, fixture, unit, dialogsService } = createComponent(entries, {}, [], new Map(), { + handlers: [new MgaActivationHandler()], + }); + const ranged = component.groups().find(group => group.id === 'ranged')!; + const arrayRow = ranged.rows.find(row => row.id === 'mga')!; + const renderedRows = Array.from( + fixture.nativeElement.querySelectorAll('.weapon-equipment-row'), + ) as HTMLElement[]; + + expect(ranged.rows.map(row => row.id)).toEqual(['mga', 'mg-1', 'mg-2', 'mg-3']); + expect(renderedRows[0].classList).toContain('mga-array-row'); + expect(renderedRows.slice(1).every(row => row.classList.contains('mga-member-controlled'))).toBeTrue(); + expect(renderedRows.slice(1).every(row => !!row.querySelector('.select-cell .mga-branch'))).toBeTrue(); + expect(renderedRows.slice(1).every(row => !row.querySelector('.name-cell .mga-branch'))).toBeTrue(); + expect(renderedRows.slice(1).map(row => row.querySelector('.mga-membership-badge')?.textContent?.trim())) + .toEqual(['Linked', 'Linked', 'Linked']); + expect(renderedRows.flatMap(row => Array.from(row.querySelectorAll('.select-cell input, .select-cell button')))).toHaveSize(1); + expect(fixture.nativeElement.querySelectorAll('.ammo-cell')).toHaveSize(1); + expect(fixture.nativeElement.querySelector('.mga-array-summary')?.textContent?.trim()) + .toBe('3 guns · 3 ammo/attack · Cluster +2'); + expect(arrayRow.display.damage).toBe('2/Sht [AI,DB]'); + + unit.createInventoryControlTarget(); + unit.inventoryControl.markInventoryViewChanged(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelectorAll('.weapon-equipment-row .target-selector')).toHaveSize(1); + + component.toggleSelected(arrayRow); + await component.consumeSelectedHeatAndAmmo(); + + expect(unit.getInventory().find(candidate => candidate.id === 'mg-ammo')?.consumed).toBe(12); + expect(dialogsService.showNoticeHtml).toHaveBeenCalledWith( + jasmine.stringMatching(/3 ammo from MG Ammo/), + 'Weapons Fired', + ); + }); + + it('does not advertise the Core MGA cluster bonus under Total Warfare rules', () => { + const { entries } = machineGunArrayEntries(); + const { component } = createComponent(entries, {}, [], new Map(), { + handlers: [new MgaActivationHandler()], + gameRules: TW_GAME_RULES, + }); + const arrayRow = component.groups().find(group => group.id === 'ranged')!.rows + .find(row => row.id === 'mga')!; + + expect(component.machineGunArraySummary(arrayRow)) + .toBe('3 guns · 3 ammo/attack · Cluster roll'); + }); + + it('shows the same MGA hierarchy but restores individual gun controls while the array is off', async () => { + const { entries } = machineGunArrayEntries(MGA_OFF_STATE); + const { component, fixture, unit } = createComponent(entries, {}, [], new Map(), { + handlers: [new MgaActivationHandler()], + }); + const ranged = component.groups().find(group => group.id === 'ranged')!; + const arrayRow = ranged.rows.find(row => row.id === 'mga')!; + const firstMember = ranged.rows.find(row => row.id === 'mg-1')!; + const renderedRows = Array.from( + fixture.nativeElement.querySelectorAll('.weapon-equipment-row'), + ) as HTMLElement[]; + + expect(component.isSelectable(arrayRow)).toBeFalse(); + expect(ranged.rows.slice(1).every(row => component.isSelectable(row))).toBeTrue(); + expect(renderedRows.slice(1).every(row => !row.classList.contains('mga-member-controlled'))).toBeTrue(); + expect(renderedRows.slice(1).every(row => !row.querySelector('.mga-branch'))).toBeTrue(); + expect(renderedRows.slice(1).map(row => row.querySelector('.mga-membership-badge')?.textContent?.trim())) + .toEqual(['Unlinked', 'Unlinked', 'Unlinked']); + expect(fixture.nativeElement.querySelectorAll('.weapon-equipment-row .select-cell input')).toHaveSize(3); + expect(fixture.nativeElement.querySelectorAll('.ammo-cell')).toHaveSize(3); + expect(component.handlerChoices(arrayRow)[0].label).toBe('Array unlinked'); + expect(fixture.nativeElement.querySelector('.mga-array-summary')?.textContent?.trim()) + .toBe('3 guns · Individual fire'); + + component.toggleSelected(firstMember); + await component.consumeSelectedHeatAndAmmo(); + + expect(unit.getInventory().find(candidate => candidate.id === 'mg-ammo')?.consumed).toBe(10); + }); + + it('reduces an active MGA to its working guns and consumes only their rounds', async () => { + const { entries, members } = machineGunArrayEntries(); + members[1].setCommittedDestroyed(true); + const { component, fixture, unit } = createComponent(entries, {}, [], new Map(), { + handlers: [new MgaActivationHandler()], + }); + const arrayRow = component.groups().find(group => group.id === 'ranged')!.rows + .find(row => row.id === 'mga')!; + + expect(fixture.nativeElement.querySelector('.mga-array-summary')?.textContent?.trim()) + .toBe('2/3 guns · 2 ammo/attack · Cluster +2'); + expect(arrayRow.display.damage).toBe('2/Sht [AI,DB]'); + + component.toggleSelected(arrayRow); + await component.consumeSelectedHeatAndAmmo(); + + expect(unit.getInventory().find(candidate => candidate.id === 'mg-ammo')?.consumed).toBe(11); + }); + + it('blocks an MGA attack atomically when its shared bin lacks one round per working gun', async () => { + const { entries, ammoBin } = machineGunArrayEntries(); + ammoBin.totalAmmo = 10; + ammoBin.consumed = 8; + const { component, unit, dialogsService } = createComponent(entries, {}, [], new Map(), { + handlers: [new MgaActivationHandler()], + }); + const arrayRow = component.groups().find(group => group.id === 'ranged')!.rows + .find(row => row.id === 'mga')!; + + component.toggleSelected(arrayRow); + await component.consumeSelectedHeatAndAmmo(); + + expect(unit.getInventory().find(candidate => candidate.id === 'mg-ammo')?.consumed).toBe(8); + expect(dialogsService.showError).toHaveBeenCalledWith( + 'MG Ammo (2/10) does not have enough ammo for the selected weapons.', + 'Not Enough Ammo', + ); + expect(dialogsService.showNoticeHtml).not.toHaveBeenCalled(); + }); + + for (const status of ['destroyed', 'disabled'] as const) { + it(`keeps C3 Configure clickable for an owned ${status} endpoint`, async () => { + const c3 = entry({ + id: 'c3-master', + equipment: misc('C3 Master', ['F_C3M', 'ANY_C3']), + }); + const handler = new C3Handler(); + const selection = spyOn(handler, 'handleSelection').and.resolveTo(true); + const { component, fixture } = createComponent( + [c3], + {}, + [], + new Map([[c3, status]]), + { + handlers: [handler], + resolveConfigureNetworkPermission: () => true, + }, + ); + const row = component.groups().find(group => group.id === 'equipment')!.rows[0]; + const choice = component.handlerChoices(row)[0]; + + expect(choice).toEqual(jasmine.objectContaining({ + label: 'Configure', + disabled: false, + })); + expect((fixture.nativeElement.querySelector('.control-button') as HTMLButtonElement).disabled).toBeFalse(); + + await component.handleChoice(row, choice); + + expect(selection).toHaveBeenCalledOnceWith(c3, choice, jasmine.any(Object)); + }); + } + + it('opens C3 Configure from a read-only panel without enabling other edits', async () => { + const c3 = entry({ + id: 'c3-master', + equipment: misc('C3 Master', ['F_C3M', 'ANY_C3']), + }); + const handler = new C3Handler(); + const selection = spyOn(handler, 'handleSelection').and.resolveTo(true); + const { component, fixture } = createComponent( + [c3], + {}, + [], + undefined, + { + handlers: [handler], + readOnly: true, + resolveConfigureNetworkPermission: () => true, + }, + ); + const row = component.groups().find(group => group.id === 'equipment')!.rows[0]; + const choice = component.handlerChoices(row)[0]; + + expect(component.handlerChoiceDisabled(choice)).toBeFalse(); + expect((fixture.nativeElement.querySelector('.control-button') as HTMLButtonElement).disabled).toBeFalse(); + + await component.handleChoice(row, choice); + + expect(selection).toHaveBeenCalledOnceWith(c3, choice, jasmine.any(Object)); + }); + + it('shows a Coolant Pod as Equipment with a direct use action', async () => { + const coolantPod = new AmmoEquipment({ + id: 'Coolant Pod', + name: 'Coolant Pod', + type: 'ammo', + ammo: { type: 'COOLANT_POD', shots: 1 }, + }); + const mounted = entry({ + id: coolantPod.id, + equipment: coolantPod, + totalAmmo: 1, + consumed: 0, + locations: new Set(['LA']), + }); + const { component } = createComponent( + [mounted], + { [coolantPod.internalName]: coolantPod }, + [], + undefined, + { handlers: [new CoolantPodHandler()] }, + ); + + const equipmentGroup = component.groups().find(group => group.id === 'equipment'); + expect(equipmentGroup?.rows.length).toBe(1); + const row = equipmentGroup!.rows[0]; + expect(row.display).toEqual(jasmine.objectContaining({ + name: 'Coolant Pod', + location: 'LA', + heat: '—', + })); + expect(row.tracksAmmo).toBeFalse(); + const choice = component.handlerChoices(row)[0]; + expect(choice.label).toBe('Use Coolant Pod'); + + await component.handleChoice(row, choice); + + const updatedRow = component.groups().find(group => group.id === 'equipment')!.rows[0]; + expect(component.handlerChoices(updatedRow)[0]).toEqual(jasmine.objectContaining({ + label: 'Coolant Pod Expended', + disabled: true, + })); + }); + + it('renders Core shield state as a Lowered/Raised mode selector', async () => { + const shield = entry({ + id: 'Shield (Medium)', + equipment: misc('Shield (Medium)', ['F_SHIELD', 'S_SHIELD_MEDIUM']), + locations: new Set(['LA']), + }); + const { component } = createComponent( + [shield], + {}, + [], + undefined, + { handlers: [new ShieldModeHandler()] }, + ); + let row = component.groups().find(group => group.id === 'physical')!.rows[0]; + let choice = component.modeChoice(row)!; + + expect(choice.value).toBe(SHIELD_INACTIVE_MODE); + expect(choice.choices?.map(option => option.label)).toEqual(['Lowered', 'Raised']); + expect(component.modeText(row, choice)).toBe('Lowered'); + + await component.selectHandlerDropdown(row, choice, SHIELD_RAISED_MODE); + + row = component.groups().find(group => group.id === 'physical')!.rows[0]; + choice = component.modeChoice(row)!; + expect(component.modeText(row, choice)).toBe('Raised'); + }); + it('shows base Gunnery and Piloting in section headings', () => { const laser = entry({ id: 'laser', equipment: weapon('Medium Laser') }); const charge = entry({ id: 'Charge', intrinsicPhysicalAttack: true }); @@ -573,23 +886,23 @@ describe('WeaponsEquipmentPanelComponent', () => { expect(component.rowEffectivelyDestroyed(row)).toBeTrue(); }); - it('toggles BAP state and updates the next toggle label', async () => { + it('queues BAP power changes for the End Phase', async () => { const probe = entry({ id: 'probe', equipment: misc('Bloodhound Active Probe', ['F_BAP']), el: svgEntry('Probe') }); const { component, toastService } = createComponent([probe], {}, [], undefined, { handlers: [new BAPHandler()] }); let row = component.groups().find(group => group.id === 'equipment')!.rows[0]; let choice = component.handlerChoices(row)[0]; - expect(choice.label).toBe('Active Probe is OFF'); - expect(choice.value).toBe('enabled'); + expect(choice.label).toBe('Active Probe is ON'); + expect(choice.value).toBe('disabling'); await component.handleChoice(row, choice); row = component.groups().find(group => group.id === 'equipment')!.rows[0]; choice = component.handlerChoices(row)[0]; - expect(probe.states?.get('state')).toBe('enabled'); - expect(choice.label).toBe('Active Probe is ON'); - expect(choice.value).toBe('disabled'); - expect(toastService.showToast).toHaveBeenCalledWith('Bloodhound Active Probe is enabled', 'info'); + expect(probe.states?.get('powerState')).toBe('disabling'); + expect(choice.label).toBe('Turning Active Probe off…'); + expect(choice.value).toBe('enabled'); + expect(toastService.showToast).toHaveBeenCalledWith('Bloodhound Active Probe is disabling', 'info'); }); it('splits Battle Armor trooper weapons and locks ammo to the same trooper', () => { diff --git a/src/app/components/equipment-dialog/weapons-equipment-panel.component.ts b/src/app/components/equipment-dialog/weapons-equipment-panel.component.ts index d5fad98cb..4fc21475b 100644 --- a/src/app/components/equipment-dialog/weapons-equipment-panel.component.ts +++ b/src/app/components/equipment-dialog/weapons-equipment-panel.component.ts @@ -41,6 +41,14 @@ import { STANDARD_AEROSPACE_RANGE_LIMITS, aerospaceRangeCaptions } from '../../u import { calculateHeatProjection } from '../../models/turn-state.model'; import { resolveSelectedWeaponFiringHeatSources, SELECTED_WEAPONS_HEAT_SOURCE_ID } from '../../utils/inventory-control-heat.util'; import type { UnitModifierBreakdownEntry } from '../../models/rules/unit-type-rules'; +import { + isMachineGunArray, + isMachineGunArrayEffectivelyActive, + isMachineGunArrayMember, + machineGunArrayController, + machineGunArrayMembers, + operationalMachineGunArrayMembers, +} from '../../utils/mga-state.util'; interface RangeColumn { key: InventoryRangeDisplayKey; @@ -183,7 +191,9 @@ export class WeaponsEquipmentPanelComponent { this.unit(), this.context().queryContext.equipmentCatalog, this.unit().getInventoryControlRules() - ); + ).map(group => group.id === 'ranged' + ? { ...group, rows: this.groupMachineGunArrayRows(group.rows) } + : group); }); sectionSkill(group: InventoryControlGroup): SectionSkillDisplay | null { @@ -359,7 +369,7 @@ export class WeaponsEquipmentPanelComponent { } groupTracksAmmo(group: InventoryControlGroup): boolean { - return group.rows.some(row => row.tracksAmmo); + return group.rows.some(row => this.showAmmoControls(row)); } groupHasControls(group: InventoryControlGroup): boolean { @@ -384,7 +394,7 @@ export class WeaponsEquipmentPanelComponent { } rowHasActions(row: InventoryControlRow): boolean { - return row.tracksAmmo || this.rowHasControls(row); + return this.showAmmoControls(row) || this.rowHasControls(row); } readOnly(): boolean { @@ -392,7 +402,68 @@ export class WeaponsEquipmentPanelComponent { } isSelectable(row: InventoryControlRow): boolean { - return isInventoryControlSelectableEntry(row.entry); + if (!isInventoryControlSelectableEntry(row.entry)) return false; + if (!isMachineGunArray(row.entry) && !isMachineGunArrayMember(row.entry)) return true; + return this.unit().getInventoryControlRules().isSelectable?.(row.entry) !== false; + } + + isMachineGunArrayRow(row: InventoryControlRow): boolean { + return isMachineGunArray(row.entry); + } + + isMachineGunArrayMemberRow(row: InventoryControlRow): boolean { + return isMachineGunArrayMember(row.entry); + } + + isMachineGunArrayMemberControlled(row: InventoryControlRow): boolean { + const array = machineGunArrayController(row.entry); + return !!array + && this.context().queryContext.getStatus(array) === 'available' + && isMachineGunArrayEffectivelyActive(array); + } + + machineGunArraySummary(row: InventoryControlRow): string | null { + if (!isMachineGunArray(row.entry)) return null; + const members = machineGunArrayMembers(row.entry); + const working = operationalMachineGunArrayMembers( + row.entry, + member => this.context().queryContext.getStatus(member) === 'available', + ).length; + const gunText = working === members.length + ? `${working} gun${working === 1 ? '' : 's'}` + : `${working}/${members.length} guns`; + const available = this.context().queryContext.getStatus(row.entry) === 'available'; + if (!available || !isMachineGunArrayEffectivelyActive(row.entry)) { + return `${gunText} · Individual fire`; + } + if (working === 0) return 'No working guns'; + const clusterModifier = this.unit().gameRules.machineGunArrayClusterModifier; + const clusterText = clusterModifier === 0 + ? 'Cluster roll' + : `Cluster ${clusterModifier > 0 ? '+' : ''}${clusterModifier}`; + return `${gunText} · ${working} ammo/attack · ${clusterText}`; + } + + machineGunArrayMemberSummary(row: InventoryControlRow): string | null { + if (!isMachineGunArrayMember(row.entry)) return null; + if (this.context().queryContext.getStatus(row.entry) !== 'available') return 'Excluded from array'; + return this.isMachineGunArrayMemberControlled(row) ? 'Linked' : 'Unlinked'; + } + + showAmmoControls(row: InventoryControlRow): boolean { + if (!row.tracksAmmo) return false; + if (isMachineGunArrayMember(row.entry)) return !this.isMachineGunArrayMemberControlled(row); + if (!isMachineGunArray(row.entry)) return true; + return this.context().queryContext.getStatus(row.entry) === 'available' + && isMachineGunArrayEffectivelyActive(row.entry) + && operationalMachineGunArrayMembers( + row.entry, + member => this.context().queryContext.getStatus(member) === 'available', + ).length > 0; + } + + isRowSortable(group: InventoryControlGroup, row: InventoryControlRow): boolean { + return group.sortable && !isMachineGunArrayMember(row.entry); } isSelected(row: InventoryControlRow): boolean { @@ -953,6 +1024,28 @@ export class WeaponsEquipmentPanelComponent { return this.groupSelectableRows(group).filter(row => !row.destroyed && !row.disabled); } + private groupMachineGunArrayRows(rows: InventoryControlRow[]): InventoryControlRow[] { + const rowsByEntry = new Map(rows.map(row => [row.entry, row])); + const nestedMembers = new Set(rows + .filter(row => { + const array = machineGunArrayController(row.entry); + return !!array && rowsByEntry.has(array); + }) + .map(row => row.entry)); + const groupedRows: InventoryControlRow[] = []; + + for (const row of rows) { + if (nestedMembers.has(row.entry)) continue; + groupedRows.push(row); + if (!isMachineGunArray(row.entry)) continue; + for (const member of machineGunArrayMembers(row.entry)) { + const memberRow = rowsByEntry.get(member); + if (memberRow) groupedRows.push(memberRow); + } + } + return groupedRows; + } + cacheDragPreviewCellWidths(event: PointerEvent): void { const sourceRow = event.currentTarget; if (!(sourceRow instanceof HTMLElement)) return; @@ -1112,7 +1205,7 @@ export class WeaponsEquipmentPanelComponent { } async handleChoice(row: InventoryControlRow, choice: HandlerChoice): Promise { - if (this.readOnly() || choice.disabled) return; + if (this.handlerChoiceDisabled(choice)) return; await this.context().registry.handleSelection(row.entry, choice, this.context().commandContext); this.inventoryControl().markInventoryViewChanged(); const updatedRow = this.groups().flatMap(group => group.rows).find(candidate => candidate.id === row.id); @@ -1121,9 +1214,16 @@ export class WeaponsEquipmentPanelComponent { } } + handlerChoiceDisabled(choice: HandlerChoice): boolean { + return !!choice.disabled + || (this.readOnly() && choice.action !== 'configure-network'); + } + private getHandlerChoices(row: InventoryControlRow): HandlerChoice[] { - if (this.rowEffectivelyDestroyed(row)) return []; - return this.context().registry.getChoices(row.entry, this.context().queryContext); + const choices = this.context().registry.getChoices(row.entry, this.context().queryContext); + return this.rowEffectivelyDestroyed(row) + ? choices.filter(choice => choice.action === 'configure-network') + : choices; } private isModeChoice(choice: HandlerChoice): boolean { 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 d4f9d7e82..19abeb257 100644 --- a/src/app/components/page-viewer/svg-interaction.service.spec.ts +++ b/src/app/components/page-viewer/svg-interaction.service.spec.ts @@ -15,9 +15,10 @@ import { LayoutService } from '../../services/layout.service'; import { OptionsService } from '../../services/options.service'; import { PickerFactoryService } from '../../services/picker-factory.service'; import { ToastService } from '../../services/toast.service'; -import { MiscEquipment, WeaponEquipment, type Equipment } from '../../models/equipment.model'; +import { AmmoEquipment, MiscEquipment, WeaponEquipment, type Equipment } from '../../models/equipment.model'; import { EquipmentDialogComponent } from '../equipment-dialog/equipment-dialog.component'; -import { MountedEquipment } from '../../models/mounted-equipment.model'; +import { MountedAmmo, MountedEquipment } from '../../models/mounted-equipment.model'; +import { COOLANT_POD_ACTIVE_STATE_KEY } from '../../equipment-handlers/coolant-pod.handler'; import { InventoryControlRuntimeState, type InventoryControlRuntimeRangeKey } from '../../models/inventory-control-runtime-state.model'; import { INVENTORY_CONTROL_MODE_STATE } from '../../utils/inventory-control.util'; import { RISC_LASER_PULSE_MODE, RISC_LASER_STANDARD_MODE } from '../../equipment-handlers/risc-laser-pulse-module.handler'; @@ -1196,6 +1197,81 @@ describe('SvgInteractionService', () => { expect(registryHandleSelection).toHaveBeenCalledWith(entry, handlerChoice, jasmine.any(Object)); }); + it('keeps Coolant Pod ammo corrections beside its direct use action', async () => { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.innerHTML = 'Ammo (Coolant Pod) 1'; + const equipment = new AmmoEquipment({ + id: 'Coolant Pod', + name: 'Coolant Pod', + type: 'ammo', + ammo: { type: 'COOLANT_POD', shots: 1 }, + }); + const critSlot = { + id: 'Coolant Pod@LA#9', + name: 'Coolant Pod', + loc: 'LA', + slot: 9, + totalAmmo: 1, + consumed: 0, + eq: equipment, + }; + let entry!: MountedAmmo; + const setInventoryEntry = jasmine.createSpy('setInventoryEntry'); + const unit = createSvgInteractionUnit({ + id: 'unit-coolant-pod', + getUnit: () => ({ type: 'Mek' }), + getInventory: () => [entry], + getCritSlots: () => [critSlot], + getCritSlot: (loc: string, slot: number) => loc === 'LA' && slot === 9 ? critSlot : null, + setCritSlot: jasmine.createSpy('setCritSlot'), + setInventoryEntry, + isInternalLocPhysicallyDestroyed: () => false, + getEquipmentStatus: () => 'available' as const, + isEquipmentOperational: () => true, + applyHitToCritSlot: jasmine.createSpy('applyHitToCritSlot'), + }); + entry = new MountedAmmo({ + owner: unit as any, + id: critSlot.id, + name: equipment.name, + equipment, + locations: new Set(['LA']), + critSlots: [critSlot], + totalAmmo: 1, + originalTotalAmmo: 1, + consumed: 0, + }); + const useChoice = { + label: 'Use Coolant Pod', + value: 'use', + displayType: 'toggle' as const, + _handler: {} as any, + }; + registryGetChoices.and.returnValue([useChoice]); + service.updateUnit(unit); + service.setupInteractions(svg); + + tap(svg.querySelector('.critSlot') as SVGElement, 33); + + const pickerConfig = pickerFactory.createChoicePicker.calls.mostRecent().args[0]; + expect(pickerConfig.values.map((choice: { label: string }) => choice.label)).toEqual(jasmine.arrayContaining([ + '-1', '+1', 'Use Coolant Pod', 'Set Ammo', + ])); + + const decrement = pickerConfig.values.find((choice: { value: unknown }) => choice.value === '-1'); + await pickerConfig.onPick({ ...decrement, keepOpen: false }); + expect(critSlot.consumed).toBe(1); + expect(entry.consumed).toBe(1); + + entry.setState(COOLANT_POD_ACTIVE_STATE_KEY, 'true'); + const increment = pickerConfig.values.find((choice: { value: unknown }) => choice.value === '+1'); + await pickerConfig.onPick({ ...increment, keepOpen: false }); + expect(critSlot.consumed).toBe(0); + expect(entry.consumed).toBe(0); + expect(entry.states.has(COOLANT_POD_ACTIVE_STATE_KEY)).toBeFalse(); + expect(setInventoryEntry).toHaveBeenCalled(); + }); + it('offers the second critical hit for a damaged one-slot Core AC/2', async () => { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.innerHTML = 'AC/2'; diff --git a/src/app/components/page-viewer/svg-interaction.service.ts b/src/app/components/page-viewer/svg-interaction.service.ts index bc8fa2a8d..9e3b496d1 100644 --- a/src/app/components/page-viewer/svg-interaction.service.ts +++ b/src/app/components/page-viewer/svg-interaction.service.ts @@ -19,7 +19,7 @@ import { type ChoicePickerInstance, isChoicePickerInstance, type NumericPickerIn import { ToastService } from '../../services/toast.service'; import { LayoutService } from '../../services/layout.service'; import { DataService } from '../../services/data.service'; -import { AmmoEquipment } from '../../models/equipment.model'; +import { AmmoEquipment, isCoolantPodEquipment } from '../../models/equipment.model'; import { createHandlerCommandContext, createHandlerQueryContext, EquipmentInteractionRegistryService } from '../../services/equipment-interaction-registry.service'; import type { HandlerChoice } from '../../services/equipment-interaction-registry.service'; import { ForceBuilderService } from '../../services/force-builder.service'; @@ -43,6 +43,7 @@ import { ClusterTableDialogComponent } from '../cluster-table-dialog/cluster-tab import { hasUnitDefaultReferenceTables } from '../../utils/reference-table-definition'; import { clusterTableForUnit } from '../../utils/record-sheet-reference-table'; import { isCenterPanelTarget, isPointInCenterPanel, resolveCenterPanelCursorElements } from '../../utils/record-sheet-center-panel.util'; +import { COOLANT_POD_ACTIVE_STATE_KEY } from '../../equipment-handlers/coolant-pod.handler'; import { canApplyMekCriticalHitToSlot } from '../../utils/mek-critical-hit.util'; import { uidTranslations } from '../../models/common.model'; import type { MekRules } from '../../models/rules/mek-rules'; @@ -1191,6 +1192,7 @@ export class SvgInteractionService { if (critSlot.consumed <= 0) return; critSlot.consumed--; unit.setCritSlot(critSlot); + this.syncInventoryAmmoStateForCritSlot(unit, critSlot); showAmmoToast(critSlot, 1); } else if (choice.value == '-1') { if (!unit.isEquipmentOperational(critSlot)) return; @@ -1200,10 +1202,12 @@ export class SvgInteractionService { if (critSlot.consumed >= totalAmmo) return; critSlot.consumed++; unit.setCritSlot(critSlot); + this.syncInventoryAmmoStateForCritSlot(unit, critSlot); showAmmoToast(critSlot, -1); } else if (choice.value == 'Empty') { critSlot.consumed = totalAmmo; unit.setCritSlot(critSlot); + this.syncInventoryAmmoStateForCritSlot(unit, critSlot); this.toastService.showToast(`Emptied ${labelText}`, 'info'); } else if (choice.value == 'Set Ammo') { if (!unit.isEquipmentOperational(critSlot)) return; @@ -1259,6 +1263,24 @@ export class SvgInteractionService { return unit.getInventory().find(entry => entry.critSlots?.some(entryCritSlot => this.sameCritSlot(entryCritSlot, critSlot))) ?? null; } + private syncInventoryAmmoStateForCritSlot(unit: CBTForceUnit, critSlot: CriticalSlot): void { + const entry = this.inventoryEntryForCritSlot(unit, critSlot); + if (!entry?.critSlots) return; + const currentSlots = entry.critSlots.map(slot => + slot.loc !== undefined && slot.slot !== undefined + ? unit.getCritSlot(slot.loc, slot.slot) ?? slot + : slot); + entry.critSlots = currentSlots; + entry.setAmmoState({ + consumed: currentSlots.reduce((total, slot) => total + (slot.consumed ?? 0), 0), + }); + if (isCoolantPodEquipment(entry.equipment) + && (entry.consumed ?? 0) < (entry.originalTotalAmmo ?? entry.totalAmmo ?? 1)) { + entry.deleteState(COOLANT_POD_ACTIVE_STATE_KEY); + } + unit.setInventoryEntry(entry); + } + private sameCritSlot(left: CriticalSlot, right: CriticalSlot): boolean { if (left.loc && right.loc && left.slot !== undefined && right.slot !== undefined) { return left.loc === right.loc && left.slot === right.slot; diff --git a/src/app/equipment-handlers/bap.handler.ts b/src/app/equipment-handlers/bap.handler.ts index f52265fbe..4dedd3eb2 100644 --- a/src/app/equipment-handlers/bap.handler.ts +++ b/src/app/equipment-handlers/bap.handler.ts @@ -3,7 +3,23 @@ // Author: Drake import { EquipmentFlag } from '../models/equipment-flags.type'; +import type { PickerChoice } from '../components/picker/picker.interface'; import type { MountedEquipment } from '../models/mounted-equipment.model'; +import type { HandlerCommandContext } from '../services/equipment-interaction-registry.service'; +import { + EQUIPMENT_POWER_OFF_STATE, + EQUIPMENT_POWER_ON_STATE, + EQUIPMENT_POWER_STATE_KEY, + EQUIPMENT_POWER_TURNING_OFF_STATE, + EQUIPMENT_POWER_TURNING_ON_STATE, + equipmentPowerState, +} from '../utils/equipment-power-state.util'; +import { + cancelConflictingElectronicSuiteActivations, + deactivateConflictingElectronicSuites, + isActiveProbeEffectivelyActive, + nextEffectiveProbePowerState, +} from '../utils/ecm-state.util'; import { ToggleHandler } from './base/toggle.handler'; export class BAPHandler extends ToggleHandler { @@ -12,10 +28,52 @@ export class BAPHandler extends ToggleHandler { override readonly priority = 10; override applicableTo(equipment: MountedEquipment): boolean { - // Nova CEWS powers its probe together with its ECM and C3 functions. - return equipment.equipment?.flags.has('F_NOVA') !== true; + // Every combined ECM/probe system has one shared mode control. + return equipment.equipment?.flags.has('F_ECM') !== true + && equipment.equipment?.flags.has('F_NOVA') !== true; } - + + protected override readonly stateKey = EQUIPMENT_POWER_STATE_KEY; + protected override readonly toggleMode = 'transient' as const; + protected override readonly enabledState = EQUIPMENT_POWER_ON_STATE; + protected override readonly enablingState = EQUIPMENT_POWER_TURNING_ON_STATE; + protected override readonly disabledState = EQUIPMENT_POWER_OFF_STATE; + protected override readonly disablingState = EQUIPMENT_POWER_TURNING_OFF_STATE; + protected override readonly defaultEnabled = true; protected override readonly enabledLabel = 'Active Probe is ON'; + protected override readonly enablingLabel = 'Turning Active Probe on…'; protected override readonly disabledLabel = 'Active Probe is OFF'; + protected override readonly disablingLabel = 'Turning Active Probe off…'; + + protected override getToggleState(equipment: MountedEquipment): string { + return nextEffectiveProbePowerState(equipment); + } + + isActive(equipment: MountedEquipment): boolean { + return isActiveProbeEffectivelyActive(equipment); + } + + override handleSelection( + equipment: MountedEquipment, + choice: PickerChoice, + context: HandlerCommandContext, + ): boolean { + const handled = super.handleSelection(equipment, choice, context); + const selectedState = String(choice.value); + const activated = (selectedState === EQUIPMENT_POWER_TURNING_ON_STATE + || selectedState === EQUIPMENT_POWER_ON_STATE) + && equipmentPowerState(equipment) === selectedState; + if (activated && cancelConflictingElectronicSuiteActivations(equipment)) { + equipment.owner.turnState().markEquipmentStateChanged(); + } + return handled; + } + + override onEndTurn(equipment: MountedEquipment): void { + const activating = equipmentPowerState(equipment) === EQUIPMENT_POWER_TURNING_ON_STATE; + super.onEndTurn(equipment); + if (activating && equipmentPowerState(equipment) === EQUIPMENT_POWER_ON_STATE) { + deactivateConflictingElectronicSuites(equipment); + } + } } diff --git a/src/app/equipment-handlers/booby-trap.handler.spec.ts b/src/app/equipment-handlers/booby-trap.handler.spec.ts new file mode 100644 index 000000000..9357aea88 --- /dev/null +++ b/src/app/equipment-handlers/booby-trap.handler.spec.ts @@ -0,0 +1,77 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { MiscEquipment } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; +import { MountedEquipment } from '../models/mounted-equipment.model'; +import type { DialogsService } from '../services/dialogs.service'; +import { createHandlerCommandContext } from '../services/equipment-interaction-registry.service'; +import type { ToastService } from '../services/toast.service'; +import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; +import { BOOBY_TRAP_DETONATED_STATE_KEY, BoobyTrapHandler } from './booby-trap.handler'; + +describe('BoobyTrapHandler', () => { + const handler = new BoobyTrapHandler(); + + function fixture(confirmed: boolean) { + const { owner } = createTestEquipmentOwner(); + const setDestroyed = jasmine.createSpy('setDestroyed'); + const setModified = jasmine.createSpy('setModified'); + Object.assign(owner, { + getDisplayName: () => 'Test Mek', + setDestroyed, + setModified, + }); + const equipment = new MiscEquipment({ + id: 'ISBoobyTrap', + name: 'Booby Trap', + type: 'misc', + flags: ['F_BOOBY_TRAP'], + }); + const mounted = new MountedEquipment({ + owner, + id: equipment.id, + name: equipment.name, + equipment, + }); + owner.setInventoryEntry(mounted); + const dialogs = jasmine.createSpyObj( + 'DialogsService', + ['createDialog', 'requestConfirmation', 'showNoticeHtml'], + ); + dialogs.requestConfirmation.and.resolveTo(confirmed); + dialogs.showNoticeHtml.and.resolveTo(); + const context = createHandlerCommandContext( + EMPTY_EQUIPMENT_REGISTRY, + jasmine.createSpyObj('ToastService', ['showToast']), + dialogs, + ); + return { mounted, dialogs, context, setDestroyed, setModified }; + } + + it('does nothing when detonation is cancelled', async () => { + const test = fixture(false); + + await handler.handleSelection(test.mounted, { label: 'Detonate', value: 'detonate' }, test.context); + + expect(test.mounted.consumed).toBeUndefined(); + expect(test.mounted.states.has(BOOBY_TRAP_DETONATED_STATE_KEY)).toBeFalse(); + expect(test.setDestroyed).not.toHaveBeenCalled(); + expect(test.dialogs.showNoticeHtml).not.toHaveBeenCalled(); + }); + + it('consumes the trap and destroys the carrying unit after confirmation', async () => { + const test = fixture(true); + + await handler.handleSelection(test.mounted, { label: 'Detonate', value: 'detonate' }, test.context); + + expect(test.mounted.consumed).toBe(1); + expect(test.mounted.states.get(BOOBY_TRAP_DETONATED_STATE_KEY)).toBe('true'); + expect(test.setDestroyed).toHaveBeenCalledOnceWith(true); + expect(test.setModified).toHaveBeenCalled(); + expect(test.dialogs.showNoticeHtml).toHaveBeenCalled(); + expect(handler.getChoices(test.mounted, {} as never)[0]) + .toEqual(jasmine.objectContaining({ label: 'Booby Trap Detonated', disabled: true })); + }); +}); diff --git a/src/app/equipment-handlers/booby-trap.handler.ts b/src/app/equipment-handlers/booby-trap.handler.ts new file mode 100644 index 000000000..3ddf345bf --- /dev/null +++ b/src/app/equipment-handlers/booby-trap.handler.ts @@ -0,0 +1,65 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { PickerChoice } from '../components/picker/picker.interface'; +import type { EquipmentFlag } from '../models/equipment-flags.type'; +import type { MountedEquipment } from '../models/mounted-equipment.model'; +import { + EquipmentInteractionHandler, + type HandlerCommandContext, + type HandlerQueryContext, +} from '../services/equipment-interaction-registry.service'; + +export const BOOBY_TRAP_DETONATED_STATE_KEY = 'boobyTrapDetonated'; + +/** One-shot self-destruction control. Blast damage still needs a battlefield map to resolve. */ +export class BoobyTrapHandler extends EquipmentInteractionHandler { + readonly id = 'booby-trap-handler'; + override readonly flags: EquipmentFlag[] = ['F_BOOBY_TRAP']; + + override getChoices(equipment: MountedEquipment, _context: HandlerQueryContext): PickerChoice[] { + const detonated = this.isDetonated(equipment); + return [{ + label: detonated ? 'Booby Trap Detonated' : 'Detonate Booby Trap', + value: 'detonate', + active: detonated, + disabled: detonated, + displayType: 'toggle', + }]; + } + + override async handleSelection( + equipment: MountedEquipment, + choice: PickerChoice, + context: HandlerCommandContext, + ): Promise { + if (choice.value !== 'detonate' || this.isDetonated(equipment)) return true; + + const confirmed = await context.dialogsService.requestConfirmation( + `Detonate ${equipment.owner.getDisplayName()}'s Booby Trap? ` + + 'The unit will be completely destroyed. Ejection and blast damage must be resolved on the battlefield.', + 'Detonate Booby Trap', + 'danger', + ); + if (!confirmed) return true; + + equipment.setAmmoState({ consumed: 1 }); + equipment.setState(BOOBY_TRAP_DETONATED_STATE_KEY, 'true'); + equipment.owner.setInventoryEntry(equipment); + equipment.owner.setDestroyed(true); + equipment.owner.setModified(); + + await context.dialogsService.showNoticeHtml( + '

The unit has been destroyed.

' + + '

Resolve the Booby Trap blast, any +4 ejection modifier, and resulting fire manually on the battlefield.

', + 'Booby Trap Detonated', + ); + return true; + } + + private isDetonated(equipment: MountedEquipment): boolean { + return equipment.states.get(BOOBY_TRAP_DETONATED_STATE_KEY) === 'true' + || (equipment.consumed ?? 0) > 0; + } +} diff --git a/src/app/equipment-handlers/coolant-pod.handler.spec.ts b/src/app/equipment-handlers/coolant-pod.handler.spec.ts new file mode 100644 index 000000000..ec01e9dd4 --- /dev/null +++ b/src/app/equipment-handlers/coolant-pod.handler.spec.ts @@ -0,0 +1,154 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { AmmoEquipment } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; +import type { CriticalSlot } from '../models/force-serialization'; +import { MountedAmmo } from '../models/mounted-equipment.model'; +import type { HeatDissipationState } from '../models/rules/heat-management'; +import type { DialogsService } from '../services/dialogs.service'; +import { createHandlerCommandContext, createHandlerQueryContext } from '../services/equipment-interaction-registry.service'; +import type { ToastService } from '../services/toast.service'; +import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; +import { COOLANT_POD_ACTIVE_STATE_KEY, CoolantPodHandler } from './coolant-pod.handler'; + +describe('CoolantPodHandler', () => { + const handler = new CoolantPodHandler(); + const queryContext = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); + const commandContext = createHandlerCommandContext( + EMPTY_EQUIPMENT_REGISTRY, + jasmine.createSpyObj('ToastService', ['showToast']), + jasmine.createSpyObj('DialogsService', ['createDialog']), + ); + + function fixture() { + const equipment = new AmmoEquipment({ + id: 'CoolantPod', + name: 'Coolant Pod', + type: 'ammo', + ammo: { type: 'COOLANT_POD', shots: 1 }, + }); + const slot: CriticalSlot = { + id: 'Coolant Pod@LA#9', + name: 'Coolant Pod', + loc: 'LA', + slot: 9, + totalAmmo: 1, + consumed: 0, + eq: equipment, + }; + const ownerFixture = createTestEquipmentOwner({ criticalSlots: [slot] }); + const markEquipmentStateChanged = jasmine.createSpy('markEquipmentStateChanged'); + Object.assign(ownerFixture.owner, { + getCritSlot: (loc: string, index: number) => ownerFixture.criticalSlots + .find(candidate => candidate.loc === loc && candidate.slot === index) ?? null, + turnState: () => ({ markEquipmentStateChanged }), + }); + const mounted = new MountedAmmo({ + owner: ownerFixture.owner, + id: slot.id, + name: slot.name!, + equipment, + locations: new Set(['LA']), + critSlots: [slot], + totalAmmo: 1, + originalTotalAmmo: 1, + consumed: 0, + }); + ownerFixture.inventory.push(mounted); + return { ...ownerFixture, mounted, slot, markEquipmentStateChanged }; + } + + it('uses the pod directly, consumes its ammo slot, and adds one full bank of cooling', () => { + const { mounted, criticalSlots, markEquipmentStateChanged } = fixture(); + expect(handler.getChoices(mounted, queryContext)[0]).toEqual(jasmine.objectContaining({ + label: 'Use Coolant Pod', + value: 'use', + disabled: false, + })); + + handler.handleSelection(mounted, handler.getChoices(mounted, queryContext)[0], commandContext); + + expect(mounted.consumed).toBe(1); + expect(criticalSlots[0].consumed).toBe(1); + expect(mounted.states.get(COOLANT_POD_ACTIVE_STATE_KEY)).toBe('true'); + expect(markEquipmentStateChanged).toHaveBeenCalled(); + const dissipation: HeatDissipationState = { + totalPips: 12, + healthyPips: 10, + damagedCount: 2, + heatsinksOff: 3, + totalDissipation: 7, + }; + expect(handler.getHeatDissipationBonus(mounted, dissipation, queryContext)).toBe(7); + expect(handler.getChoices(mounted, queryContext)[0]).toEqual(jasmine.objectContaining({ + label: 'Coolant Pod Expended', + disabled: true, + })); + }); + + it('uses the live critical-slot count so ammo correction controls can undo a mistake', () => { + const { mounted, criticalSlots } = fixture(); + handler.handleSelection(mounted, handler.getChoices(mounted, queryContext)[0], commandContext); + criticalSlots[0] = { ...criticalSlots[0], consumed: 0 }; + + expect(handler.getChoices(mounted, queryContext)[0]).toEqual(jasmine.objectContaining({ + label: 'Use Coolant Pod', + disabled: false, + })); + }); + + it('clears the temporary cooling effect without refunding the pod', () => { + const { mounted, criticalSlots } = fixture(); + handler.handleSelection(mounted, handler.getChoices(mounted, queryContext)[0], commandContext); + + handler.onEndTurn(mounted); + + expect(mounted.states.has(COOLANT_POD_ACTIVE_STATE_KEY)).toBeFalse(); + expect(mounted.consumed).toBe(1); + expect(criticalSlots[0].consumed).toBe(1); + }); + + it('prevents a second pod from being used in the same turn', () => { + const test = fixture(); + const secondSlot: CriticalSlot = { + ...test.slot, + id: 'Coolant Pod@RA#9', + loc: 'RA', + }; + test.criticalSlots.push(secondSlot); + const second = new MountedAmmo({ + owner: test.owner, + id: secondSlot.id, + name: secondSlot.name!, + equipment: test.mounted.equipment, + locations: new Set(['RA']), + critSlots: [secondSlot], + totalAmmo: 1, + originalTotalAmmo: 1, + consumed: 0, + }); + test.inventory.push(second); + + handler.handleSelection(test.mounted, handler.getChoices(test.mounted, queryContext)[0], commandContext); + expect(handler.getChoices(second, queryContext)[0].disabled).toBeTrue(); + handler.handleSelection(second, { label: 'Use Coolant Pod', value: 'use' }, commandContext); + + expect(second.consumed).toBe(0); + expect(second.states.has(COOLANT_POD_ACTIVE_STATE_KEY)).toBeFalse(); + expect(secondSlot.consumed).toBe(0); + }); + + it('does not reuse the active pod when malformed ammo data gives it extra capacity', () => { + const { mounted } = fixture(); + Object.defineProperty(mounted, 'originalTotalAmmo', { value: 2 }); + mounted.totalAmmo = 2; + + handler.handleSelection(mounted, handler.getChoices(mounted, queryContext)[0], commandContext); + + expect(handler.getChoices(mounted, queryContext)[0].disabled).toBeTrue(); + handler.handleSelection(mounted, { label: 'Use Coolant Pod', value: 'use' }, commandContext); + expect(mounted.consumed).toBe(1); + }); +}); diff --git a/src/app/equipment-handlers/coolant-pod.handler.ts b/src/app/equipment-handlers/coolant-pod.handler.ts new file mode 100644 index 000000000..187e22b01 --- /dev/null +++ b/src/app/equipment-handlers/coolant-pod.handler.ts @@ -0,0 +1,132 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { PickerChoice } from '../components/picker/picker.interface'; +import { isCoolantPodEquipment } from '../models/equipment.model'; +import { MountedAmmo, type MountedEquipment } from '../models/mounted-equipment.model'; +import type { HeatDissipationState } from '../models/rules/heat-management'; +import { + EquipmentInteractionHandler, + type HandlerCommandContext, + type HandlerQueryContext, +} from '../services/equipment-interaction-registry.service'; +import { RadicalHeatSinkHandler } from './radical-heat-sink.handler'; + +export const COOLANT_POD_ACTIVE_STATE_KEY = 'coolantPodActive'; + +export class CoolantPodHandler extends EquipmentInteractionHandler { + readonly id = 'coolant-pod-handler'; + + override applicableTo(equipment: MountedEquipment): boolean { + return isCoolantPodEquipment(equipment.equipment); + } + + override getChoices(equipment: MountedEquipment, _context: HandlerQueryContext): PickerChoice[] { + const expended = this.remaining(equipment) === 0; + const podAlreadyUsedThisTurn = equipment.owner.getInventory().some(entry => + this.isActive(entry)); + return [{ + label: expended ? 'Coolant Pod Expended' : 'Use Coolant Pod', + value: 'use', + active: this.isActive(equipment), + disabled: expended || podAlreadyUsedThisTurn, + displayType: 'toggle', + }]; + } + + override handleSelection( + equipment: MountedEquipment, + choice: PickerChoice, + context: HandlerCommandContext, + ): boolean { + if (choice.value !== 'use' || this.remaining(equipment) === 0) return true; + if (equipment.owner.getInventory().some(entry => + this.isActive(entry))) { + context.toastService.showToast('Only one Coolant Pod may be used per turn', 'error'); + return true; + } + + const consumed = this.consumed(equipment) + 1; + equipment.setAmmoState({ consumed }); + this.syncCriticalSlotConsumption(equipment, consumed); + equipment.setState(COOLANT_POD_ACTIVE_STATE_KEY, 'true'); + equipment.owner.setInventoryEntry(equipment); + equipment.owner.turnState().markEquipmentStateChanged(); + context.toastService.showToast( + this.hasActiveRadicalHeatSink(equipment) + ? 'Coolant Pod triggered, but the active Radical Heat Sink prevents its effect' + : 'Coolant Pod triggered', + this.hasActiveRadicalHeatSink(equipment) ? 'error' : 'info', + ); + return true; + } + + override getHeatDissipationBonus( + equipment: MountedEquipment, + dissipation: HeatDissipationState, + context: HandlerQueryContext, + ): number { + if (!this.isActive(equipment) + || context.getStatus(equipment) !== 'available' + || this.hasActiveRadicalHeatSink(equipment)) return 0; + return Math.max(0, dissipation.healthyPips - dissipation.heatsinksOff); + } + + override onEndTurn(equipment: MountedEquipment): void { + if (equipment.deleteState(COOLANT_POD_ACTIVE_STATE_KEY)) { + equipment.owner.setInventoryEntry(equipment); + } + } + + private remaining(equipment: MountedEquipment): number { + const capacity = equipment.originalTotalAmmo + ?? equipment.totalAmmo + ?? (equipment instanceof MountedAmmo ? equipment.getMaxShots() : 1); + return Math.max(0, capacity - this.consumed(equipment)); + } + + private consumed(equipment: MountedEquipment): number { + const mountedSlots = equipment.critSlots ?? []; + if (mountedSlots.length === 0) return equipment.consumed ?? 0; + return mountedSlots.reduce((total, mountedSlot) => { + const current = mountedSlot.loc !== undefined && mountedSlot.slot !== undefined + ? equipment.owner.getCritSlot(mountedSlot.loc, mountedSlot.slot) + : null; + return total + (current?.consumed ?? mountedSlot.consumed ?? 0); + }, 0); + } + + private isActive(equipment: MountedEquipment): boolean { + return equipment.states.get(COOLANT_POD_ACTIVE_STATE_KEY) === 'true' + && this.consumed(equipment) > 0; + } + + private syncCriticalSlotConsumption(equipment: MountedEquipment, consumed: number): void { + const mountedSlots = equipment.critSlots ?? []; + if (mountedSlots.length === 0) return; + const positions = new Set(mountedSlots + .filter(slot => slot.loc !== undefined && slot.slot !== undefined) + .map(slot => `${slot.loc}:${slot.slot}`)); + if (positions.size === 0) return; + + let remaining = consumed; + const critSlots = equipment.owner.getCritSlots().map(slot => { + if (!positions.has(`${slot.loc}:${slot.slot}`)) return slot; + const capacity = slot.totalAmmo + || Number(slot.el?.getAttribute('totalAmmo') ?? 0) + || 1; + const slotConsumed = Math.min(capacity, remaining); + remaining -= slotConsumed; + return { ...slot, consumed: slotConsumed }; + }); + equipment.owner.setCritSlots(critSlots); + } + + private hasActiveRadicalHeatSink(equipment: MountedEquipment): boolean { + return equipment.owner.getInventory().some(entry => + entry.equipment?.hasFlag('F_RADICAL_HEATSINK') === true + && entry.owner.isEquipmentOperational(entry) + && RadicalHeatSinkHandler.isActive(entry)); + } +} diff --git a/src/app/equipment-handlers/ecm.handler.ts b/src/app/equipment-handlers/ecm.handler.ts index 8c093ce8c..e958c10ec 100644 --- a/src/app/equipment-handlers/ecm.handler.ts +++ b/src/app/equipment-handlers/ecm.handler.ts @@ -4,12 +4,20 @@ import { EquipmentInteractionHandler, type HandlerCommandContext, type HandlerQueryContext } from '../services/equipment-interaction-registry.service'; import type { MountedEquipment } from '../models/mounted-equipment.model'; -import type { PickerChoice, PickerValue } from '../components/picker/picker.interface'; +import type { PickerChoice } from '../components/picker/picker.interface'; import { ECMMode } from '../models/common.model'; import { EquipmentFlag } from '../models/equipment-flags.type'; import type { CBTForceUnit } from '../models/cbt-force-unit.model'; import { unitHasActiveC3DisruptingStealth } from '../models/stealth-equipment.model'; -import { ECM_MODE_STATE_KEY, isEcmModeActive } from '../utils/ecm-state.util'; +import { + cancelConflictingElectronicSuiteActivations, + deactivateConflictingElectronicSuites, + ECM_MODE_STATE_KEY, + ECM_PENDING_MODE_STATE_KEY, + getEffectiveEcmMode, + getNextEffectiveEcmMode, + isEcmModeActive, +} from '../utils/ecm-state.util'; export { ECM_MODE_STATE_KEY } from '../utils/ecm-state.util'; @@ -23,10 +31,6 @@ export class ECMHandler extends EquipmentInteractionHandler { return equipment.equipment?.flags.has('F_NOVA') !== true; } - private getDefaultMode(): string { - return ECMMode.ECM; - } - private getModes(equipment: MountedEquipment) { const modes = [ { value: ECMMode.ECM, label: 'ECM' }, @@ -54,7 +58,7 @@ export class ECMHandler extends EquipmentInteractionHandler { } getChoices(equipment: MountedEquipment, _context: HandlerQueryContext): PickerChoice[] { - const currentState = equipment.states?.get(ECM_MODE_STATE_KEY) || this.getDefaultMode(); + const currentState = getNextEffectiveEcmMode(equipment); const modes = this.getModes(equipment); return [ @@ -69,9 +73,21 @@ export class ECMHandler extends EquipmentInteractionHandler { } handleSelection(equipment: MountedEquipment, choice: PickerChoice, context: HandlerCommandContext): boolean { - if (equipment.setState(ECM_MODE_STATE_KEY, String(choice.value))) { + const selectedMode = String(choice.value); + if (!this.getModes(equipment).some(mode => mode.value === selectedMode)) return true; + + const effectiveMode = getEffectiveEcmMode(equipment); + const changed = selectedMode === effectiveMode + ? equipment.deleteState(ECM_PENDING_MODE_STATE_KEY) + : equipment.setState(ECM_PENDING_MODE_STATE_KEY, selectedMode); + if (changed) { equipment.owner.setInventoryEntry(equipment); } + const canceledConflict = selectedMode !== ECMMode.OFF + && cancelConflictingElectronicSuiteActivations(equipment); + if (changed || canceledConflict) { + equipment.owner.turnState().markEquipmentStateChanged(); + } context.toastService.showToast( `${equipment.getDisplayName()} mode: ${choice.label}`, 'info' @@ -83,4 +99,14 @@ export class ECMHandler extends EquipmentInteractionHandler { if (unitHasActiveC3DisruptingStealth(equipment.owner as CBTForceUnit)) return false; return isEcmModeActive(equipment); } + + override onEndTurn(equipment: MountedEquipment): void { + const pendingMode = equipment.states.get(ECM_PENDING_MODE_STATE_KEY); + if (pendingMode === undefined) return; + const activating = pendingMode !== ECMMode.OFF; + const changed = equipment.setState(ECM_MODE_STATE_KEY, pendingMode); + const cleared = equipment.deleteState(ECM_PENDING_MODE_STATE_KEY); + if (changed || cleared) equipment.owner.setInventoryEntry(equipment); + if (activating) deactivateConflictingElectronicSuites(equipment); + } } diff --git a/src/app/equipment-handlers/equipment-power.handler.spec.ts b/src/app/equipment-handlers/equipment-power.handler.spec.ts new file mode 100644 index 000000000..cb7484be7 --- /dev/null +++ b/src/app/equipment-handlers/equipment-power.handler.spec.ts @@ -0,0 +1,95 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { PickerChoice } from '../components/picker/picker.interface'; +import type { EquipmentFlag } from '../models/equipment-flags.type'; +import { MiscEquipment } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; +import { MountedEquipment } from '../models/mounted-equipment.model'; +import type { DialogsService } from '../services/dialogs.service'; +import { + createHandlerCommandContext, + createHandlerQueryContext, + EquipmentInteractionRegistry, +} from '../services/equipment-interaction-registry.service'; +import type { ToastService } from '../services/toast.service'; +import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; +import { + EQUIPMENT_POWER_OFF_STATE, + EQUIPMENT_POWER_STATE_KEY, + EQUIPMENT_POWER_TURNING_OFF_STATE, +} from '../utils/equipment-power-state.util'; +import { EquipmentPowerHandler } from './equipment-power.handler'; + +function entry(flags: EquipmentFlag[], type: 'Mek' | 'ProtoMek' = 'Mek'): MountedEquipment { + const { owner } = createTestEquipmentOwner({ unit: { type } }); + const markEquipmentStateChanged = jasmine.createSpy('markEquipmentStateChanged'); + Object.assign(owner, { turnState: () => ({ markEquipmentStateChanged }) }); + const equipment = new MiscEquipment({ + id: flags.join('-'), + name: 'Test Equipment', + type: 'misc', + flags, + }); + const mounted = new MountedEquipment({ + owner, + id: equipment.id, + name: equipment.name, + equipment, + }); + owner.setInventoryEntry(mounted); + return mounted; +} + +describe('EquipmentPowerHandler', () => { + const handler = new EquipmentPowerHandler(); + const queryContext = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); + const commandContext = createHandlerCommandContext( + EMPTY_EQUIPMENT_REGISTRY, + jasmine.createSpyObj('ToastService', ['showToast', 'toasts']), + jasmine.createSpyObj('DialogsService', ['createDialog']), + ); + + it('only exposes switches with an explicit rules benefit', () => { + expect(handler.applicableTo(entry(['F_MINESWEEPER']))).toBeTrue(); + expect(handler.applicableTo(entry(['F_EI_INTERFACE']))).toBeTrue(); + + for (const flag of [ + 'F_APOLLO', + 'F_ARTEMIS', + 'F_ARTEMIS_PROTO', + 'F_ARTEMIS_V', + 'F_TAG', + 'F_TARGETING_COMPUTER', + 'F_C3S', + ] satisfies EquipmentFlag[]) { + expect(handler.applicableTo(entry([flag]))) + .withContext(flag) + .toBeFalse(); + } + }); + + it('does not offer the non-switchable ProtoMek EI interface', () => { + expect(handler.applicableTo(entry(['F_EI_INTERFACE'], 'ProtoMek'))).toBeFalse(); + }); + + it('keeps the system effective until its End-Phase shutdown commits', () => { + const mounted = entry(['F_MINESWEEPER']); + const registry = new EquipmentInteractionRegistry(); + registry.register(handler); + + const choice = registry.getChoices(mounted, queryContext)[0] as PickerChoice; + expect(choice).toEqual(jasmine.objectContaining({ + label: 'System is ON', + value: EQUIPMENT_POWER_TURNING_OFF_STATE, + active: true, + })); + + handler.handleSelection(mounted, choice, commandContext); + expect(mounted.states.get(EQUIPMENT_POWER_STATE_KEY)).toBe(EQUIPMENT_POWER_TURNING_OFF_STATE); + + handler.onEndTurn(mounted); + expect(mounted.states.get(EQUIPMENT_POWER_STATE_KEY)).toBe(EQUIPMENT_POWER_OFF_STATE); + }); +}); diff --git a/src/app/equipment-handlers/equipment-power.handler.ts b/src/app/equipment-handlers/equipment-power.handler.ts new file mode 100644 index 000000000..9db635a52 --- /dev/null +++ b/src/app/equipment-handlers/equipment-power.handler.ts @@ -0,0 +1,42 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { EquipmentFlag } from '../models/equipment-flags.type'; +import type { MountedEquipment } from '../models/mounted-equipment.model'; +import { + EQUIPMENT_POWER_OFF_STATE, + EQUIPMENT_POWER_ON_STATE, + EQUIPMENT_POWER_STATE_KEY, + EQUIPMENT_POWER_TURNING_OFF_STATE, + EQUIPMENT_POWER_TURNING_ON_STATE, +} from '../utils/equipment-power-state.util'; +import { ToggleHandler } from './base/toggle.handler'; + +const END_PHASE_POWER_FLAGS: EquipmentFlag[] = [ + 'F_MINESWEEPER', + 'F_EI_INTERFACE', +]; + +/** Shared delayed power switch for electronics governed by the End Phase. */ +export class EquipmentPowerHandler extends ToggleHandler { + readonly id = 'equipment-power-handler'; + override readonly priority = 5; + protected override readonly stateKey = EQUIPMENT_POWER_STATE_KEY; + protected override readonly toggleMode = 'transient' as const; + protected override readonly enabledState = EQUIPMENT_POWER_ON_STATE; + protected override readonly enablingState = EQUIPMENT_POWER_TURNING_ON_STATE; + protected override readonly disabledState = EQUIPMENT_POWER_OFF_STATE; + protected override readonly disablingState = EQUIPMENT_POWER_TURNING_OFF_STATE; + protected override readonly defaultEnabled = true; + protected override readonly enabledLabel = 'System is ON'; + protected override readonly enablingLabel = 'Turning system on…'; + protected override readonly disabledLabel = 'System is OFF'; + protected override readonly disablingLabel = 'Turning system off…'; + + override applicableTo(equipment: MountedEquipment): boolean { + if (equipment.equipment?.hasAnyFlag(END_PHASE_POWER_FLAGS) !== true) return false; + return equipment.equipment.hasFlag('F_EI_INTERFACE') === false + || equipment.owner.getUnit().type !== 'ProtoMek'; + } +} diff --git a/src/app/equipment-handlers/index.ts b/src/app/equipment-handlers/index.ts index 869d23804..ce73a1cca 100644 --- a/src/app/equipment-handlers/index.ts +++ b/src/app/equipment-handlers/index.ts @@ -8,25 +8,34 @@ import { ArtemisVHandler } from './artemis-v.handler'; import { AtmHandler } from './atm.handler'; import { BAPHandler } from './bap.handler'; import { BlueShieldHandler } from './blue-shield.handler'; +import { BoobyTrapHandler } from './booby-trap.handler'; import { BombastLaserHandler } from './bombast-laser.handler'; import { C3EmergencyMasterHandler } from './c3-emergency-master.handler'; import { C3Handler } from './c3.handler'; +import { CoolantPodHandler } from './coolant-pod.handler'; import { ECMHandler } from './ecm.handler'; +import { EquipmentPowerHandler } from './equipment-power.handler'; import { FlamerHandler } from './flamer.handler'; import { GaussPowerHandler } from './gauss-power.handler'; import { HagHandler } from './hag.handler'; import { InventoryModeHandler } from './inventory-mode.handler'; import { LaserInsulatorHandler } from './laser-insulator.handler'; import { MascHandler } from './masc.handler'; +import { MobileHpgHandler } from './mobile-hpg.handler'; +import { MgaActivationHandler } from './mga-activation.handler'; import { MmlHandler } from './mml.handler'; import { NovaCewsHandler } from './nova-cews.handler'; import { PpcCapacitorHandler } from './ppc-capacitor.handler'; import { PrecisionAmmoHandler } from './precision-ammo.handler'; +import { PrototypeLaserHandler } from './prototype-laser.handler'; import { RadicalHeatSinkHandler } from './radical-heat-sink.handler'; import { RiscEmergencyCoolantSystemHandler } from './risc-emergency-coolant-system.handler'; import { RiscLaserPulseModuleHandler } from './risc-laser-pulse-module.handler'; import { RiscViralJammerHandler } from './risc-viral-jammer.handler'; +import { SearchlightHandler } from './searchlight.handler'; +import { ShieldModeHandler } from './shield-mode.handler'; import { StealthHandler } from './stealth.handler'; +import { SpotWelderHandler } from './spot-welder.handler'; import { TwBombastLaserHandler } from './tw-bombast-laser.handler'; import { UACFiringModeHandler } from './uac-firing-mode.handler'; import { UACJammingHandler } from './uacjamming.handler'; @@ -45,25 +54,34 @@ export function registerAllHandlers(registryService: EquipmentInteractionRegistr registry.register(new AtmHandler()); registry.register(new BAPHandler()); registry.register(new BlueShieldHandler()); + registry.register(new BoobyTrapHandler()); registry.register(new BombastLaserHandler()); registry.register(new C3EmergencyMasterHandler()); registry.register(new C3Handler()); + registry.register(new CoolantPodHandler()); registry.register(new ECMHandler()); + registry.register(new EquipmentPowerHandler()); registry.register(new FlamerHandler()); registry.register(new GaussPowerHandler()); registry.register(new HagHandler()); registry.register(new InventoryModeHandler()); registry.register(new LaserInsulatorHandler()); registry.register(new MascHandler()); + registry.register(new MobileHpgHandler()); + registry.register(new MgaActivationHandler()); registry.register(new MmlHandler()); registry.register(new NovaCewsHandler()); registry.register(new PpcCapacitorHandler()); registry.register(new PrecisionAmmoHandler()); + registry.register(new PrototypeLaserHandler()); registry.register(new RadicalHeatSinkHandler()); registry.register(new RiscEmergencyCoolantSystemHandler()); registry.register(new RiscLaserPulseModuleHandler()); registry.register(new RiscViralJammerHandler()); + registry.register(new SearchlightHandler()); + registry.register(new ShieldModeHandler()); registry.register(new StealthHandler()); + registry.register(new SpotWelderHandler()); registry.register(new TwBombastLaserHandler()); registry.register(new UACFiringModeHandler()); registry.register(new UACJammingHandler()); diff --git a/src/app/equipment-handlers/mga-activation.handler.spec.ts b/src/app/equipment-handlers/mga-activation.handler.spec.ts new file mode 100644 index 000000000..0b81801fe --- /dev/null +++ b/src/app/equipment-handlers/mga-activation.handler.spec.ts @@ -0,0 +1,136 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { WeaponEquipment, type WeaponDamage } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; +import { MountedEquipment } from '../models/mounted-equipment.model'; +import type { DialogsService } from '../services/dialogs.service'; +import { + createHandlerCommandContext, + createHandlerQueryContext, +} from '../services/equipment-interaction-registry.service'; +import type { ToastService } from '../services/toast.service'; +import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; +import { + MGA_ACTIVATION_STATE_KEY, + MGA_ACTIVE_STATE, + MGA_OFF_STATE, + MGA_TURNING_OFF_STATE, + MGA_TURNING_ON_STATE, +} from '../utils/mga-state.util'; +import { MgaActivationHandler } from './mga-activation.handler'; + +function fixture(memberCount = 3) { + const { owner } = createTestEquipmentOwner(); + Object.assign(owner, { + turnState: () => ({ markEquipmentStateChanged: jasmine.createSpy('markEquipmentStateChanged') }), + }); + const arrayType = new WeaponEquipment({ + id: 'ISMGA', + name: 'Machine Gun Array', + type: 'weapon', + flags: ['F_MGA'], + weapon: { ammoType: 'MG', rackSize: 2, damage: 2 }, + }); + const memberType = new WeaponEquipment({ + id: 'ISMachineGun', + name: 'Machine Gun', + type: 'weapon', + flags: ['F_MG'], + weapon: { ammoType: 'MG', rackSize: 2, damage: 2 }, + }); + const array = new MountedEquipment({ + owner, + id: 'array', + name: arrayType.name, + equipment: arrayType, + locations: new Set(['LT']), + }); + const members = Array.from({ length: memberCount }, (_, index) => new MountedEquipment({ + owner, + id: `member-${index + 1}`, + name: memberType.name, + equipment: memberType, + locations: new Set(['LT']), + })); + array.setLinkedEquipment(members); + [array, ...members].forEach(entry => owner.setInventoryEntry(entry)); + return { owner, array, members }; +} + +describe('MgaActivationHandler', () => { + const handler = new MgaActivationHandler(); + const queryContext = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); + const commandContext = createHandlerCommandContext( + EMPTY_EQUIPMENT_REGISTRY, + jasmine.createSpyObj('ToastService', ['showToast', 'toasts']), + jasmine.createSpyObj('DialogsService', ['createDialog']), + ); + const damageContext = { + selectedRange: null, + selectedAmmo: null, + equipmentCatalog: EMPTY_EQUIPMENT_REGISTRY, + } as const; + + it('models the rulebook Activated/Off state instead of generic equipment power', () => { + const { array, members } = fixture(); + + expect(handler.getChoices(array, queryContext)[0]).toEqual(jasmine.objectContaining({ + label: 'Array linked', + active: true, + value: MGA_TURNING_OFF_STATE, + })); + expect(handler.getChoices(members[0], queryContext)).toEqual([]); + + handler.handleSelection(array, handler.getChoices(array, queryContext)[0], commandContext); + expect(array.states.get(MGA_ACTIVATION_STATE_KEY)).toBe(MGA_TURNING_OFF_STATE); + expect(handler.isInventoryControlSelectable(array, queryContext)).toBeNull(); + expect(handler.isInventoryControlSelectable(members[0], queryContext)).toBeFalse(); + + handler.onEndTurn(array); + expect(array.states.get(MGA_ACTIVATION_STATE_KEY)).toBe(MGA_OFF_STATE); + expect(handler.isInventoryControlSelectable(array, queryContext)).toBeFalse(); + expect(handler.isInventoryControlSelectable(members[0], queryContext)).toBeNull(); + + handler.handleSelection(array, handler.getChoices(array, queryContext)[0], commandContext); + expect(array.states.get(MGA_ACTIVATION_STATE_KEY)).toBe(MGA_TURNING_ON_STATE); + expect(handler.isInventoryControlSelectable(array, queryContext)).toBeFalse(); + expect(handler.isInventoryControlSelectable(members[0], queryContext)).toBeNull(); + }); + + it('uses each working member for cluster size, maximum damage, and ammo consumption', () => { + const { array, members } = fixture(); + const damage: WeaponDamage = { values: [2], maximum: 2 }; + + expect(handler.applyInventoryControlAmmoConsumption(array, 1, queryContext)).toBe(3); + expect(handler.applyInventoryControlDamageEffects(array, damage, damageContext, queryContext)) + .toEqual({ values: [2], maximum: 6, unit: 'shot' }); + expect(handler.applyInventoryControlHeatEffects(array, { value: 1, weakened: false }, queryContext)) + .toEqual({ value: 3, displayValue: 1, weakened: false }); + + members[1].setCommittedDestroyed(true); + + expect(handler.applyInventoryControlAmmoConsumption(array, 1, queryContext)).toBe(2); + expect(handler.applyInventoryControlDamageEffects(array, damage, damageContext, queryContext)) + .toEqual({ values: [2], maximum: 4, unit: 'shot' }); + expect(handler.applyInventoryControlHeatEffects(array, { value: 1, weakened: false }, queryContext)) + .toEqual({ value: 2, displayValue: 1, weakened: false }); + }); + + it('cannot fire an active array with no working guns', () => { + const { array, members } = fixture(2); + members.forEach(member => member.setCommittedDestroyed(true)); + + expect(handler.isInventoryControlSelectable(array, queryContext)).toBeFalse(); + expect(handler.applyInventoryControlAmmoConsumption(array, 1, queryContext)).toBe(0); + }); + + it('releases member guns when the controller is destroyed', () => { + const { array, members } = fixture(); + array.setCommittedDestroyed(true); + + expect(handler.isInventoryControlSelectable(array, queryContext)).toBeFalse(); + expect(handler.isInventoryControlSelectable(members[0], queryContext)).toBeNull(); + }); +}); diff --git a/src/app/equipment-handlers/mga-activation.handler.ts b/src/app/equipment-handlers/mga-activation.handler.ts new file mode 100644 index 000000000..6096a58d1 --- /dev/null +++ b/src/app/equipment-handlers/mga-activation.handler.ts @@ -0,0 +1,124 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { PickerChoice } from '../components/picker/picker.interface'; +import type { WeaponDamage } from '../models/equipment.model'; +import type { MountedEquipment } from '../models/mounted-equipment.model'; +import type { + HandlerQueryContext, +} from '../services/equipment-interaction-registry.service'; +import type { InventoryControlDamageContext } from '../utils/inventory-control-damage.util'; +import type { InventoryControlDisplayData, InventoryControlDisplayEffectOptions } from '../utils/inventory-control.util'; +import type { InventoryControlHeatEffect } from '../utils/inventory-control-heat.util'; +import { + isMachineGunArray, + isMachineGunArrayEffectivelyActive, + isMachineGunArrayMember, + machineGunArrayActivationState, + machineGunArrayController, + MGA_ACTIVATION_STATE_KEY, + MGA_ACTIVE_STATE, + MGA_OFF_STATE, + MGA_TURNING_OFF_STATE, + MGA_TURNING_ON_STATE, + operationalMachineGunArrayMembers, +} from '../utils/mga-state.util'; +import { ToggleHandler } from './base/toggle.handler'; + +/** Implements Total Warfare's End-Phase Activated/Off state for Machine Gun Arrays. */ +export class MgaActivationHandler extends ToggleHandler { + readonly id = 'mga-activation-handler'; + + protected override readonly stateKey = MGA_ACTIVATION_STATE_KEY; + protected override readonly toggleMode = 'transient' as const; + protected override readonly enabledState = MGA_ACTIVE_STATE; + protected override readonly enablingState = MGA_TURNING_ON_STATE; + protected override readonly disabledState = MGA_OFF_STATE; + protected override readonly disablingState = MGA_TURNING_OFF_STATE; + protected override readonly defaultEnabled = true; + protected override readonly enabledLabel = 'Array linked'; + protected override readonly enablingLabel = 'Links at End Phase…'; + protected override readonly disabledLabel = 'Array unlinked'; + protected override readonly disablingLabel = 'Unlinked at End Phase…'; + protected override readonly enabledToastVerb = 'active'; + protected override readonly enablingToastVerb = 'scheduled to link at End Phase'; + protected override readonly disabledToastVerb = 'off'; + protected override readonly disablingToastVerb = 'scheduled to unlink at End Phase'; + + override applicableTo(equipment: MountedEquipment): boolean { + return isMachineGunArray(equipment) || isMachineGunArrayMember(equipment); + } + + override getChoices(equipment: MountedEquipment, context: HandlerQueryContext): PickerChoice[] { + return isMachineGunArray(equipment) ? super.getChoices(equipment, context) : []; + } + + protected override getToggleState(equipment: MountedEquipment): string { + return machineGunArrayActivationState(equipment); + } + + override isInventoryControlSelectable( + equipment: MountedEquipment, + context: HandlerQueryContext, + ): boolean | null { + if (isMachineGunArray(equipment)) { + if (context.getStatus(equipment) !== 'available' + || !isMachineGunArrayEffectivelyActive(equipment)) return false; + return operationalMachineGunArrayMembers( + equipment, + member => context.getStatus(member) === 'available', + ).length > 0 ? null : false; + } + + const array = machineGunArrayController(equipment); + return array + && context.getStatus(array) === 'available' + && isMachineGunArrayEffectivelyActive(array) + ? false + : null; + } + + override applyInventoryControlAmmoConsumption( + equipment: MountedEquipment, + count: number, + context: HandlerQueryContext, + ): number { + if (!isMachineGunArray(equipment)) return count; + return count * operationalMachineGunArrayMembers( + equipment, + member => context.getStatus(member) === 'available', + ).length; + } + + override applyInventoryControlDamageEffects( + equipment: MountedEquipment, + damage: WeaponDamage, + _damageContext: InventoryControlDamageContext, + context: HandlerQueryContext, + ): WeaponDamage { + if (!isMachineGunArray(equipment)) return damage; + const memberCount = operationalMachineGunArrayMembers( + equipment, + member => context.getStatus(member) === 'available', + ).length; + return memberCount > 0 + ? { ...damage, maximum: damage.maximum * memberCount, unit: 'shot' } + : damage; + } + + override applyInventoryControlHeatEffects( + equipment: MountedEquipment, + effect: InventoryControlHeatEffect, + context: HandlerQueryContext, + ): InventoryControlHeatEffect { + if (!isMachineGunArray(equipment)) return effect; + const memberCount = operationalMachineGunArrayMembers( + equipment, + member => context.getStatus(member) === 'available', + ).length; + return memberCount > 1 + ? { ...effect, value: effect.value * memberCount, displayValue: effect.value } + : effect; + } +} diff --git a/src/app/equipment-handlers/mobile-hpg.handler.spec.ts b/src/app/equipment-handlers/mobile-hpg.handler.spec.ts new file mode 100644 index 000000000..94cbae7bd --- /dev/null +++ b/src/app/equipment-handlers/mobile-hpg.handler.spec.ts @@ -0,0 +1,223 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { PickerChoice } from '../components/picker/picker.interface'; +import { MiscEquipment, WeaponEquipment } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; +import { MountedEquipment } from '../models/mounted-equipment.model'; +import type { TurnState } from '../models/turn-state.model'; +import type { UnitEngineType, WeightClass } from '../models/unit-summary.model'; +import type { DialogsService } from '../services/dialogs.service'; +import { + createHandlerCommandContext, + createHandlerQueryContext, +} from '../services/equipment-interaction-registry.service'; +import type { ToastService } from '../services/toast.service'; +import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; +import { + HPG_CHARGED_STATE, + HPG_CHARGING_STATE, + HPG_COOLDOWN_STATE, + HPG_IDLE_STATE, + HPG_TRANSMITTING_STATE, + hpgState, +} from '../utils/hpg-state.util'; +import { MobileHpgHandler } from './mobile-hpg.handler'; + +interface HpgFixtureOptions { + readonly groundMobile?: boolean; + readonly engine?: UnitEngineType | null; + readonly weightClass?: WeightClass; +} + +function fixture(options: HpgFixtureOptions = {}) { + let moveMode = 'stationary'; + let moveDistance = 0; + let weaponSelected = false; + const markEquipmentStateChanged = jasmine.createSpy('markEquipmentStateChanged'); + const { owner } = createTestEquipmentOwner({ + unit: { + engine: options.engine === undefined ? 'Fusion' : options.engine, + weightClass: options.weightClass ?? 'Medium', + }, + }); + Object.assign(owner, { + turnState: () => ({ + effectiveMoveMode: () => moveMode, + moveDistance: () => moveDistance, + markEquipmentStateChanged, + }), + }); + const equipment = new MiscEquipment({ + id: options.groundMobile === false ? 'ISMobileHPG' : 'ISGroundMobileHPG', + name: options.groundMobile === false ? 'Mobile HPG' : 'Ground-Mobile HPG', + type: 'misc', + flags: options.groundMobile === false + ? ['F_MOBILE_HPG'] + : ['F_MOBILE_HPG', 'F_MEK_EQUIPMENT'], + }); + const mounted = new MountedEquipment({ + owner, + id: equipment.id, + name: equipment.name, + equipment, + }); + owner.setInventoryEntry(mounted); + const weapon = new MountedEquipment({ + owner, + id: 'test-weapon', + name: 'Test Weapon', + equipment: new WeaponEquipment({ + id: 'test-weapon', + name: 'Test Weapon', + type: 'weapon', + weapon: { ammoType: 'NA', ranges: [1, 2, 3, 4] }, + }), + }); + owner.setInventoryEntry(weapon); + Object.assign(owner, { + isInventoryControlEntrySelected: (id: string) => weaponSelected && id === weapon.id, + }); + return { + owner, + mounted, + markEquipmentStateChanged, + setMovement: (mode: string, distance: number) => { + moveMode = mode; + moveDistance = distance; + }, + setWeaponSelected: (selected: boolean) => { weaponSelected = selected; }, + }; +} + +describe('MobileHpgHandler', () => { + const handler = new MobileHpgHandler(); + const queryContext = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); + const toastService = jasmine.createSpyObj('ToastService', ['showToast', 'toasts']); + const commandContext = createHandlerCommandContext( + EMPTY_EQUIPMENT_REGISTRY, + toastService, + jasmine.createSpyObj('DialogsService', ['createDialog']), + ); + const turnState = {} as TurnState; + + beforeEach(() => toastService.showToast.calls.reset()); + + it('recognizes only actual fusion-engine variants', () => { + for (const engine of [ + 'Fusion', 'XL (IS)', 'XL (Clan)', 'XXL (IS)', 'XXL (Clan)', 'Light', 'Compact', + ] satisfies UnitEngineType[]) { + expect(handler.getChoices(fixture({ engine }).mounted, queryContext)[0].disabled) + .withContext(engine) + .toBeFalse(); + } + + for (const engine of [ + 'ICE', 'Fuel Cell', 'Fission', 'None', 'MagLev', 'Steam', 'Battery', 'Solar', 'External', + ] satisfies UnitEngineType[]) { + expect(handler.getChoices(fixture({ engine }).mounted, queryContext)[0].disabled) + .withContext(engine) + .toBeTrue(); + } + + const missingEngine = fixture(); + Object.assign(missingEngine.owner.getUnit(), { engine: undefined }); + expect(handler.getChoices(missingEngine.mounted, queryContext)[0].disabled).toBeTrue(); + }); + + it('charges, transmits, generates grouped heat, and observes the five-turn ground cycle', () => { + const { mounted } = fixture(); + + handler.handleSelection(mounted, handler.getChoices(mounted, queryContext)[0], commandContext); + expect(hpgState(mounted)).toBe(HPG_CHARGING_STATE); + expect(handler.getInventoryHeatSources(mounted, turnState, queryContext)).toEqual([{ + id: `mobile-hpg:${mounted.id}`, + label: 'HPG Charging', + value: 20, + group: 'Equipment', + }]); + + handler.onEndTurn(mounted); + expect(hpgState(mounted)).toBe(HPG_CHARGED_STATE); + expect(handler.getInventoryHeatSources(mounted, turnState, queryContext)).toEqual([]); + + handler.handleSelection(mounted, handler.getChoices(mounted, queryContext)[0], commandContext); + expect(hpgState(mounted)).toBe(HPG_TRANSMITTING_STATE); + expect(handler.getInventoryHeatSources(mounted, turnState, queryContext)[0]) + .toEqual(jasmine.objectContaining({ label: 'HPG Transmission', value: 20 })); + + handler.onEndTurn(mounted); + expect(hpgState(mounted)).toBe(HPG_COOLDOWN_STATE); + expect(handler.getChoices(mounted, queryContext)[0].label).toBe('HPG Cooldown (3)'); + handler.onEndTurn(mounted); + handler.onEndTurn(mounted); + handler.onEndTurn(mounted); + expect(hpgState(mounted)).toBe(HPG_IDLE_STATE); + }); + + it('requires a Ground-Mobile HPG to spend zero MP before transmitting', () => { + const { mounted, setMovement } = fixture(); + handler.handleSelection(mounted, handler.getChoices(mounted, queryContext)[0], commandContext); + handler.onEndTurn(mounted); + setMovement('walk', 1); + + const transmit = handler.getChoices(mounted, queryContext)[0] as PickerChoice; + expect(transmit.disabled).toBeTrue(); + handler.handleSelection(mounted, transmit, commandContext); + + expect(hpgState(mounted)).toBe(HPG_CHARGED_STATE); + expect(toastService.showToast).toHaveBeenCalledWith( + 'A Ground-Mobile HPG can transmit only after spending 0 MP', + 'error', + ); + }); + + it('does not begin charging or transmitting after a weapon attack is selected', () => { + const { mounted, setWeaponSelected } = fixture(); + setWeaponSelected(true); + + const charge = handler.getChoices(mounted, queryContext)[0]; + expect(charge.disabled).toBeTrue(); + handler.handleSelection(mounted, { ...charge, disabled: false }, commandContext); + expect(hpgState(mounted)).toBe(HPG_IDLE_STATE); + + setWeaponSelected(false); + handler.handleSelection(mounted, handler.getChoices(mounted, queryContext)[0], commandContext); + handler.onEndTurn(mounted); + expect(hpgState(mounted)).toBe(HPG_CHARGED_STATE); + + setWeaponSelected(true); + const transmit = handler.getChoices(mounted, queryContext)[0]; + expect(transmit.disabled).toBeTrue(); + handler.handleSelection(mounted, { ...transmit, disabled: false }, commandContext); + expect(hpgState(mounted)).toBe(HPG_CHARGED_STATE); + expect(toastService.showToast).toHaveBeenCalledWith( + 'An HPG cannot charge or transmit in a turn with weapon attacks', + 'error', + ); + }); + + it('lets a Large Support Vehicle begin a new charge after transmission', () => { + const { mounted } = fixture({ weightClass: 'Large Support Vehicle' }); + handler.handleSelection(mounted, handler.getChoices(mounted, queryContext)[0], commandContext); + handler.onEndTurn(mounted); + handler.handleSelection(mounted, handler.getChoices(mounted, queryContext)[0], commandContext); + handler.onEndTurn(mounted); + + expect(hpgState(mounted)).toBe(HPG_IDLE_STATE); + expect(handler.getChoices(mounted, queryContext)[0].label).toBe('Charge HPG'); + }); + + it('toggles a Mobile HPG transmission and generates 40 heat', () => { + const { mounted } = fixture({ groundMobile: false }); + + handler.handleSelection(mounted, handler.getChoices(mounted, queryContext)[0], commandContext); + expect(hpgState(mounted)).toBe(HPG_TRANSMITTING_STATE); + expect(handler.getInventoryHeatSources(mounted, turnState, queryContext)[0]) + .toEqual(jasmine.objectContaining({ value: 40, group: 'Equipment' })); + + handler.handleSelection(mounted, handler.getChoices(mounted, queryContext)[0], commandContext); + expect(hpgState(mounted)).toBe(HPG_IDLE_STATE); + }); +}); diff --git a/src/app/equipment-handlers/mobile-hpg.handler.ts b/src/app/equipment-handlers/mobile-hpg.handler.ts new file mode 100644 index 000000000..2879f3cfa --- /dev/null +++ b/src/app/equipment-handlers/mobile-hpg.handler.ts @@ -0,0 +1,176 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { PickerChoice } from '../components/picker/picker.interface'; +import type { EquipmentFlag } from '../models/equipment-flags.type'; +import type { MountedEquipment } from '../models/mounted-equipment.model'; +import { EQUIPMENT_HEAT_SOURCE_GROUP, type UnitHeatSource } from '../models/rules/unit-type-rules'; +import type { TurnState } from '../models/turn-state.model'; +import { isFusionUnitEngine } from '../models/unit-summary.model'; +import { + EquipmentInteractionHandler, + type HandlerCommandContext, + type HandlerQueryContext, +} from '../services/equipment-interaction-registry.service'; +import { + HPG_CHARGED_STATE, + HPG_CHARGING_STATE, + HPG_COOLDOWN_STATE, + HPG_COOLDOWN_TURNS_STATE_KEY, + HPG_IDLE_STATE, + HPG_STATE_KEY, + HPG_TRANSMITTING_STATE, + hpgState, + isGroundMobileHpg, + unitHasSelectedWeaponAttack, +} from '../utils/hpg-state.util'; + +export class MobileHpgHandler extends EquipmentInteractionHandler { + readonly id = 'mobile-hpg-handler'; + override readonly flags: EquipmentFlag[] = ['F_MOBILE_HPG']; + + override getChoices(equipment: MountedEquipment, _context: HandlerQueryContext): PickerChoice[] { + const state = hpgState(equipment); + const noFusionEngine = !this.hasFusionEngine(equipment); + if (!isGroundMobileHpg(equipment)) { + const transmitting = state === HPG_TRANSMITTING_STATE; + return [{ + label: transmitting ? 'Stop HPG Transmission' : 'Start HPG Transmission', + value: transmitting ? HPG_IDLE_STATE : HPG_TRANSMITTING_STATE, + active: transmitting, + disabled: noFusionEngine || (!transmitting && unitHasSelectedWeaponAttack(equipment.owner)), + displayType: 'toggle', + }]; + } + + if (state === HPG_IDLE_STATE) { + return [{ + label: 'Charge HPG', + value: HPG_CHARGING_STATE, + active: false, + disabled: noFusionEngine || unitHasSelectedWeaponAttack(equipment.owner), + displayType: 'toggle', + }]; + } + if (state === HPG_CHARGED_STATE) { + return [{ + label: 'Transmit HPG', + value: HPG_TRANSMITTING_STATE, + active: false, + disabled: noFusionEngine + || !this.canTransmitGroundMobile(equipment) + || unitHasSelectedWeaponAttack(equipment.owner), + displayType: 'toggle', + }]; + } + if (state === HPG_COOLDOWN_STATE) { + return [{ + label: `HPG Cooldown (${this.cooldownTurns(equipment)})`, + value: HPG_COOLDOWN_STATE, + active: false, + disabled: true, + displayType: 'toggle', + }]; + } + return [{ + label: state === HPG_CHARGING_STATE ? 'HPG Charging…' : 'HPG Transmitting…', + value: state, + active: true, + disabled: true, + displayType: 'toggle', + }]; + } + + override handleSelection( + equipment: MountedEquipment, + choice: PickerChoice, + context: HandlerCommandContext, + ): boolean { + const next = String(choice.value); + if (!this.hasFusionEngine(equipment)) { + context.toastService.showToast('A Mobile HPG requires a fusion engine', 'error'); + return true; + } + if ((next === HPG_CHARGING_STATE || next === HPG_TRANSMITTING_STATE) + && unitHasSelectedWeaponAttack(equipment.owner)) { + context.toastService.showToast('An HPG cannot charge or transmit in a turn with weapon attacks', 'error'); + return true; + } + if (isGroundMobileHpg(equipment) + && next === HPG_TRANSMITTING_STATE + && !this.canTransmitGroundMobile(equipment)) { + context.toastService.showToast('A Ground-Mobile HPG can transmit only after spending 0 MP', 'error'); + return true; + } + const state = hpgState(equipment); + const allowed = !isGroundMobileHpg(equipment) + ? (state === HPG_IDLE_STATE && next === HPG_TRANSMITTING_STATE) + || (state === HPG_TRANSMITTING_STATE && next === HPG_IDLE_STATE) + : (state === HPG_IDLE_STATE && next === HPG_CHARGING_STATE) + || (state === HPG_CHARGED_STATE && next === HPG_TRANSMITTING_STATE); + if (!allowed || !equipment.setState(HPG_STATE_KEY, next)) return true; + + equipment.owner.setInventoryEntry(equipment); + equipment.owner.turnState().markEquipmentStateChanged(); + context.toastService.showToast(`${equipment.getDisplayName()}: ${choice.label}`, 'info'); + return true; + } + + override getInventoryHeatSources( + equipment: MountedEquipment, + _turnState: TurnState, + context: HandlerQueryContext, + ): UnitHeatSource[] { + if (context.getStatus(equipment) !== 'available' || !this.hasFusionEngine(equipment)) return []; + const state = hpgState(equipment); + const groundMobile = isGroundMobileHpg(equipment); + const active = state === HPG_TRANSMITTING_STATE || (groundMobile && state === HPG_CHARGING_STATE); + if (!active) return []; + return [{ + id: `mobile-hpg:${equipment.id}`, + label: state === HPG_CHARGING_STATE ? 'HPG Charging' : 'HPG Transmission', + value: groundMobile ? 20 : 40, + group: EQUIPMENT_HEAT_SOURCE_GROUP, + }]; + } + + override onEndTurn(equipment: MountedEquipment): void { + if (!isGroundMobileHpg(equipment)) return; + const state = hpgState(equipment); + let changed = false; + if (state === HPG_CHARGING_STATE) { + changed = equipment.setState(HPG_STATE_KEY, HPG_CHARGED_STATE); + } else if (state === HPG_TRANSMITTING_STATE) { + if (equipment.owner.getUnit().weightClass === 'Large Support Vehicle') { + changed = equipment.setState(HPG_STATE_KEY, HPG_IDLE_STATE); + } else { + changed = equipment.setState(HPG_STATE_KEY, HPG_COOLDOWN_STATE); + changed = equipment.setState(HPG_COOLDOWN_TURNS_STATE_KEY, '3') || changed; + } + } else if (state === HPG_COOLDOWN_STATE) { + const remaining = Math.max(0, this.cooldownTurns(equipment) - 1); + if (remaining === 0) { + changed = equipment.setState(HPG_STATE_KEY, HPG_IDLE_STATE); + changed = equipment.deleteState(HPG_COOLDOWN_TURNS_STATE_KEY) || changed; + } else { + changed = equipment.setState(HPG_COOLDOWN_TURNS_STATE_KEY, String(remaining)); + } + } + if (changed) equipment.owner.setInventoryEntry(equipment); + } + + private canTransmitGroundMobile(equipment: MountedEquipment): boolean { + const turnState = equipment.owner.turnState(); + return turnState.effectiveMoveMode() === 'stationary' && (turnState.moveDistance() ?? 0) === 0; + } + + private hasFusionEngine(equipment: MountedEquipment): boolean { + return isFusionUnitEngine(equipment.owner.getUnit().engine); + } + + private cooldownTurns(equipment: MountedEquipment): number { + const turns = Number(equipment.states.get(HPG_COOLDOWN_TURNS_STATE_KEY)); + return Number.isInteger(turns) && turns > 0 ? turns : 0; + } +} diff --git a/src/app/equipment-handlers/nova-cews.handler.spec.ts b/src/app/equipment-handlers/nova-cews.handler.spec.ts index 3b3586ffe..7e2ea1bdd 100644 --- a/src/app/equipment-handlers/nova-cews.handler.spec.ts +++ b/src/app/equipment-handlers/nova-cews.handler.spec.ts @@ -260,6 +260,25 @@ describe('NovaCewsHandler', () => { expect(isNovaCewsEffectivelyActive(second)).toBeTrue(); }); + it('keeps the selected Nova active when an inventory write rebuilds mount objects', () => { + const test = fixture(); + test.add('nova-1'); + const second = test.add('nova-2'); + (test.owner.setInventoryEntry as jasmine.Spy).and.callFake((entry: MountedEquipment) => { + const next = test.inventory.map(candidate => candidate.id === entry.id ? entry : candidate); + test.inventory.splice(0, test.inventory.length, ...MountedEquipment.fromAll(next)); + }); + + handler.handleSelection(second, handler.getChoices(second, queryContext)[0], commandContext); + const pending = test.owner.getInventory().find(entry => entry.id === second.id)!; + handler.onEndTurn(pending); + + const currentFirst = test.owner.getInventory().find(entry => entry.id === 'nova-1')!; + const currentSecond = test.owner.getInventory().find(entry => entry.id === 'nova-2')!; + expect(isNovaCewsEffectivelyActive(currentFirst)).toBeFalse(); + expect(isNovaCewsEffectivelyActive(currentSecond)).toBeTrue(); + }); + it('leaves the current mount active when a pending handoff is cancelled', () => { const test = fixture(); const first = test.add('nova-1'); diff --git a/src/app/equipment-handlers/nova-cews.handler.ts b/src/app/equipment-handlers/nova-cews.handler.ts index 628ad8171..2470019e1 100644 --- a/src/app/equipment-handlers/nova-cews.handler.ts +++ b/src/app/equipment-handlers/nova-cews.handler.ts @@ -3,17 +3,22 @@ // Author: Drake import type { EquipmentFlag } from '../models/equipment-flags.type'; +import type { PickerChoice } from '../components/picker/picker.interface'; import type { MountedEquipment } from '../models/mounted-equipment.model'; import { EQUIPMENT_HEAT_SOURCE_GROUP, type UnitHeatSource } from '../models/rules/unit-type-rules'; import type { TurnState } from '../models/turn-state.model'; import type { HandlerQueryContext } from '../services/equipment-interaction-registry.service'; +import type { HandlerCommandContext } from '../services/equipment-interaction-registry.service'; import { + cancelConflictingElectronicSuiteActivations, + deactivateConflictingElectronicSuites, isNovaCewsEffectivelyActive, NOVA_CEWS_OFF_STATE, NOVA_CEWS_ON_STATE, NOVA_CEWS_STATE_KEY, NOVA_CEWS_TURNING_OFF_STATE, NOVA_CEWS_TURNING_ON_STATE, + nextEffectiveNovaCewsState, novaCewsState, } from '../utils/ecm-state.util'; import { ToggleHandler } from './base/toggle.handler'; @@ -42,26 +47,37 @@ export class NovaCewsHandler extends ToggleHandler { protected override readonly disablingToastVerb = 'turning off'; protected override getToggleState(equipment: MountedEquipment): string { - return novaCewsState(equipment); + return nextEffectiveNovaCewsState(equipment); } isActive(equipment: MountedEquipment): boolean { return isNovaCewsEffectivelyActive(equipment); } + override handleSelection( + equipment: MountedEquipment, + choice: PickerChoice, + context: HandlerCommandContext, + ): boolean { + const handled = super.handleSelection(equipment, choice, context); + const selectedState = String(choice.value); + const activated = (selectedState === NOVA_CEWS_TURNING_ON_STATE + || selectedState === NOVA_CEWS_ON_STATE) + && novaCewsState(equipment) === selectedState; + if (activated && cancelConflictingElectronicSuiteActivations(equipment)) { + equipment.owner.turnState().markEquipmentStateChanged(); + } + return handled; + } + override onEndTurn(equipment: MountedEquipment): void { const activating = novaCewsState(equipment) === NOVA_CEWS_TURNING_ON_STATE; super.onEndTurn(equipment); if (!activating || novaCewsState(equipment) !== NOVA_CEWS_ON_STATE) return; - // A unit may operate only one Nova CEWS. Commit the selected mount's - // handoff after outgoing-turn effects and heat have already resolved. - for (const other of equipment.owner.getInventory()) { - if (other === equipment || other.equipment?.flags.has('F_NOVA') !== true) continue; - if (other.setState(NOVA_CEWS_STATE_KEY, NOVA_CEWS_OFF_STATE)) { - other.owner.setInventoryEntry(other); - } - } + // Commit the selected suite's ECM/probe handoff after outgoing-turn + // effects and heat have already resolved. + deactivateConflictingElectronicSuites(equipment); } override getInventoryHeatSources( diff --git a/src/app/equipment-handlers/prototype-laser.handler.spec.ts b/src/app/equipment-handlers/prototype-laser.handler.spec.ts new file mode 100644 index 000000000..0c3487479 --- /dev/null +++ b/src/app/equipment-handlers/prototype-laser.handler.spec.ts @@ -0,0 +1,92 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { WeaponEquipment } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; +import { MountedWeapon } from '../models/mounted-equipment.model'; +import { createHandlerQueryContext } from '../services/equipment-interaction-registry.service'; +import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; +import { PrototypeLaserHandler } from './prototype-laser.handler'; + +function fixture(internalName: string, type: 'Mek' | 'Aero' = 'Mek') { + const addFiredHeat = jasmine.createSpy('addFiredHeat'); + const { owner } = createTestEquipmentOwner({ unit: { type } }); + const heat = { current: 0, previous: 0, next: undefined as number | undefined }; + const setHeat = jasmine.createSpy('setHeat').and.callFake((value: number) => heat.next = value); + Object.assign(owner, { + getHeat: () => heat, + setHeat, + turnState: () => ({ addFiredHeat }), + }); + const equipment = new WeaponEquipment({ + id: internalName, + name: internalName, + type: 'weapon', + weapon: { heat: 10, damage: 10, ranges: [5, 10, 15, 20], ammoType: 'NA' }, + }); + const mounted = new MountedWeapon({ + owner, + id: internalName, + name: equipment.name, + equipment, + }); + return { mounted, addFiredHeat, setHeat }; +} + +describe('PrototypeLaserHandler', () => { + const handler = new PrototypeLaserHandler(); + const context = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); + + it('marks ground prototype heat as variable and rolls the extra heat after firing', () => { + const medium = fixture('ISMediumPulseLaserPrototype'); + spyOn(Math, 'random').and.returnValue(5 / 6); + + expect(handler.applicableTo(medium.mounted)).toBeTrue(); + expect(handler.applyInventoryControlHeatEffects( + medium.mounted, + { value: 10, weakened: false }, + context, + )).toEqual({ value: 10, weakened: false, suffix: '*' }); + + handler.afterInventoryControlFire(medium.mounted); + expect(medium.addFiredHeat).toHaveBeenCalledOnceWith(6); + expect(medium.setHeat).not.toHaveBeenCalled(); + }); + + it('adds random ground prototype heat to an existing manual heat target', () => { + const medium = fixture('ISMediumPulseLaserPrototype'); + medium.mounted.owner.setHeat(14); + medium.setHeat.calls.reset(); + spyOn(Math, 'random').and.returnValue(5 / 6); + + handler.afterInventoryControlFire(medium.mounted); + + expect(medium.addFiredHeat).toHaveBeenCalledOnceWith(6); + expect(medium.setHeat).toHaveBeenCalledOnceWith(20); + }); + + it('uses 1D3 extra heat for the small prototype pulse laser', () => { + const small = fixture('ISSmallPulseLaserPrototype'); + spyOn(Math, 'random').and.returnValue(5 / 6); + + handler.afterInventoryControlFire(small.mounted); + expect(small.addFiredHeat).toHaveBeenCalledOnceWith(3); + }); + + it('uses maximum extra heat for aerospace firing without a random post-fire roll', () => { + const aero = fixture('ISERLargeLaserPrototype', 'Aero'); + + expect(handler.applyInventoryControlHeatEffects( + aero.mounted, + { value: 12, weakened: false }, + context, + )).toEqual({ value: 18, weakened: false }); + handler.afterInventoryControlFire(aero.mounted); + expect(aero.addFiredHeat).not.toHaveBeenCalled(); + }); + + it('does not claim ordinary lasers', () => { + expect(handler.applicableTo(fixture('ISMediumLaser').mounted)).toBeFalse(); + }); +}); diff --git a/src/app/equipment-handlers/prototype-laser.handler.ts b/src/app/equipment-handlers/prototype-laser.handler.ts new file mode 100644 index 000000000..465608622 --- /dev/null +++ b/src/app/equipment-handlers/prototype-laser.handler.ts @@ -0,0 +1,71 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { MountedEquipment } from '../models/mounted-equipment.model'; +import { WeaponEquipment } from '../models/equipment.model'; +import type { InventoryControlHeatEffect } from '../utils/inventory-control-heat.util'; +import { + EquipmentInteractionHandler, + type HandlerCommandContext, + type HandlerQueryContext, +} from '../services/equipment-interaction-registry.service'; + +const PROTOTYPE_LASER_MAX_EXTRA_HEAT = new Map([ + ['ISSmallPulseLaserPrototype', 3], + ['ISMediumPulseLaserPrototype', 6], + ['ISLargePulseLaserPrototype', 6], + ['ISERLargeLaserPrototype', 6], + ['ISMediumPulseLaserRecovered', 6], +]); + +export class PrototypeLaserHandler extends EquipmentInteractionHandler { + readonly id = 'prototype-laser-handler'; + + override applicableTo(equipment: MountedEquipment): boolean { + return equipment.equipment instanceof WeaponEquipment + && PROTOTYPE_LASER_MAX_EXTRA_HEAT.has(equipment.equipment.internalName); + } + + override getChoices(_equipment: MountedEquipment, _context: HandlerQueryContext) { + return []; + } + + override handleSelection( + _equipment: MountedEquipment, + _choice: never, + _context: HandlerCommandContext, + ): boolean { + return true; + } + + override applyInventoryControlHeatEffects( + equipment: MountedEquipment, + effect: InventoryControlHeatEffect, + _context: HandlerQueryContext, + ): InventoryControlHeatEffect { + const maximum = this.maximumExtraHeat(equipment); + if (maximum === 0) return effect; + if (equipment.owner.getUnit().type === 'Aero') { + return { ...effect, value: effect.value + maximum }; + } + return { ...effect, suffix: '*' }; + } + + override afterInventoryControlFire(equipment: MountedEquipment): void { + if (equipment.owner.getUnit().type === 'Aero') return; + const maximum = this.maximumExtraHeat(equipment); + if (maximum === 0) return; + const roll = Math.floor(Math.random() * 6) + 1; + const extraHeat = maximum === 3 ? Math.ceil(roll / 2) : roll; + const manualHeatTarget = equipment.owner.getHeat().next; + equipment.owner.turnState().addFiredHeat(extraHeat); + if (manualHeatTarget !== undefined) { + equipment.owner.setHeat(manualHeatTarget + extraHeat); + } + } + + private maximumExtraHeat(equipment: MountedEquipment): 0 | 3 | 6 { + return PROTOTYPE_LASER_MAX_EXTRA_HEAT.get(equipment.equipment?.internalName ?? '') ?? 0; + } +} diff --git a/src/app/equipment-handlers/searchlight.handler.spec.ts b/src/app/equipment-handlers/searchlight.handler.spec.ts new file mode 100644 index 000000000..ae228d307 --- /dev/null +++ b/src/app/equipment-handlers/searchlight.handler.spec.ts @@ -0,0 +1,84 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { PickerChoice } from '../components/picker/picker.interface'; +import type { EquipmentFlag } from '../models/equipment-flags.type'; +import { MiscEquipment } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; +import { MountedEquipment } from '../models/mounted-equipment.model'; +import type { DialogsService } from '../services/dialogs.service'; +import { + createHandlerCommandContext, + createHandlerQueryContext, + EquipmentInteractionRegistry, +} from '../services/equipment-interaction-registry.service'; +import type { ToastService } from '../services/toast.service'; +import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; +import { + EQUIPMENT_POWER_OFF_STATE, + EQUIPMENT_POWER_ON_STATE, + EQUIPMENT_POWER_STATE_KEY, + EQUIPMENT_POWER_TURNING_OFF_STATE, +} from '../utils/equipment-power-state.util'; +import { SearchlightHandler } from './searchlight.handler'; + +function entry(flags: EquipmentFlag[]): MountedEquipment { + const { owner } = createTestEquipmentOwner(); + const markEquipmentStateChanged = jasmine.createSpy('markEquipmentStateChanged'); + Object.assign(owner, { turnState: () => ({ markEquipmentStateChanged }) }); + const equipment = new MiscEquipment({ + id: flags.join('-'), + name: 'Searchlight', + type: 'misc', + flags, + }); + const mounted = new MountedEquipment({ + owner, + id: equipment.id, + name: equipment.name, + equipment, + }); + owner.setInventoryEntry(mounted); + return mounted; +} + +describe('SearchlightHandler', () => { + const handler = new SearchlightHandler(); + const queryContext = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); + const commandContext = createHandlerCommandContext( + EMPTY_EQUIPMENT_REGISTRY, + jasmine.createSpyObj('ToastService', ['showToast', 'toasts']), + jasmine.createSpyObj('DialogsService', ['createDialog']), + ); + + it('handles vehicle and battle-armor searchlights only', () => { + expect(handler.applicableTo(entry(['F_SEARCHLIGHT']))).toBeTrue(); + expect(handler.applicableTo(entry(['F_BA_SEARCHLIGHT']))).toBeTrue(); + expect(handler.applicableTo(entry(['F_TAG']))).toBeFalse(); + }); + + it('keeps the searchlight on until its End-Phase shutdown commits', () => { + const mounted = entry(['F_SEARCHLIGHT']); + const registry = new EquipmentInteractionRegistry(); + registry.register(handler); + + const choice = registry.getChoices(mounted, queryContext)[0] as PickerChoice; + expect(choice).toEqual(jasmine.objectContaining({ + label: 'Searchlight is ON', + value: EQUIPMENT_POWER_TURNING_OFF_STATE, + active: true, + })); + + handler.handleSelection(mounted, choice, commandContext); + expect(mounted.states.get(EQUIPMENT_POWER_STATE_KEY)).toBe(EQUIPMENT_POWER_TURNING_OFF_STATE); + expect(registry.getChoices(mounted, queryContext)[0]).toEqual(jasmine.objectContaining({ + label: 'Turning searchlight off…', + value: EQUIPMENT_POWER_ON_STATE, + active: true, + })); + + handler.onEndTurn(mounted); + expect(mounted.states.get(EQUIPMENT_POWER_STATE_KEY)).toBe(EQUIPMENT_POWER_OFF_STATE); + }); +}); diff --git a/src/app/equipment-handlers/searchlight.handler.ts b/src/app/equipment-handlers/searchlight.handler.ts new file mode 100644 index 000000000..a65887f79 --- /dev/null +++ b/src/app/equipment-handlers/searchlight.handler.ts @@ -0,0 +1,31 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { MountedEquipment } from '../models/mounted-equipment.model'; +import { + EQUIPMENT_POWER_OFF_STATE, + EQUIPMENT_POWER_ON_STATE, + EQUIPMENT_POWER_STATE_KEY, + EQUIPMENT_POWER_TURNING_OFF_STATE, + EQUIPMENT_POWER_TURNING_ON_STATE, +} from '../utils/equipment-power-state.util'; +import { ToggleHandler } from './base/toggle.handler'; + +export class SearchlightHandler extends ToggleHandler { + readonly id = 'searchlight-handler'; + override applicableTo(equipment: MountedEquipment): boolean { + return equipment.equipment?.hasAnyFlag(['F_SEARCHLIGHT', 'F_BA_SEARCHLIGHT']) === true; + } + protected override readonly stateKey = EQUIPMENT_POWER_STATE_KEY; + protected override readonly toggleMode = 'transient' as const; + protected override readonly enabledState = EQUIPMENT_POWER_ON_STATE; + protected override readonly enablingState = EQUIPMENT_POWER_TURNING_ON_STATE; + protected override readonly disabledState = EQUIPMENT_POWER_OFF_STATE; + protected override readonly disablingState = EQUIPMENT_POWER_TURNING_OFF_STATE; + protected override readonly defaultEnabled = true; + protected override readonly enabledLabel = 'Searchlight is ON'; + protected override readonly enablingLabel = 'Turning searchlight on…'; + protected override readonly disabledLabel = 'Searchlight is OFF'; + protected override readonly disablingLabel = 'Turning searchlight off…'; +} diff --git a/src/app/equipment-handlers/shield-mode.handler.spec.ts b/src/app/equipment-handlers/shield-mode.handler.spec.ts new file mode 100644 index 000000000..9df9b3d90 --- /dev/null +++ b/src/app/equipment-handlers/shield-mode.handler.spec.ts @@ -0,0 +1,176 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { Equipment } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; +import { MountedEquipment } from '../models/mounted-equipment.model'; +import { TW_GAME_RULES } from '../models/rules/game-rules'; +import type { DialogsService } from '../services/dialogs.service'; +import { createHandlerCommandContext, createHandlerQueryContext } from '../services/equipment-interaction-registry.service'; +import type { ToastService } from '../services/toast.service'; +import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; +import type { InventoryControlDisplayData } from '../utils/inventory-control.util'; +import { + selectedShieldMode, + shieldProtectsLocation, + SHIELD_INACTIVE_MODE, + SHIELD_PASSIVE_MODE, + SHIELD_RAISED_MODE, +} from '../utils/shield-mode.util'; +import { ShieldModeHandler } from './shield-mode.handler'; + +describe('ShieldModeHandler', () => { + const handler = new ShieldModeHandler(); + const queryContext = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); + const commandContext = createHandlerCommandContext( + EMPTY_EQUIPMENT_REGISTRY, + jasmine.createSpyObj('ToastService', ['showToast']), + jasmine.createSpyObj('DialogsService', ['createDialog']), + ); + const display: InventoryControlDisplayData = { + name: 'Shield (Medium)', location: 'LA', heat: '—', damage: '+2', hit: '—', + min: '—', short: '—', medium: '—', long: '—', + }; + + function mounted(rules = undefined as typeof TW_GAME_RULES | undefined): MountedEquipment { + const { owner, inventory } = createTestEquipmentOwner({ gameRules: rules }); + const equipment = new Equipment({ + id: 'MediumShield', + name: 'Shield (Medium)', + type: 'misc', + flags: ['F_SHIELD', 'S_SHIELD_MEDIUM'], + }); + const entry = new MountedEquipment({ + owner, + id: equipment.id, + name: equipment.name, + equipment, + locations: new Set(['LA']), + }); + inventory.push(entry); + return entry; + } + + it('presents Core internal None as Lowered and resets Raised at end of phase', () => { + const shield = mounted(); + const choice = handler.getChoices(shield, queryContext)[0]; + + expect(choice.value).toBe(SHIELD_INACTIVE_MODE); + expect(choice.choices).toEqual([ + { label: 'Lowered', value: SHIELD_INACTIVE_MODE }, + { label: 'Raised', value: SHIELD_RAISED_MODE }, + ]); + + handler.handleSelection(shield, { ...choice, value: SHIELD_RAISED_MODE }, commandContext); + expect(selectedShieldMode(shield)).toBe(SHIELD_RAISED_MODE); + + handler.onEndPhase(shield); + expect(selectedShieldMode(shield)).toBe(SHIELD_INACTIVE_MODE); + }); + + it('presents the persistent TW Active, Passive, and Inactive modes', () => { + const shield = mounted(TW_GAME_RULES); + const choice = handler.getChoices(shield, queryContext)[0]; + + expect(choice.value).toBe(SHIELD_INACTIVE_MODE); + expect(choice.choices).toEqual([ + { label: 'Inactive', value: SHIELD_INACTIVE_MODE }, + { label: 'Active', value: SHIELD_RAISED_MODE }, + { label: 'Passive', value: SHIELD_PASSIVE_MODE }, + ]); + + handler.handleSelection(shield, { ...choice, value: SHIELD_PASSIVE_MODE }, commandContext); + handler.onEndPhase(shield); + expect(selectedShieldMode(shield)).toBe(SHIELD_PASSIVE_MODE); + }); + + it('keeps Core head and rear-mounted weapons available behind a raised shield', () => { + const shield = mounted(); + handler.handleSelection(shield, { label: 'Mode', value: SHIELD_RAISED_MODE }, commandContext); + + expect(shieldProtectsLocation(shield, 'CT')).toBeTrue(); + expect(shieldProtectsLocation(shield, 'LT')).toBeTrue(); + expect(shieldProtectsLocation(shield, 'HD')).toBeFalse(); + expect(shieldProtectsLocation(shield, 'LT', true)).toBeFalse(); + expect(shieldProtectsLocation(shield, 'RT')).toBeFalse(); + }); + + it('uses the broader TW active-shield arc, including the head and same-side rear weapons', () => { + const shield = mounted(TW_GAME_RULES); + handler.handleSelection(shield, { label: 'Mode', value: SHIELD_RAISED_MODE }, commandContext); + + expect(shieldProtectsLocation(shield, 'HD')).toBeTrue(); + expect(shieldProtectsLocation(shield, 'LT', true)).toBeTrue(); + expect(shieldProtectsLocation(shield, 'CT', true)).toBeFalse(); + }); + + it('hands a raised Core shield over to the newly selected arm', () => { + const { owner, inventory } = createTestEquipmentOwner(); + const equipment = new Equipment({ + id: 'MediumShield', + name: 'Shield (Medium)', + type: 'misc', + flags: ['F_SHIELD', 'S_SHIELD_MEDIUM'], + }); + const left = new MountedEquipment({ + owner, id: 'shield-left', name: equipment.name, equipment, locations: new Set(['LA']), + }); + const right = new MountedEquipment({ + owner, id: 'shield-right', name: equipment.name, equipment, locations: new Set(['RA']), + }); + inventory.push(left, right); + + handler.handleSelection(left, { label: 'Mode', value: SHIELD_RAISED_MODE }, commandContext); + handler.handleSelection(right, { label: 'Mode', value: SHIELD_RAISED_MODE }, commandContext); + + expect(selectedShieldMode(left)).toBe(SHIELD_INACTIVE_MODE); + expect(selectedShieldMode(right)).toBe(SHIELD_RAISED_MODE); + }); + + it('allows both TW shields to remain active', () => { + const { owner, inventory } = createTestEquipmentOwner({ gameRules: TW_GAME_RULES }); + const equipment = new Equipment({ + id: 'MediumShield', + name: 'Shield (Medium)', + type: 'misc', + flags: ['F_SHIELD', 'S_SHIELD_MEDIUM'], + }); + const left = new MountedEquipment({ + owner, id: 'shield-left', name: equipment.name, equipment, locations: new Set(['LA']), + }); + const right = new MountedEquipment({ + owner, id: 'shield-right', name: equipment.name, equipment, locations: new Set(['RA']), + }); + inventory.push(left, right); + + handler.handleSelection(left, { label: 'Mode', value: SHIELD_RAISED_MODE }, commandContext); + handler.handleSelection(right, { label: 'Mode', value: SHIELD_RAISED_MODE }, commandContext); + + expect(selectedShieldMode(left)).toBe(SHIELD_RAISED_MODE); + expect(selectedShieldMode(right)).toBe(SHIELD_RAISED_MODE); + }); + + it('shows the current shield mode in SVG summaries and updates an existing suffix', () => { + const options = { selectedRange: null, hitModifierBreakdown: [], showModeName: true }; + const coreShield = mounted(); + + expect(handler.applyInventoryControlDisplayEffects(coreShield, display, options, queryContext).name) + .toBe('Shield (Medium) (Lowered)'); + handler.handleSelection(coreShield, { label: 'Mode', value: SHIELD_RAISED_MODE }, commandContext); + expect(handler.applyInventoryControlDisplayEffects( + coreShield, + { ...display, name: 'Shield (Medium) (Lowered)' }, + options, + queryContext, + ).name).toBe('Shield (Medium) (Raised)'); + + const twShield = mounted(TW_GAME_RULES); + handler.handleSelection(twShield, { label: 'Mode', value: SHIELD_PASSIVE_MODE }, commandContext); + expect(handler.applyInventoryControlDisplayEffects(twShield, display, options, queryContext).name) + .toBe('Shield (Medium) (Passive)'); + expect(handler.applyInventoryControlDisplayEffects( + twShield, display, { ...options, showModeName: false }, queryContext, + )).toBe(display); + }); +}); diff --git a/src/app/equipment-handlers/shield-mode.handler.ts b/src/app/equipment-handlers/shield-mode.handler.ts new file mode 100644 index 000000000..51fe15979 --- /dev/null +++ b/src/app/equipment-handlers/shield-mode.handler.ts @@ -0,0 +1,86 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { PickerChoice } from '../components/picker/picker.interface'; +import type { EquipmentFlag } from '../models/equipment-flags.type'; +import type { MountedEquipment } from '../models/mounted-equipment.model'; +import { + EquipmentInteractionHandler, + type HandlerCommandContext, + type HandlerQueryContext, +} from '../services/equipment-interaction-registry.service'; +import type { + InventoryControlDisplayData, + InventoryControlDisplayEffectOptions, +} from '../utils/inventory-control.util'; +import { + selectedShieldMode, + setShieldMode, + shieldModeOptions, + SHIELD_INACTIVE_MODE, + SHIELD_RAISED_MODE, + type ShieldMode, +} from '../utils/shield-mode.util'; + +export class ShieldModeHandler extends EquipmentInteractionHandler { + readonly id = 'shield-mode-handler'; + override readonly flags: EquipmentFlag[] = ['F_SHIELD']; + override readonly priority = 100; + + override applicableTo(mounted: MountedEquipment): boolean { + return mounted.equipment?.hasFlag('F_SHIELD') === true; + } + + override getChoices(mounted: MountedEquipment, _context: HandlerQueryContext): PickerChoice[] { + return [{ + label: 'Mode', + value: selectedShieldMode(mounted), + displayType: 'dropdown', + choices: shieldModeOptions(mounted).map(mode => ({ ...mode })), + keepOpen: true, + }]; + } + + override handleSelection( + mounted: MountedEquipment, + choice: PickerChoice, + _context: HandlerCommandContext, + ): boolean { + const mode = String(choice.value) as ShieldMode; + if (!shieldModeOptions(mounted).some(option => option.value === mode)) return true; + if (mounted.owner.gameRules.id === 'core2026' && mode === SHIELD_RAISED_MODE) { + for (const other of mounted.owner.getInventory()) { + if (other.id !== mounted.id + && other.equipment?.hasFlag('F_SHIELD') === true + && selectedShieldMode(other) === SHIELD_RAISED_MODE) { + setShieldMode(other, SHIELD_INACTIVE_MODE); + } + } + } + setShieldMode(mounted, mode); + return true; + } + + override applyInventoryControlDisplayEffects( + mounted: MountedEquipment, + display: InventoryControlDisplayData, + options: InventoryControlDisplayEffectOptions, + _context: HandlerQueryContext, + ): InventoryControlDisplayData { + if (!options.showModeName) return display; + + const selectedMode = selectedShieldMode(mounted); + const label = shieldModeOptions(mounted) + .find(mode => mode.value === selectedMode)?.label ?? selectedMode; + const baseName = display.name.replace(/\s+\((?:Lowered|Raised|Inactive|Active|Passive)\)$/, ''); + return { ...display, name: `${baseName} (${label})` }; + } + + override onEndPhase(mounted: MountedEquipment): void { + if (mounted.owner.gameRules.id === 'core2026' + && selectedShieldMode(mounted) === SHIELD_RAISED_MODE) { + setShieldMode(mounted, SHIELD_INACTIVE_MODE); + } + } +} diff --git a/src/app/equipment-handlers/spot-welder.handler.spec.ts b/src/app/equipment-handlers/spot-welder.handler.spec.ts new file mode 100644 index 000000000..0843af1ed --- /dev/null +++ b/src/app/equipment-handlers/spot-welder.handler.spec.ts @@ -0,0 +1,62 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { MiscEquipment } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; +import { MountedEquipment } from '../models/mounted-equipment.model'; +import type { TurnState } from '../models/turn-state.model'; +import { createHandlerQueryContext } from '../services/equipment-interaction-registry.service'; +import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; +import { SpotWelderHandler } from './spot-welder.handler'; + +describe('SpotWelderHandler', () => { + const handler = new SpotWelderHandler(); + const context = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); + + function fixture() { + const removeFiredHeat = jasmine.createSpy('removeFiredHeat'); + const { owner } = createTestEquipmentOwner(); + Object.assign(owner, { turnState: () => ({ removeFiredHeat }) }); + const equipment = new MiscEquipment({ + id: 'ISSpotWelder', + name: 'Spot Welder', + type: 'misc', + flags: ['F_CLUB', 'S_SPOT_WELDER'], + }); + const mounted = new MountedEquipment({ + owner, + id: equipment.id, + name: equipment.name, + equipment, + }); + owner.setInventoryEntry(mounted); + return { mounted, removeFiredHeat }; + } + + it('displays two heat and moves fired heat into grouped Equipment heat', () => { + const { mounted, removeFiredHeat } = fixture(); + + expect(handler.getInventoryControlHeatEffect()) + .toEqual({ value: 2, weakened: false }); + handler.afterInventoryControlFire(mounted); + + expect(removeFiredHeat).toHaveBeenCalledOnceWith(2); + expect(handler.getInventoryHeatSources(mounted, {} as TurnState, context)).toEqual([{ + id: `spot-welder:${mounted.id}`, + label: 'Spot Welder', + value: 2, + group: 'Equipment', + }]); + }); + + it('counts repeated uses and clears only the per-turn heat state', () => { + const { mounted } = fixture(); + handler.afterInventoryControlFire(mounted); + handler.afterInventoryControlFire(mounted); + expect(handler.getInventoryHeatSources(mounted, {} as TurnState, context)[0].value).toBe(4); + + handler.onEndTurn(mounted); + expect(handler.getInventoryHeatSources(mounted, {} as TurnState, context)).toEqual([]); + }); +}); diff --git a/src/app/equipment-handlers/spot-welder.handler.ts b/src/app/equipment-handlers/spot-welder.handler.ts new file mode 100644 index 000000000..a9689af70 --- /dev/null +++ b/src/app/equipment-handlers/spot-welder.handler.ts @@ -0,0 +1,73 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { MountedEquipment } from '../models/mounted-equipment.model'; +import { EQUIPMENT_HEAT_SOURCE_GROUP, type UnitHeatSource } from '../models/rules/unit-type-rules'; +import type { TurnState } from '../models/turn-state.model'; +import type { InventoryControlHeatEffect } from '../utils/inventory-control-heat.util'; +import { + EquipmentInteractionHandler, + type HandlerCommandContext, + type HandlerQueryContext, +} from '../services/equipment-interaction-registry.service'; + +const SPOT_WELDER_HEAT = 2; +const SPOT_WELDER_USES_STATE_KEY = 'spotWelderUses'; + +export class SpotWelderHandler extends EquipmentInteractionHandler { + readonly id = 'spot-welder-handler'; + + override applicableTo(equipment: MountedEquipment): boolean { + return equipment.equipment?.hasAllFlags(['F_CLUB', 'S_SPOT_WELDER']) === true; + } + + override getChoices(_equipment: MountedEquipment, _context: HandlerQueryContext) { + return []; + } + + override handleSelection( + _equipment: MountedEquipment, + _choice: never, + _context: HandlerCommandContext, + ): boolean { + return true; + } + + override getInventoryControlHeatEffect(): InventoryControlHeatEffect { + return { value: SPOT_WELDER_HEAT, weakened: false }; + } + + override afterInventoryControlFire(equipment: MountedEquipment): void { + const uses = this.useCount(equipment) + 1; + equipment.owner.turnState().removeFiredHeat(SPOT_WELDER_HEAT); + if (equipment.setState(SPOT_WELDER_USES_STATE_KEY, String(uses))) { + equipment.owner.setInventoryEntry(equipment); + } + } + + override getInventoryHeatSources( + equipment: MountedEquipment, + _turnState: TurnState, + _context: HandlerQueryContext, + ): UnitHeatSource[] { + const uses = this.useCount(equipment); + return uses > 0 ? [{ + id: `spot-welder:${equipment.id}`, + label: 'Spot Welder', + value: uses * SPOT_WELDER_HEAT, + group: EQUIPMENT_HEAT_SOURCE_GROUP, + }] : []; + } + + override onEndTurn(equipment: MountedEquipment): void { + if (equipment.deleteState(SPOT_WELDER_USES_STATE_KEY)) { + equipment.owner.setInventoryEntry(equipment); + } + } + + private useCount(equipment: MountedEquipment): number { + const value = Number(equipment.states.get(SPOT_WELDER_USES_STATE_KEY)); + return Number.isInteger(value) && value > 0 ? value : 0; + } +} diff --git a/src/app/equipment-handlers/stealth.handler.spec.ts b/src/app/equipment-handlers/stealth.handler.spec.ts index 6707865f0..c2c4a46ba 100644 --- a/src/app/equipment-handlers/stealth.handler.spec.ts +++ b/src/app/equipment-handlers/stealth.handler.spec.ts @@ -26,13 +26,16 @@ import { createHandlerCommandContext, createHandlerQueryContext } from '../servi import type { DialogsService } from '../services/dialogs.service'; import type { ToastService } from '../services/toast.service'; import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; +import { ECM_PENDING_MODE_STATE_KEY } from '../utils/ecm-state.util'; import { StealthHandler } from './stealth.handler'; import { ECMHandler } from './ecm.handler'; +import type { MotiveModes } from '../models/motiveModes.model'; interface StealthFixture { readonly owner: MountedEquipment['owner']; readonly markEquipmentStateChanged: jasmine.Spy; readonly moveDistance: WritableSignal; + readonly moveMode: WritableSignal; add(id: string, equipment: Equipment, states?: Map): MountedEquipment; } @@ -40,12 +43,16 @@ function fixture(): StealthFixture { const { owner } = createTestEquipmentOwner(); const markEquipmentStateChanged = jasmine.createSpy('markEquipmentStateChanged'); const moveDistance = signal(0); - Object.assign(owner, { turnState: () => ({ markEquipmentStateChanged, moveDistance }) }); + const moveMode = signal('stationary'); + Object.assign(owner, { + turnState: () => ({ markEquipmentStateChanged, moveDistance, effectiveMoveMode: moveMode }), + }); spyOn(owner, 'setInventoryEntry').and.callThrough(); return { owner, markEquipmentStateChanged, moveDistance, + moveMode, add: (id, equipment, states = new Map()) => { const entry = new MountedEquipment({ owner, id, name: equipment.name, equipment, states }); owner.setInventoryEntry(entry); @@ -80,7 +87,9 @@ function misc(id: string, flag: EquipmentFlag): MiscEquipment { id, name: id, type: 'misc', - modes: flag === 'F_CHAMELEON_SHIELD' || flag === 'F_NULL_SIG' ? ['Off', 'On'] : undefined, + modes: flag === 'F_CHAMELEON_SHIELD' || flag === 'F_NULL_SIG' || flag === 'F_VOID_SIG' + ? ['Off', 'On'] + : undefined, flags: [flag], }); } @@ -222,6 +231,40 @@ describe('StealthHandler', () => { expect(stealth.states.get(STEALTH_STATE_KEY)).toBe(STEALTH_DISABLED_STATE); }); + it('forces active stealth off when its ECM is queued to leave its supporting mode', () => { + const test = fixture(); + const stealth = test.add('stealth', stealthArmor(), new Map([ + [STEALTH_STATE_KEY, STEALTH_ENABLED_STATE], + ])); + const ecm = test.add('ecm', misc('ECM', 'F_ECM')); + ecm.states.set(ECM_PENDING_MODE_STATE_KEY, ECMMode.OFF); + + expect(hasFunctionalEcmForStealth(stealth)).toBeTrue(); + expect(hasFunctionalEcmForStealth(stealth, true)).toBeFalse(); + + handler.beforeEquipmentStateCommit(stealth); + + expect(stealth.states.get(STEALTH_STATE_KEY)).toBe(STEALTH_DISABLED_STATE); + }); + + it('keeps active stealth through a queued handoff to another ECM suite', () => { + const test = fixture(); + const stealth = test.add('stealth', stealthArmor(), new Map([ + [STEALTH_STATE_KEY, STEALTH_ENABLED_STATE], + ])); + const currentEcm = test.add('current-ecm', misc('Current ECM', 'F_ECM')); + const nextEcm = test.add('next-ecm', misc('Next ECM', 'F_ECM')); + currentEcm.states.set(ECM_PENDING_MODE_STATE_KEY, ECMMode.OFF); + nextEcm.states.set('ecm_mode', ECMMode.OFF); + nextEcm.states.set(ECM_PENDING_MODE_STATE_KEY, ECMMode.ECM); + + expect(hasFunctionalEcmForStealth(stealth, true)).toBeTrue(); + + handler.beforeEquipmentStateCommit(stealth); + + expect(stealth.states.get(STEALTH_STATE_KEY)).toBe(STEALTH_ENABLED_STATE); + }); + it('activates Chameleon without ECM, contributes 6 heat, and leaves C3 available', () => { const test = fixture(); const chameleon = test.add('chameleon', misc('Chameleon LPS', 'F_CHAMELEON_SHIELD')); @@ -254,6 +297,61 @@ describe('StealthHandler', () => { expect(isC3DisruptingStealthActive(nullSignature)).toBeFalse(); }); + it('activates Void Signature at end turn, consumes its ECM, and contributes grouped heat', () => { + const test = fixture(); + const voidSignature = test.add('void-signature', misc('Void Signature System', 'F_VOID_SIG')); + const ecm = test.add('ecm', misc('ECM', 'F_ECM')); + + handler.handleSelection(voidSignature, handler.getChoices(voidSignature, queryContext)[0], commandContext); + expect(voidSignature.states.get(STEALTH_STATE_KEY)).toBe(STEALTH_ENABLING_STATE); + expect(handler.getInventoryHeatSources(voidSignature, {} as TurnState, queryContext)).toEqual([]); + + handler.onEndTurn(voidSignature); + + expect(handler.getInventoryHeatSources(voidSignature, {} as TurnState, queryContext)).toEqual([{ + id: 'stealth:void-signature', + label: 'Void Signature', + value: 10, + group: 'Equipment', + }]); + expect(isC3DisruptingStealthActive(voidSignature)).toBeTrue(); + expect(new ECMHandler().isActive(ecm)).toBeFalse(); + }); + + it('requires ECM for Void Signature and applies its movement-based protection', () => { + const test = fixture(); + const voidSignature = test.add('void-signature', misc('Void Signature System', 'F_VOID_SIG')); + + expect(handler.getChoices(voidSignature, queryContext)[0].disabled).toBeTrue(); + + test.add('ecm', misc('ECM', 'F_ECM')); + handler.handleSelection(voidSignature, handler.getChoices(voidSignature, queryContext)[0], commandContext); + handler.onEndTurn(voidSignature); + + for (const [distance, modifier] of [[0, 3], [1, 2], [3, 1], [6, 0]] as const) { + test.moveDistance.set(distance); + expect(getActiveStealthTnModifiers(test.owner)).withContext(`${distance} hexes`).toEqual({ + short: modifier, + medium: modifier, + long: modifier, + conventionalInfantry: { + short: Math.max(0, modifier - 1), + medium: Math.max(0, modifier - 1), + long: Math.max(0, modifier - 1), + }, + }); + } + + test.moveMode.set('walk'); + test.moveDistance.set(0); + expect(getActiveStealthTnModifiers(test.owner)).toEqual({ + short: 2, + medium: 2, + long: 2, + conventionalInfantry: { short: 1, medium: 1, long: 1 }, + }); + }); + it('uses the Total Warfare Battle Armor stealth range profiles', () => { const test = fixture(); const improved = test.add('improved', passiveBattleArmorStealth('BA_STEALTH_IMP')); diff --git a/src/app/equipment-handlers/stealth.handler.ts b/src/app/equipment-handlers/stealth.handler.ts index 9301634f4..7d4c1fe16 100644 --- a/src/app/equipment-handlers/stealth.handler.ts +++ b/src/app/equipment-handlers/stealth.handler.ts @@ -16,6 +16,8 @@ import { isStealthEquipmentFunctioning, isStealthSystemEquipment, isSwitchableStealthEquipment, + isVoidSignatureEquipment, + isVoidSignatureFunctioning, STEALTH_DISABLED_STATE, STEALTH_DISABLING_STATE, STEALTH_ENABLED_STATE, @@ -48,7 +50,7 @@ export class StealthHandler extends ToggleHandler { override getChoices(equipment: MountedEquipment, context: HandlerQueryContext): PickerChoice[] { if (!isSwitchableStealthEquipment(equipment)) return []; const choices = super.getChoices(equipment, context); - if (isStealthEquipment(equipment) + if (this.needsFunctionalEcm(equipment) && choices[0]?.value === STEALTH_ENABLING_STATE && !hasFunctionalEcmForStealth(equipment)) { choices[0] = { ...choices[0], disabled: true }; @@ -62,10 +64,15 @@ export class StealthHandler extends ToggleHandler { context: HandlerCommandContext, ): boolean { if (!isSwitchableStealthEquipment(equipment)) return true; - if (isStealthEquipment(equipment) + if (this.needsFunctionalEcm(equipment) && choice.value === STEALTH_ENABLING_STATE && !hasFunctionalEcmForStealth(equipment)) { - context.toastService.showToast('Stealth armor requires a functional ECM suite', 'error'); + context.toastService.showToast( + isVoidSignatureEquipment(equipment) + ? 'Void Signature System requires a functional ECM suite' + : 'Stealth armor requires a functional ECM suite', + 'error', + ); return true; } return super.handleSelection(equipment, choice, context); @@ -81,17 +88,19 @@ export class StealthHandler extends ToggleHandler { ? (isChameleonShieldActive(equipment) ? 6 : 0) : isNullSignatureEquipment(equipment) ? (isNullSignatureActive(equipment) ? 10 : 0) + : isVoidSignatureEquipment(equipment) + ? (isVoidSignatureFunctioning(equipment) ? 10 : 0) : (isStealthEquipmentFunctioning(equipment) ? 10 : 0); return heat > 0 ? [{ id: `stealth:${equipment.id}`, - label: 'Stealth', + label: isVoidSignatureEquipment(equipment) ? 'Void Signature' : 'Stealth', value: heat, group: EQUIPMENT_HEAT_SOURCE_GROUP, }] : []; } override beforeEquipmentStateCommit(equipment: MountedEquipment): void { - this.forceOffWithoutEcm(equipment); + this.forceOffWithoutEcm(equipment, true); } override onEndTurn(equipment: MountedEquipment): void { @@ -99,14 +108,18 @@ export class StealthHandler extends ToggleHandler { super.onEndTurn(equipment); } - private forceOffWithoutEcm(equipment: MountedEquipment): boolean { - if (!isStealthEquipment(equipment) + private forceOffWithoutEcm(equipment: MountedEquipment, next = false): boolean { + if (!this.needsFunctionalEcm(equipment) || !isSwitchableStealthEquipment(equipment) - || hasFunctionalEcmForStealth(equipment)) return false; + || hasFunctionalEcmForStealth(equipment, next)) return false; if (this.getToggleState(equipment) !== STEALTH_DISABLED_STATE && equipment.setState(STEALTH_STATE_KEY, STEALTH_DISABLED_STATE)) { equipment.owner.setInventoryEntry(equipment); } return true; } + + private needsFunctionalEcm(equipment: MountedEquipment): boolean { + return isStealthEquipment(equipment) || isVoidSignatureEquipment(equipment); + } } diff --git a/src/app/models/cbt-force-unit-c3.spec.ts b/src/app/models/cbt-force-unit-c3.spec.ts index 9c30237e1..52f836e06 100644 --- a/src/app/models/cbt-force-unit-c3.spec.ts +++ b/src/app/models/cbt-force-unit-c3.spec.ts @@ -63,7 +63,10 @@ function c3BadgeUnit( shutdown: { value: false, writable: true, configurable: true }, getUnit: { value: () => ({ comp: [] }), configurable: true }, getInventory: { value: () => inventory, configurable: true }, - turnState: { value: () => ({ moveDistance: () => 0 }), configurable: true }, + turnState: { + value: () => ({ moveDistance: () => 0, effectiveMoveMode: () => null }), + configurable: true, + }, getCrewMember: { value: () => ({ getState: () => 'healthy' }), configurable: true }, getEquipmentStatus: { value: (entry: MountedEquipment) => unavailable.has(entry.id) ? 'destroyed' : 'available', diff --git a/src/app/models/cbt-force-unit.model.spec.ts b/src/app/models/cbt-force-unit.model.spec.ts index 5d32aee16..74a49ce0a 100644 --- a/src/app/models/cbt-force-unit.model.spec.ts +++ b/src/app/models/cbt-force-unit.model.spec.ts @@ -51,6 +51,7 @@ import { applyMekCriticalRoll } from '../utils/mek-critical-hit.util'; import type { AutomationMode, CBTAutomationKey } from './options.model'; import { NovaCewsHandler } from '../equipment-handlers/nova-cews.handler'; import { NOVA_CEWS_OFF_STATE, NOVA_CEWS_STATE_KEY } from '../utils/ecm-state.util'; +import { HPG_CHARGING_STATE, HPG_STATE_KEY, HPG_TRANSMITTING_STATE } from '../utils/hpg-state.util'; function createEquipment(): EquipmentMap { const ultraAc20 = new WeaponEquipment({ @@ -5006,6 +5007,50 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(forceUnit.canPerformEquipmentAction(hatchet, 'physical-attack')).toBeTrue(); }); + it('enforces Ground-Mobile HPG weapon and movement restrictions at unit level', () => { + const forceUnit = createForceUnit(createEmptyUnit({ + ...createMekUnit(), + engine: 'Fusion', + walk: 5, + run: 8, + run2: 8, + })); + forceUnit.isLoaded.set(true); + const hpgEquipment = new MiscEquipment({ + id: 'ISGroundMobileHPG', + name: 'Ground-Mobile HPG', + type: 'misc', + flags: ['F_MOBILE_HPG', 'F_MEK_EQUIPMENT'], + }); + const hpg = new MountedEquipment({ + owner: forceUnit, + id: 'ISGroundMobileHPG@CT#0', + name: hpgEquipment.name, + equipment: hpgEquipment, + }); + const weapon = new MountedEquipment({ + owner: forceUnit, + id: 'VariableDamageLaser@RA#0', + name: 'Variable Damage Laser', + equipment: equipment['VariableDamageLaser'], + }); + forceUnit.setInventory([hpg, weapon], true); + + const currentHpg = forceUnit.getInventory().find(entry => entry.id === hpg.id)!; + currentHpg.setState(HPG_STATE_KEY, HPG_CHARGING_STATE); + forceUnit.setInventoryEntry(currentHpg); + + expect(forceUnit.canPerformEquipmentAction(weapon, 'fire')).toBeFalse(); + expect(forceUnit.getAvailableMotiveModes(false).some(option => option.mode === 'walk')).toBeTrue(); + + const transmittingHpg = forceUnit.getInventory().find(entry => entry.id === hpg.id)!; + transmittingHpg.setState(HPG_STATE_KEY, HPG_TRANSMITTING_STATE); + forceUnit.setInventoryEntry(transmittingHpg); + + expect(forceUnit.canPerformEquipmentAction(weapon, 'fire')).toBeFalse(); + expect(forceUnit.getAvailableMotiveModes(false).map(option => option.mode)).toEqual(['stationary']); + }); + it('prevents an unconscious crew from receiving movement or attack selections', () => { const forceUnit = createForceUnit(createEmptyUnit({ ...createMekUnit(), diff --git a/src/app/models/cbt-force-unit.model.ts b/src/app/models/cbt-force-unit.model.ts index 25cef97cb..77c93bd11 100644 --- a/src/app/models/cbt-force-unit.model.ts +++ b/src/app/models/cbt-force-unit.model.ts @@ -53,6 +53,7 @@ import type { C3DegradationSource, C3TargetingResolution, CBTGameRules, MekExplo import { OptionsService } from '../services/options.service'; import type { AutomationMode, CBTAutomationKey } from './options.model'; import { resolveSelectedInventoryWeaponHeat } from '../utils/inventory-control-heat.util'; +import { unitHasBusyHpg, unitHasTransmittingGroundMobileHpg } from '../utils/hpg-state.util'; import { parseInventoryComponentReference } from './inventory-component-reference.model'; import type { InventoryControlPhysicalDamageEffect } from '../utils/inventory-control-physical-damage.util'; import { uuidv7 } from '../utils/uuid.util'; @@ -1509,6 +1510,7 @@ export class CBTForceUnit extends ForceUnit { if (!this.isEquipmentOperational(entry) || this.destroyed || this.getCondition('shutdown')) { return false; } + if (action === 'fire' && unitHasBusyHpg(this)) return false; if (action !== 'provide-passive-effect' && !this.canTakeActiveActions()) return false; if (action === 'physical-attack' && this.isPhysicalActionUnavailable(entry)) return false; if (action === 'fire' && !this.isInventoryWeaponUsableInWater(entry, this.getInventoryControlSelectedAmmo(entry))) return false; @@ -2028,6 +2030,7 @@ export class CBTForceUnit extends ForceUnit { const cannotMove = this.getCondition('immobile') || !this.canTakeActiveActions(); return options .filter(option => option.mode === 'stationary' || !cannotMove) + .filter(option => option.mode === 'stationary' || !unitHasTransmittingGroundMobileHpg(this)) .filter(option => this._rules.isMotiveModeAvailable(option.mode)) .map(option => ({ ...option, @@ -2045,6 +2048,7 @@ export class CBTForceUnit extends ForceUnit { PSRTargetRoll = computed(() => this._rules.PSRTargetRoll()); endPhase() { + this.dispatchEndPhaseEquipmentLifecycle(); this.dispatchBeforeEquipmentStateCommit(); this.resolvePendingCrewDeaths(); this.state.endPhase(); @@ -2054,6 +2058,12 @@ export class CBTForceUnit extends ForceUnit { this.phaseTrigger.update(v => v + 1); // Trigger change detection } + private dispatchEndPhaseEquipmentLifecycle(): void { + const equipmentRegistry = this.injector.get(EquipmentInteractionRegistryService).getRegistry(); + this.forEachCurrentInventoryEntry(entry => + equipmentRegistry.onEndPhase(entry)); + } + private dispatchBeforeEquipmentStateCommit(): void { const equipmentRegistry = this.injector.get(EquipmentInteractionRegistryService).getRegistry(); this.forEachCurrentInventoryEntry(entry => @@ -2452,6 +2462,9 @@ export class CBTForceUnit extends ForceUnit { } public endTurn(automationDecisions: CBTEndTurnAutomationDecisions = {}) { + if (automationDecisions.phaseAlreadyEnded !== true) { + this.dispatchEndPhaseEquipmentLifecycle(); + } const endsForceTurn = !this.force.units().some(unit => unit !== this && unit.turnState().dirty()); const heatAutomationMode = this.automationMode('heatAndDissipationResolution'); const resolveHeat = heatAutomationMode === 'yes' diff --git a/src/app/models/equipment.model.ts b/src/app/models/equipment.model.ts index eb71c24ea..a586bd7d1 100644 --- a/src/app/models/equipment.model.ts +++ b/src/app/models/equipment.model.ts @@ -957,6 +957,13 @@ export function isTorpedoAmmo(ammo: AmmoEquipment | null | undefined): boolean { && (NATIVE_TORPEDO_AMMO_TYPES.has(ammo.ammoType) || ammo.hasMunitionType('M_TORPEDO')); } +/** Coolant Pods are encoded as ammo by MegaMek, but are operated directly as equipment. */ +export function isCoolantPodEquipment( + equipment: Equipment | null | undefined, +): equipment is AmmoEquipment { + return equipment instanceof AmmoEquipment && equipment.ammoType === 'COOLANT_POD'; +} + // ============================================================================ // Misc Equipment Class // ============================================================================ diff --git a/src/app/models/rules/game-rules.spec.ts b/src/app/models/rules/game-rules.spec.ts index 50b4c623b..0950cc3b5 100644 --- a/src/app/models/rules/game-rules.spec.ts +++ b/src/app/models/rules/game-rules.spec.ts @@ -305,6 +305,11 @@ describe('game rules', () => { expect(TW_GAME_RULES.aggregatedEndPhaseConsciousRolls).toBeFalse(); }); + it('defines the ruleset-specific Machine Gun Array cluster modifier', () => { + expect(CORE_2026_GAME_RULES.machineGunArrayClusterModifier).toBe(2); + expect(TW_GAME_RULES.machineGunArrayClusterModifier).toBe(0); + }); + it('owns the ruleset-specific hull-breach result and label', () => { expect(CORE_2026_GAME_RULES.getHullBreachCheckRangeLabel()).toBe('2–4'); expect(CORE_2026_GAME_RULES.hullBreachCheckSucceeds(2)).toBeTrue(); diff --git a/src/app/models/rules/game-rules.ts b/src/app/models/rules/game-rules.ts index 88fa2f4d1..a6b0b6421 100644 --- a/src/app/models/rules/game-rules.ts +++ b/src/app/models/rules/game-rules.ts @@ -183,6 +183,8 @@ export function separateHeatFireModifier(resolution: ToHitResolution): ToHitHeat export abstract class CBTGameRules { abstract readonly id: 'core2026' | 'tw'; + /** Modifier applied to Machine Gun Array rolls on the Cluster Hits Table. */ + abstract readonly machineGunArrayClusterModifier: number; abstract readonly aggregatedEndPhaseConsciousRolls: boolean; abstract readonly c3DegradationLabel: C3DegradationLabel; abstract readonly escalatingFailureTargets: readonly number[]; @@ -476,6 +478,7 @@ export abstract class CBTGameRules { export class GameRules extends CBTGameRules { readonly id = 'core2026' as const; + readonly machineGunArrayClusterModifier = 2; readonly aggregatedEndPhaseConsciousRolls = true; readonly c3DegradationLabel = 'DEGRADED' as const; readonly physicalBaseHitModifiers = { @@ -613,6 +616,7 @@ export class GameRules extends CBTGameRules { export class TWGameRules extends CBTGameRules { readonly id = 'tw' as const; + readonly machineGunArrayClusterModifier = 0; readonly aggregatedEndPhaseConsciousRolls = false; readonly c3DegradationLabel = 'JAMMED' as const; readonly physicalBaseHitModifiers = { diff --git a/src/app/models/rules/mek-rules.spec.ts b/src/app/models/rules/mek-rules.spec.ts index 2c5bdef66..2b5cccfaf 100644 --- a/src/app/models/rules/mek-rules.spec.ts +++ b/src/app/models/rules/mek-rules.spec.ts @@ -28,6 +28,16 @@ import { PPC_CAPACITOR_CHARGED_STATE, PPC_CAPACITOR_CHARGING_STATE, PPC_CAPACITO import { EquipmentFlag } from '../equipment-flags.type'; import { isInventoryControlSelectableEntry, syncSvgMode } from '../../utils/inventory-control.util'; import { MEK_LOCATIONS, MEK_QUAD_LOCATIONS, MEK_TRIPOD_LOCATIONS } from '../entity/types'; +import { ECMMode } from '../common.model'; +import { STEALTH_ENABLED_STATE, STEALTH_STATE_KEY } from '../stealth-equipment.model'; +import { + selectedShieldMode, + setShieldMode, + SHIELD_INACTIVE_MODE, + SHIELD_PASSIVE_MODE, + SHIELD_RAISED_MODE, +} from '../../utils/shield-mode.util'; +import { ShieldModeHandler } from '../../equipment-handlers/shield-mode.handler'; class TestCBTForce extends CBTForce { override emitChanged(): void { @@ -61,7 +71,7 @@ function createRulesHarness(options: { jump?: number; umu?: number; tons?: number; - engine?: string; + engine?: UnitSummary['engine']; subtype?: UnitSubtype; rulesId?: 'core2026' | 'tw'; forcedWithdrawal?: boolean; @@ -136,7 +146,7 @@ function createForceUnitHarness(options: { jump?: number; umu?: number; tons?: number; - engine?: string; + engine?: UnitSummary['engine']; subtype?: UnitSubtype; rulesId?: 'core2026' | 'tw'; forcedWithdrawal?: boolean; @@ -581,6 +591,113 @@ describe('MekRules', () => { } }); + it('adds one UMU heat, doubled by XXL engines', () => { + const scenarios = [ + { engine: 'Fusion', expected: 1 }, + { engine: 'XXL (IS)', expected: 2 }, + { engine: 'XXL (Clan)', expected: 2 }, + ] as const; + for (const scenario of scenarios) { + const forceUnit = createForceUnitHarness({ engine: scenario.engine }); + forceUnit.turnState().moveMode.set('UMU'); + + expect(forceUnit.rules.heatSources(forceUnit.turnState()) + .find(source => source.id === 'movement')?.value) + .withContext(scenario.engine) + .toBe(scenario.expected); + } + }); + + it('generates no jump heat when a working mechanical jump booster is used', () => { + const forceUnit = createForceUnitHarness(); + const booster = miscEquipment('MechanicalJumpBooster', 'Mechanical Jump Booster', ['F_JUMP_BOOSTER']); + forceUnit.setInventory([miscEntry(forceUnit, booster)]); + forceUnit.turnState().moveMode.set('jump'); + forceUnit.turnState().moveDistance.set(5); + + expect(forceUnit.rules.heatSources(forceUnit.turnState()) + .find(source => source.id === 'movement')?.value).toBe(0); + }); + + it('defaults dual jump systems to jump-jet heat like MegaMek', () => { + const forceUnit = createForceUnitHarness(); + const booster = miscEquipment('MechanicalJumpBooster', 'Mechanical Jump Booster', ['F_JUMP_BOOSTER']); + const jumpJet = miscEquipment('JumpJet', 'Jump Jet', ['F_JUMP_JET']); + forceUnit.setInventory([ + miscEntry(forceUnit, booster), + miscEntry(forceUnit, jumpJet), + ]); + forceUnit.turnState().moveMode.set('jump'); + forceUnit.turnState().moveDistance.set(5); + + expect(forceUnit.rules.heatSources(forceUnit.turnState()) + .find(source => source.id === 'movement')?.value).toBe(5); + }); + + it('uses AirMek movement heat while an LAM is airborne', () => { + const forceUnit = createForceUnitHarness({ subtype: 'Land-Air BattleMek' }); + forceUnit.turnState().airborne.set(true); + forceUnit.turnState().moveMode.set('run'); + + for (const [distance, expectedHeat] of [[1, 1], [4, 1], [5, 2]] as const) { + forceUnit.turnState().moveDistance.set(distance); + expect(forceUnit.rules.heatSources(forceUnit.turnState()) + .find(source => source.id === 'movement')?.value) + .withContext(`${distance} AirMek MP`) + .toBe(expectedHeat); + } + }); + + it('only exempts ICE and Fuel Cell Industrial Meks from ground-movement heat', () => { + const scenarios = [ + { engine: 'ICE', expected: [0, 0, 0] }, + { engine: 'Fuel Cell', expected: [0, 0, 0] }, + { engine: 'Fusion', expected: [1, 2, 3] }, + { engine: 'XXL (IS)', expected: [4, 6, 9] }, + ] as const; + const modes = ['walk', 'run', 'sprint'] as const; + for (const { engine, expected } of scenarios) { + const forceUnit = createForceUnitHarness({ subtype: 'Industrial Mek', engine }); + for (const [index, mode] of modes.entries()) { + forceUnit.turnState().moveMode.set(mode); + expect(forceUnit.rules.heatSources(forceUnit.turnState()) + .find(source => source.id === 'movement')?.value) + .withContext(`${engine} ${mode}`) + .toBe(expected[index]); + } + } + }); + + it('generates damaged-engine heat only for fusion-family engines', () => { + for (const engine of [ + 'ICE', 'Fuel Cell', 'Fission', 'None', 'MagLev', 'Steam', 'Battery', 'Solar', 'External', + ] as const) { + const forceUnit = createForceUnitHarness({ + engine, + critSlots: [{ ...crit('Engine'), loc: 'CT', slot: 0 }], + }); + + expect(forceUnit.rules.heatSources(forceUnit.turnState()) + .find(source => source.id === 'damaged-engine')) + .withContext(engine) + .toBeUndefined(); + } + + for (const engine of [ + 'Fusion', 'XL (IS)', 'XL (Clan)', 'XXL (IS)', 'XXL (Clan)', 'Light', 'Compact', + ] as const) { + const forceUnit = createForceUnitHarness({ + engine, + critSlots: [{ ...crit('Engine'), loc: 'CT', slot: 0 }], + }); + + expect(forceUnit.rules.heatSources(forceUnit.turnState()) + .find(source => source.id === 'damaged-engine')?.value) + .withContext(engine) + .toBe(5); + } + }); + beforeEach(() => { dataService = jasmine.createSpyObj('DataService', ['getEquipmentRegistry', 'findEquipment', 'getUnitByName']); dataService.getEquipmentRegistry.and.returnValue(new EquipmentRegistry({})); @@ -1241,6 +1358,58 @@ describe('MekRules', () => { expect(isInventoryControlSelectableEntry(shield)).toBeFalse(); }); + it('blocks a Core punch while its shield is raised without removing the lowered punch bonus', () => { + const { forceUnit, shield } = createShieldHarness('core2026'); + const rules = forceUnit.rules as MekRules; + const punch = punchEntry(forceUnit, 'LA'); + + expect(setShieldMode(shield, SHIELD_INACTIVE_MODE)).toBeTrue(); + expect(rules.canPerformEquipmentAction(punch, 'physical-attack')).toBeTrue(); + expect(rules.computeMeleeDamage(7, 'punch', 'LA')).toEqual({ damage: 9, maxDamage: 18 }); + + expect(setShieldMode(shield, SHIELD_RAISED_MODE)).toBeTrue(); + expect(rules.canPerformEquipmentAction(punch, 'physical-attack')).toBeFalse(); + expect(rules.computeMeleeDamage(7, 'punch', 'LA')).toEqual({ damage: 9, maxDamage: 18 }); + }); + + it('lowers a raised Core shield at phase end but keeps TW shield modes persistent', () => { + TestBed.inject(EquipmentInteractionRegistryService).getRegistry().register(new ShieldModeHandler()); + const core = createShieldHarness('core2026'); + const tw = createShieldHarness('tw'); + + expect(setShieldMode(core.shield, SHIELD_RAISED_MODE)).toBeTrue(); + expect(setShieldMode(tw.shield, SHIELD_PASSIVE_MODE)).toBeTrue(); + + core.forceUnit.endPhase(); + tw.forceUnit.endPhase(); + + expect(selectedShieldMode(core.forceUnit.getInventory()[0])).toBe(SHIELD_INACTIVE_MODE); + expect(selectedShieldMode(tw.forceUnit.getInventory()[0])).toBe(SHIELD_PASSIVE_MODE); + }); + + it('allows Core AMS fire through a raised shield while continuing to block other weapons', () => { + const { forceUnit, shield } = createShieldHarness('core2026'); + const rules = forceUnit.rules as MekRules; + const mountedWeapon = (id: string, flags: EquipmentFlag[] = []) => new MountedWeapon({ + owner: forceUnit, + id, + name: id, + equipment: new WeaponEquipment({ + id, + name: id, + type: 'weapon', + flags, + weapon: { damage: 2, heat: 1, ranges: [1, 2, 3, 4], ammoType: 'NA' }, + }), + locations: new Set(['LA']), + }); + + expect(setShieldMode(shield, SHIELD_RAISED_MODE)).toBeTrue(); + expect(rules.canPerformEquipmentAction(mountedWeapon('Arm Laser'), 'fire')).toBeFalse(); + expect(rules.canPerformEquipmentAction(mountedWeapon('AMS', ['F_AMS']), 'fire')).toBeTrue(); + expect(rules.canPerformEquipmentAction(mountedWeapon('AMS Bay', ['F_AMS_BAY']), 'fire')).toBeTrue(); + }); + it('shows the Core shield bash modifier for every shield size', () => { for (const [sizeFlag, bashBonus] of [ ['S_SHIELD_SMALL', 1], @@ -1315,6 +1484,39 @@ describe('MekRules', () => { expect(isInventoryControlSelectableEntry(shield)).toBeTrue(); }); + it('applies TW shield mode firing penalties and blocks attacks from raised protection', () => { + const { forceUnit, shield } = createShieldHarness('tw'); + const rules = forceUnit.rules as MekRules; + const equipment = new WeaponEquipment({ + id: 'ArmLaser', + name: 'Arm Laser', + type: 'weapon', + weapon: { damage: 5, heat: 3, ranges: [3, 6, 9, 12], ammoType: 'NA' }, + }); + const armLaser = new MountedWeapon({ + owner: forceUnit, + id: equipment.id, + name: equipment.name, + equipment, + locations: new Set(['LA']), + }); + + expect(rules.getEquipmentToHitModifiers(armLaser)).toContain(jasmine.objectContaining({ + label: 'Shield (LA)', + modifier: 1, + })); + + expect(setShieldMode(shield, SHIELD_PASSIVE_MODE)).toBeTrue(); + expect(rules.getEquipmentToHitModifiers(armLaser)).toContain(jasmine.objectContaining({ + label: 'Passive Shield (LA)', + modifier: 2, + })); + + expect(setShieldMode(shield, SHIELD_RAISED_MODE)).toBeTrue(); + expect(rules.canPerformEquipmentAction(armLaser, 'fire')).toBeFalse(); + expect(rules.canPerformEquipmentAction(shield, 'physical-attack')).toBeFalse(); + }); + it('does not render either passive Core or active TW shields as disabled inventory', () => { for (const rulesId of ['core2026', 'tw'] as const) { const { forceUnit, shield } = createShieldHarness(rulesId); @@ -2226,6 +2428,29 @@ describe('MekRules', () => { } }); + it('applies the active Void Signature penalty only to weapon attacks', () => { + const forceUnit = createForceUnitHarness(); + const voidSignature = miscEntry(forceUnit, + miscEquipment('ISVoidSignatureSystem', 'Void Signature System', ['F_VOID_SIG'])); + voidSignature.states.set(STEALTH_STATE_KEY, STEALTH_ENABLED_STATE); + const ecm = miscEntry(forceUnit, miscEquipment('ISECM', 'ECM Suite', ['F_ECM'])); + ecm.states.set('ecm_mode', ECMMode.ECM); + forceUnit.setInventory([voidSignature, ecm]); + + expect(forceUnit.rules.getEquipmentToHitModifiers(directFireWeaponEntry(forceUnit))).toContain({ + label: 'Void Signature', + modifier: 1, + }); + expect(forceUnit.rules.getEquipmentToHitModifiers(punchEntry(forceUnit))) + .not.toContain(jasmine.objectContaining({ label: 'Void Signature' })); + + const liveEcm = forceUnit.getInventory().find(entry => entry.id === ecm.id)!; + liveEcm.setState('ecm_mode', ECMMode.OFF); + forceUnit.setInventoryEntry(liveEcm); + expect(forceUnit.rules.getEquipmentToHitModifiers(directFireWeaponEntry(forceUnit))) + .not.toContain(jasmine.objectContaining({ label: 'Void Signature' })); + }); + it('uses the best available alternate pilot with a modifier when the Tripod dedicated pilot is disabled', () => { const forceUnit = createForceUnitHarness({ subtype: 'Tripod BattleMek', crewStates: ['unconscious', 'healthy', 'healthy'] }); forceUnit.getCrewMember(0).setSkill('piloting', 5); diff --git a/src/app/models/rules/mek-rules.ts b/src/app/models/rules/mek-rules.ts index fd69dcaaf..ee3e509f9 100644 --- a/src/app/models/rules/mek-rules.ts +++ b/src/app/models/rules/mek-rules.ts @@ -25,13 +25,22 @@ import { type MekConfig, } from '../entity/types'; import { resolveShieldProfile, type ShieldProfile } from '../entity/utils/physical-weapon'; -import type { Equipment } from '../equipment.model'; +import { WeaponEquipment, type Equipment } from '../equipment.model'; import type { EquipmentFlag } from '../equipment-flags.type'; +import { isFusionUnitEngine } from '../unit-summary.model'; export { LEG_LOCATIONS } from '../entity/types'; import type { InventoryControlDisplayData } from '../../utils/inventory-control.util'; import type { ToHitModifierBreakdownEntry } from './game-rules'; import { uuidv7 } from '../../utils/uuid.util'; +import { + isShieldRaised, + selectedShieldMode, + shieldMountingArm, + shieldProtectsLocation, + SHIELD_INACTIVE_MODE, + SHIELD_PASSIVE_MODE, +} from '../../utils/shield-mode.util'; type ArmLocation = 'LA' | 'RA'; @@ -1020,23 +1029,43 @@ export class MekRules extends UnitTypeRulesBase { const moveMode = turnState.effectiveMoveMode(); const hasXXLEngine = this.hasXXLEngine(); const superCooledMyomerActive = this.hasActiveSuperCooledMyomer(); + const heatlessIndustrialEngine = this.hasHeatlessIndustrialEngine(); if (moveMode === 'stationary') { if (superCooledMyomerActive) return 0; return hasXXLEngine ? 2 : 0; } else if (moveMode === 'walk') { if (superCooledMyomerActive) return 0; + if (heatlessIndustrialEngine) return 0; + if (this.isUsingAirMekMovement(turnState)) return this.computeAirMekHeat(turnState, hasXXLEngine); return hasXXLEngine ? 4 : 1; } else if (moveMode === 'run' || moveMode === 'sprint') { if (superCooledMyomerActive) return 0; + if (heatlessIndustrialEngine) return 0; + if (this.isUsingAirMekMovement(turnState)) return this.computeAirMekHeat(turnState, hasXXLEngine); const runningHeat = hasXXLEngine ? 6 : 2; return moveMode === 'sprint' ? Math.floor(runningHeat * 1.5) : runningHeat; } else if (moveMode === 'jump') { + // MegaMek defaults a dual-system Mek to its jump jets; merely + // carrying a booster must not erase their movement heat. Booster- + // only Meks still generate no heat when they jump. + if (this.hasWorkingMechanicalJumpBooster() && !this.hasWorkingJumpJets()) return 0; const distance = turnState.moveDistance() || 0; return this.computeJumpHeat(distance, hasXXLEngine); + } else if (moveMode === 'UMU') { + return hasXXLEngine ? 2 : 1; } return 0; } + private isUsingAirMekMovement(turnState: TurnState): boolean { + return this.unit.getUnit().subtype === 'Land-Air BattleMek' && turnState.airborne() === true; + } + + private computeAirMekHeat(turnState: TurnState, hasXXLEngine: boolean): number { + const distance = turnState.moveDistance() || 0; + return Math.round(this.computeJumpHeat(distance, hasXXLEngine) / 3); + } + private computeJumpHeat(distance: number, hasXXLEngine: boolean): number { const partialWingBonus = this.partialWingJumpBonus(); const heatDistance = Math.max(0, distance - partialWingBonus); @@ -1075,6 +1104,33 @@ export class MekRules extends UnitTypeRulesBase { return this.unit.getUnit().engine?.startsWith('XXL ') ?? false; } + private isIndustrialMek(): boolean { + return this.unit.getUnit().subtype?.endsWith('Industrial Mek') === true; + } + + private hasHeatlessIndustrialEngine(): boolean { + const engine = this.unit.getUnit().engine; + return this.isIndustrialMek() && (engine === 'ICE' || engine === 'Fuel Cell'); + } + + private hasWorkingMechanicalJumpBooster(): boolean { + return this.unit.getInventory().some(entry => + entry.equipment?.hasFlag('F_JUMP_BOOSTER') === true + && this.unit.isEquipmentOperational(entry)) + || this.unit.getCritSlots().some(slot => + slot.eq?.hasFlag('F_JUMP_BOOSTER') === true + && this.unit.isEquipmentOperational(slot)); + } + + private hasWorkingJumpJets(): boolean { + return this.unit.getInventory().some(entry => + entry.equipment?.hasFlag('F_JUMP_JET') === true + && this.unit.isEquipmentOperational(entry)) + || this.unit.getCritSlots().some(slot => + slot.eq?.hasFlag('F_JUMP_JET') === true + && this.unit.isEquipmentOperational(slot)); + } + private hasActiveSuperCooledMyomer(): boolean { const superCooledMyomerSlots = this.unit.getCritSlots().filter(slot => this.isSuperCooledMyomerSlot(slot)); return superCooledMyomerSlots.length > 0 @@ -1087,8 +1143,10 @@ export class MekRules extends UnitTypeRulesBase { private computeDamagedEngineHeat(): number { if (this.unit.destroyed || this.unit.shutdown) return 0; + if (!isFusionUnitEngine(this.unit.getUnit().engine)) return 0; const critSlots = this.unit.getCritSlots(); - const engineHits = critSlots.filter(slot => this.isNamedCrit(slot, 'Engine') && this.isDestroyedOrDestroyingCrit(slot)).length; + const engineHits = critSlots.filter(slot => + this.isNamedCrit(slot, 'Engine') && this.isDestroyedOrDestroyingCrit(slot)).length; return Math.min(10, engineHits * 5); } @@ -2531,6 +2589,9 @@ export class MekRules extends UnitTypeRulesBase { override canPerformEquipmentAction(entry: MountedEquipment, action: EquipmentAction): boolean { if ((action === 'fire' || action === 'physical-attack') && this.unit.turnState().effectiveMoveMode() === 'sprint') return false; + if ((action === 'fire' || action === 'physical-attack') + && !this.isCoreShieldAmsExempt(entry, action) + && this.hasRaisedShieldProtectingEntry(entry)) return false; if (action === 'fire') return this.fireControl()?.canFire ?? true; if (action !== 'physical-attack') return true; if (entry.equipment?.hasFlag('F_SHIELD') && !this.standaloneShieldDamageEnabled) return false; @@ -2595,7 +2656,11 @@ export class MekRules extends UnitTypeRulesBase { const fire = this.fireControl(); const systemsStatus = this.systemsStatus(); if (!physical || !fire) { - return [...hitModifierBreakdown, ...this.getUnitEquipmentToHitModifiers(entry)]; + return [ + ...hitModifierBreakdown, + ...this.getShieldAttackToHitModifiers(entry), + ...this.getUnitEquipmentToHitModifiers(entry), + ]; } if (entry.isIntrinsicPhysicalAttack()) { @@ -2670,7 +2735,69 @@ export class MekRules extends UnitTypeRulesBase { const tarcompWeapon = entry.parent ?? entry; hitModifierBreakdown.push(...this.getMountedTargetingComputerModifiers(tarcompWeapon)); } - return [...hitModifierBreakdown, ...this.getUnitEquipmentToHitModifiers(entry)]; + return [ + ...hitModifierBreakdown, + ...this.getShieldAttackToHitModifiers(entry), + ...this.getUnitEquipmentToHitModifiers(entry), + ]; + } + + private hasRaisedShieldProtectingEntry(entry: MountedEquipment): boolean { + const locations = this.equipmentLocations(entry); + if (locations.length === 0) return false; + const rearMounted = this.isRearMounted(entry); + return this.operationalShields().some(shield => + isShieldRaised(shield) + && locations.some(location => shieldProtectsLocation(shield, location, rearMounted))); + } + + private isCoreShieldAmsExempt(entry: MountedEquipment, action: EquipmentAction): boolean { + return this.unit.gameRules.id === 'core2026' + && action === 'fire' + && entry.equipment?.hasAnyFlag(['F_AMS', 'F_AMS_BAY']) === true; + } + + private getShieldAttackToHitModifiers(entry: MountedEquipment): ToHitModifierBreakdownEntry[] { + if (this.unit.gameRules.id !== 'tw' + || entry.isIntrinsicPhysicalAttack() + || (!(entry.equipment instanceof WeaponEquipment) && !entry.isPhysicalWeapon())) return []; + + const locations = this.equipmentLocations(entry); + if (locations.length === 0) return []; + const rearMounted = this.isRearMounted(entry); + let inactiveShieldArm: 'LA' | 'RA' | null = null; + for (const shield of this.operationalShields()) { + const mode = selectedShieldMode(shield); + const arm = shieldMountingArm(shield); + if (!arm || !locations.some(location => shieldProtectsLocation(shield, location, rearMounted))) continue; + if (mode === SHIELD_PASSIVE_MODE) { + return [{ label: `Passive Shield (${arm})`, modifier: 2 }]; + } + if (mode === SHIELD_INACTIVE_MODE) inactiveShieldArm ??= arm; + } + return inactiveShieldArm + ? [{ label: `Shield (${inactiveShieldArm})`, modifier: 1 }] + : []; + } + + private operationalShields(): MountedEquipment[] { + return this.unit.getMountedEquipmentByFlag('F_SHIELD').filter(shield => { + if (!this.unit.isEquipmentOperational(shield)) return false; + const damage = this.getShieldDamageState(shield); + return damage !== null && damage.absorption > 0 && damage.capacity > 0; + }); + } + + private equipmentLocations(entry: MountedEquipment): string[] { + return Array.from(new Set([ + ...Array.from(entry.locations ?? []), + ...this.entryCriticalSlots(entry).flatMap(slot => slot.loc ?? []), + ])); + } + + private isRearMounted(entry: MountedEquipment): boolean { + const value = entry.el?.getAttribute('rearMounted')?.toLowerCase(); + return value === '1' || value === 'true'; } private getKickToHitModifierBreakdown(destroyedLegActuatorsCount: number): ToHitModifierBreakdownEntry[] { diff --git a/src/app/models/rules/unit-type-rules.ts b/src/app/models/rules/unit-type-rules.ts index 10f0ac34b..6915fb8ab 100644 --- a/src/app/models/rules/unit-type-rules.ts +++ b/src/app/models/rules/unit-type-rules.ts @@ -18,7 +18,7 @@ import { TN_SKIDDING_ATTACKER, TN_SKIDDING_MODIFIER, } from '../target-number-calculator.model'; -import { getActiveStealthTnModifiers } from '../stealth-equipment.model'; +import { getActiveStealthTnModifiers, unitHasActiveVoidSignature } from '../stealth-equipment.model'; import type { CBTForceUnit, EquipmentAction } from '../cbt-force-unit.model'; import type { HeatDissipationState, HeatScaleEntry } from './heat-management'; import type { InventoryControlDisplayData } from '../../utils/inventory-control.util'; @@ -677,7 +677,10 @@ export abstract class UnitTypeRulesBase implements UnitTypeRules { : entry.equipment instanceof WeaponEquipment ? this.rangedHitModifiers() : []; - return unitModifiers; + if (!(entry.equipment instanceof WeaponEquipment) || !unitHasActiveVoidSignature(this.unit)) { + return unitModifiers; + } + return [...unitModifiers, { label: 'Void Signature', modifier: 1 }]; } protected getMountedTargetingComputerModifiers(entry: MountedEquipment): ToHitModifierBreakdownEntry[] { diff --git a/src/app/models/stealth-equipment.model.ts b/src/app/models/stealth-equipment.model.ts index a953f5c4b..6d12d0e93 100644 --- a/src/app/models/stealth-equipment.model.ts +++ b/src/app/models/stealth-equipment.model.ts @@ -14,7 +14,7 @@ import { type TnRangeModifiers, type TnStealthModifiers, } from './target-number-calculator.model'; -import { getEffectiveEcmMode } from '../utils/ecm-state.util'; +import { getEffectiveEcmMode, getNextEffectiveEcmMode } from '../utils/ecm-state.util'; export const STEALTH_STATE_KEY = 'state'; export const STEALTH_ENABLED_STATE = 'enabled'; @@ -67,11 +67,16 @@ export function isNullSignatureEquipment(equipment: MountedEquipment): boolean { return equipment.equipment?.flags.has('F_NULL_SIG') === true; } +export function isVoidSignatureEquipment(equipment: MountedEquipment): boolean { + return equipment.equipment?.flags.has('F_VOID_SIG') === true; +} + export function isStealthSystemEquipment(equipment: MountedEquipment): boolean { return isStealthEquipment(equipment) || isVisualCamoEquipment(equipment) || isChameleonShieldEquipment(equipment) - || isNullSignatureEquipment(equipment); + || isNullSignatureEquipment(equipment) + || isVoidSignatureEquipment(equipment); } export function isSwitchableStealthEquipment(equipment: MountedEquipment): boolean { @@ -104,23 +109,28 @@ export function isNullSignatureActive(equipment: MountedEquipment): boolean { return isNullSignatureEquipment(equipment) && isPassiveOrToggleActive(equipment); } +export function isVoidSignatureActive(equipment: MountedEquipment): boolean { + return isVoidSignatureEquipment(equipment) && isPassiveOrToggleActive(equipment); +} + export function isStealthSystemActive(equipment: MountedEquipment): boolean { return isStealthEquipmentActive(equipment) || isVisualCamoActive(equipment) || isChameleonShieldActive(equipment) - || isNullSignatureActive(equipment); + || isNullSignatureActive(equipment) + || isVoidSignatureActive(equipment); } /** Only ECM-bearing modes power Stealth Armor; ECCM and plain Ghost do not. */ -export function ecmModeSupportsStealth(equipment: MountedEquipment): boolean { - const mode = getEffectiveEcmMode(equipment); +export function ecmModeSupportsStealth(equipment: MountedEquipment, next = false): boolean { + const mode = next ? getNextEffectiveEcmMode(equipment) : getEffectiveEcmMode(equipment); return mode === ECMMode.ECM || mode === ECMMode.ECM_ECCM || mode === ECMMode.ECM_GHOST; } /** Switchable Stealth Armor needs an operable ECM suite in an ECM-bearing mode. */ -export function hasFunctionalEcmForStealth(equipment: MountedEquipment): boolean { +export function hasFunctionalEcmForStealth(equipment: MountedEquipment, next = false): boolean { const owner = equipment.owner; const roots = [ ...(owner.getInventory?.() ?? []), @@ -137,7 +147,7 @@ export function hasFunctionalEcmForStealth(equipment: MountedEquipment): boolean pending.push(...(candidate.linkedWith ?? [])); if (candidate.parent) pending.push(candidate.parent); - if (candidate.equipment?.flags.has('F_ECM') !== true || !ecmModeSupportsStealth(candidate)) continue; + if (candidate.equipment?.flags.has('F_ECM') !== true || !ecmModeSupportsStealth(candidate, next)) continue; if (candidate.committedDestroyed() || candidate.isDestroying() || owner.isEquipmentOperational?.(candidate) === false) continue; @@ -151,9 +161,14 @@ export function isStealthEquipmentFunctioning(equipment: MountedEquipment): bool return !isSwitchableStealthEquipment(equipment) || hasFunctionalEcmForStealth(equipment); } +export function isVoidSignatureFunctioning(equipment: MountedEquipment): boolean { + return isVoidSignatureActive(equipment) && hasFunctionalEcmForStealth(equipment); +} + /** Active Stealth Armor cuts C3 and suppresses the unit's ECM-sensitive systems. */ export function isC3DisruptingStealthActive(equipment: MountedEquipment): boolean { - return isSwitchableStealthEquipment(equipment) && isStealthEquipmentFunctioning(equipment); + return isSwitchableStealthEquipment(equipment) + && (isStealthEquipmentFunctioning(equipment) || isVoidSignatureFunctioning(equipment)); } export function unitHasActiveC3DisruptingStealth(unit: CBTForceUnit): boolean { @@ -164,6 +179,15 @@ export function unitHasActiveC3DisruptingStealth(unit: CBTForceUnit): boolean { )); } +/** Void Signature penalizes every weapon attack made by its carrying unit. */ +export function unitHasActiveVoidSignature(unit: CBTForceUnit): boolean { + if (unit.destroyed || unit.getCondition('shutdown')) return false; + return (unit.getInventory?.() ?? []).some(equipment => ( + unit.isEquipmentOperational?.(equipment) !== false + && isVoidSignatureFunctioning(equipment) + )); +} + function infantryIgnoredProfile(short: number, medium: number, long: number): TnStealthModifiers { return { short, medium, long, conventionalInfantry: ZERO_RANGE_MODIFIERS }; } @@ -173,6 +197,20 @@ export function getStealthTnModifiersForEquipment( equipment: MountedEquipment, targetMoveDistance = 0, ): TnStealthModifiers | null { + if (isVoidSignatureFunctioning(equipment)) { + const modifier = targetMoveDistance > 5 ? 0 : targetMoveDistance > 2 ? 1 : targetMoveDistance > 0 ? 2 : 3; + const infantryModifier = Math.max(0, modifier - 1); + return { + short: modifier, + medium: modifier, + long: modifier, + conventionalInfantry: { + short: infantryModifier, + medium: infantryModifier, + long: infantryModifier, + }, + }; + } if (isChameleonShieldActive(equipment)) return TN_CHAMELEON_MODIFIERS; if (isNullSignatureActive(equipment)) return TN_NULL_SIGNATURE_MODIFIERS; if (isMimeticArmorEquipment(equipment) && isVisualCamoActive(equipment)) { @@ -248,7 +286,15 @@ export function getActiveStealthTnModifiers(unit: CBTForceUnit): TnStealthModifi if (unit.destroyed || unit.getCondition('shutdown')) return undefined; const inventory = (unit.getInventory?.() ?? []) .filter(entry => unit.isEquipmentOperational?.(entry) !== false); - const targetMoveDistance = unit.turnState().moveDistance() ?? 0; + const recordedMoveDistance = unit.turnState().moveDistance() ?? 0; + // Void Signature treats a unit that spent MP without leaving its hex as + // having moved one hex. The selected non-stationary mode is the tracker’s + // record that MP was spent when the hex distance itself remains zero. + const targetMoveDistance = recordedMoveDistance === 0 + && unit.turnState().effectiveMoveMode() !== null + && unit.turnState().effectiveMoveMode() !== 'stationary' + ? 1 + : recordedMoveDistance; const hasBattleArmorMyomerBooster = inventory.some(entry => ( entry.equipment?.flags.has('F_BA_EQUIPMENT') === true && entry.equipment.flags.has('F_MASC') @@ -259,19 +305,25 @@ export function getActiveStealthTnModifiers(unit: CBTForceUnit): TnStealthModifi const simpleCamo: TnStealthModifiers[] = []; const chameleon: TnStealthModifiers[] = []; const nullSignature: TnStealthModifiers[] = []; + const voidSignature: TnStealthModifiers[] = []; for (const entry of inventory) { if (hasBattleArmorMyomerBooster && (isBattleArmorStealthEquipment(entry) || isMimeticArmorEquipment(entry))) continue; const profile = getStealthTnModifiersForEquipment(entry, targetMoveDistance); if (!profile) continue; - if (isMimeticArmorEquipment(entry)) mimetic.push(profile); + if (isVoidSignatureEquipment(entry)) voidSignature.push(profile); + else if (isMimeticArmorEquipment(entry)) mimetic.push(profile); else if (isSimpleCamoEquipment(entry)) simpleCamo.push(profile); else if (isChameleonShieldEquipment(entry)) chameleon.push(profile); else if (isNullSignatureEquipment(entry)) nullSignature.push(profile); else electronicStealth.push(profile); } + // Void Signature does not combine with Stealth Armor or Null Signature. + const voidProfile = maxProfiles(voidSignature); + if (voidProfile) return voidProfile; + const mimeticProfile = maxProfiles(mimetic); const armorProfile = mimeticProfile ?? addProfiles( maxProfiles(electronicStealth), diff --git a/src/app/models/turn-state.model.spec.ts b/src/app/models/turn-state.model.spec.ts index 4cb8abb0f..382e08a69 100644 --- a/src/app/models/turn-state.model.spec.ts +++ b/src/app/models/turn-state.model.spec.ts @@ -2411,7 +2411,10 @@ describe('TurnState', () => { it('reactivates only when changed criticals alter passive heat sources', () => { const engineCrit = createCritSlot('Engine', 'CT'); const unrelatedCrit = createCritSlot('Sensors', 'HD'); - const { turnState, critSlots } = createTurnStateHarness({ critSlots: [engineCrit, unrelatedCrit] }); + const { turnState, critSlots } = createTurnStateHarness({ + critSlots: [engineCrit, unrelatedCrit], + unit: { engine: 'Fusion' }, + }); turnState.acknowledgeHeatSources(); unrelatedCrit.destroying = 1; @@ -2445,7 +2448,10 @@ describe('TurnState', () => { createCritSlot('Engine', 'CT', { id: 'engine@CT#1', destroying: 2 }), createCritSlot('Engine', 'CT', { id: 'engine@CT#2' }), ]; - const { turnState, critSlots } = createTurnStateHarness({ critSlots: engineCrits }); + const { turnState, critSlots } = createTurnStateHarness({ + critSlots: engineCrits, + unit: { engine: 'Fusion' }, + }); turnState.acknowledgeHeatSources(); expect(turnState.heatProjectionVisible()).toBeFalse(); @@ -2462,9 +2468,17 @@ describe('TurnState', () => { createCritSlot('Engine', 'CT', { id: 'engine@CT#0', destroyed: 1 }), createCritSlot('Engine', 'CT', { id: 'engine@CT#1', destroyed: 1 }), ]; - const operational = createTurnStateHarness({ critSlots: engineCrits }); - const destroyed = createTurnStateHarness({ critSlots: engineCrits, destroyed: true }); - const shutdown = createTurnStateHarness({ critSlots: engineCrits, shutdown: true }); + const operational = createTurnStateHarness({ critSlots: engineCrits, unit: { engine: 'Fusion' } }); + const destroyed = createTurnStateHarness({ + critSlots: engineCrits, + destroyed: true, + unit: { engine: 'Fusion' }, + }); + const shutdown = createTurnStateHarness({ + critSlots: engineCrits, + shutdown: true, + unit: { engine: 'Fusion' }, + }); expect(getDamagedEngineHeat(operational.turnState)).toBe(10); expect(operational.turnState.hasPendingHeatResolution()).toBeTrue(); @@ -2477,7 +2491,10 @@ describe('TurnState', () => { it('keeps acknowledged engine heat suppressed when movement changes', () => { const engineCrit = createCritSlot('Engine', 'CT', { destroying: 1 }); - const { turnState } = createTurnStateHarness({ critSlots: [engineCrit] }); + const { turnState } = createTurnStateHarness({ + critSlots: [engineCrit], + unit: { engine: 'Fusion' }, + }); turnState.moveMode.set('run'); turnState.acknowledgeHeatSources(); diff --git a/src/app/models/turn-state.model.ts b/src/app/models/turn-state.model.ts index e5dd596f6..738f88f4e 100644 --- a/src/app/models/turn-state.model.ts +++ b/src/app/models/turn-state.model.ts @@ -1228,6 +1228,13 @@ export class TurnState { this.weaponsHeat.update((value)=> { return value + amount }); } + /** Moves heat already recorded by the firing workflow to an itemized non-weapon source. */ + removeFiredHeat(amount: number) { + if (!Number.isFinite(amount) || amount <= 0) return; + this.invalidateHeatSource('weapons'); + this.weaponsHeat.update(value => Math.max(0, value - amount)); + } + acknowledgeHeatSources(consumedDissipation = 0): void { const acknowledged = { ...this.acknowledgedHeatSources() }; this.unresolvedHeatSources().forEach(source => acknowledged[source.id] = this.heatSourceSignature(source)); diff --git a/src/app/services/equipment-interaction-registry.service.ts b/src/app/services/equipment-interaction-registry.service.ts index dbbe9c9d7..bb2f5681c 100644 --- a/src/app/services/equipment-interaction-registry.service.ts +++ b/src/app/services/equipment-interaction-registry.service.ts @@ -97,6 +97,7 @@ export type HandlerNotifications = Pick; export interface HandlerDialogsService { createDialog: DialogsService['createDialog']; + requestConfirmation: DialogsService['requestConfirmation']; showError: DialogsService['showError']; showNoticeHtml: DialogsService['showNoticeHtml']; } @@ -180,6 +181,9 @@ export abstract class EquipmentInteractionHandler { */ beforeEquipmentStateCommit?(equipment: MountedEquipment): void; + /** Hook called when the owning unit ends a phase. */ + onEndPhase?(equipment: MountedEquipment): void; + /** Declares a rules/state-specific Mek explosion that this handler owns through phase end. */ getCriticalDelayedExplosion?( hitEntry: MountedEquipment, @@ -444,6 +448,12 @@ export class EquipmentInteractionRegistry { } } + onEndPhase(equipment: MountedEquipment): void { + for (const handler of this.getHandlers(equipment)) { + handler.onEndPhase?.(equipment); + } + } + getCriticalDelayedExplosion( hitEntry: MountedEquipment, explosionContext: CriticalDelayedExplosionContext, diff --git a/src/app/services/unit-initializer.service.spec.ts b/src/app/services/unit-initializer.service.spec.ts index 5659eb800..cdae58380 100644 --- a/src/app/services/unit-initializer.service.spec.ts +++ b/src/app/services/unit-initializer.service.spec.ts @@ -8,7 +8,7 @@ import { CBTForce } from '../models/cbt-force.model'; import { CBTForceUnit } from '../models/cbt-force-unit.model'; import { AmmoEquipment, ArmorEquipment, MiscEquipment, StructureEquipment, WeaponEquipment, type EquipmentMap } from '../models/equipment.model'; import { EquipmentRegistry } from '../models/equipment-lookup'; -import { MountedEquipment, MountedWeapon } from '../models/mounted-equipment.model'; +import { MountedAmmo, MountedEquipment, MountedWeapon } from '../models/mounted-equipment.model'; import { isIntrinsicOneShotAmmoMount } from '../utils/ammo-interaction.util'; import { createEmptyUnit } from '../testing/unit-test-helpers'; import { DataService } from './data.service'; @@ -36,7 +36,10 @@ function createEquipment(): EquipmentMap { const doubleHeatSink = new MiscEquipment({ id: 'ISDoubleHeatSink', name: 'Double Heat Sink', type: 'misc', flags: ['F_DOUBLE_HEAT_SINK'] }); const improvedJumpJet = new MiscEquipment({ id: 'ISImprovedJumpJet', name: 'Improved Jump Jet', type: 'misc', flags: ['F_JUMP_JET'] }); const mediumLaser = new WeaponEquipment({ id: 'CLMediumLaser', name: 'Medium Laser', type: 'weapon', weapon: { ammoType: 'NA' } }); + const machineGun = new WeaponEquipment({ id: 'ISMachineGun', name: 'Machine Gun', type: 'weapon', flags: ['F_MG'], weapon: { ammoType: 'MG', rackSize: 2 } }); + const machineGunArray = new WeaponEquipment({ id: 'ISMGA', name: 'Machine Gun Array', type: 'weapon', flags: ['F_MGA'], weapon: { ammoType: 'MG', rackSize: 2 } }); const ultraAc20Ammo = new AmmoEquipment({ id: 'CLUltraAC20Ammo', name: 'Ultra AC/20 Ammo', type: 'ammo', ammo: { type: 'AC_ULTRA', rackSize: 20, shots: 5 } }); + const coolantPod = new AmmoEquipment({ id: 'Coolant Pod', name: 'Coolant Pod', type: 'ammo', ammo: { type: 'COOLANT_POD', shots: 1 } }); return { [masc.internalName]: masc, [supercharger.internalName]: supercharger, @@ -48,7 +51,10 @@ function createEquipment(): EquipmentMap { [doubleHeatSink.internalName]: doubleHeatSink, [improvedJumpJet.internalName]: improvedJumpJet, [mediumLaser.internalName]: mediumLaser, + [machineGun.internalName]: machineGun, + [machineGunArray.internalName]: machineGunArray, [ultraAc20Ammo.internalName]: ultraAc20Ammo, + [coolantPod.internalName]: coolantPod, }; } @@ -180,6 +186,30 @@ describe('UnitInitializerService', () => { expect(forceUnit.getInventory().filter(entry => entry.id === 'CLMASC@LT#7').length).toBe(1); }); + it('reconstructs a flat record-sheet MGA as a controller with same-location member guns', () => { + const forceUnit = createForceUnit(); + const svg = createSvg(` + LT + LT + LT + LT + RT + `); + + service.initializeUnitIfNeeded(forceUnit, svg); + + const inventory = forceUnit.getInventory(); + const array = inventory.find(entry => entry.id === 'ISMGA@LT#3')!; + const linkedIds = array.linkedWith?.map(entry => entry.id); + expect(linkedIds).toEqual([ + 'ISMachineGun@LT#0', + 'ISMachineGun@LT#1', + 'ISMachineGun@LT#2', + ]); + expect(array.linkedWith?.every(entry => entry.parent === array)).toBeTrue(); + expect(inventory.find(entry => entry.id === 'ISMachineGun@RT#4')?.parent).toBeFalsy(); + }); + it('does not mirror Mek ammo critical slots into inventory entries', () => { const forceUnit = createForceUnit(); const svg = createSvg(` @@ -193,6 +223,28 @@ describe('UnitInitializerService', () => { expect(forceUnit.getInventory().some(entry => entry.id === 'CLUltraAC20Ammo@LT#7')).toBeFalse(); }); + it('materializes a critical-slot Coolant Pod as directly operated equipment', () => { + const forceUnit = createForceUnit(); + const svg = createSvg(` + + + `); + + service.initializeUnitIfNeeded(forceUnit, svg); + + const coolantPod = forceUnit.getInventory().find(entry => entry.id === 'Coolant Pod@LA#9'); + expect(coolantPod instanceof MountedAmmo).toBeTrue(); + expect(coolantPod).toEqual(jasmine.objectContaining({ + name: 'Coolant Pod', + totalAmmo: 1, + originalTotalAmmo: 1, + consumed: 0, + })); + expect(coolantPod?.critSlots?.length).toBe(1); + expect(Array.from(coolantPod?.locations ?? [])).toEqual(['LA']); + expect(forceUnit.getInventory().some(entry => entry.id === 'CLUltraAC20Ammo@LT#7')).toBeFalse(); + }); + it('preserves pending-destruction state when rebuilding direct ammo bins', () => { const testCases: Array<{ description: string; diff --git a/src/app/services/unit-initializer.service.ts b/src/app/services/unit-initializer.service.ts index 2ab356c64..43e9c7714 100644 --- a/src/app/services/unit-initializer.service.ts +++ b/src/app/services/unit-initializer.service.ts @@ -6,11 +6,12 @@ import { inject, Injectable, Injector } from '@angular/core'; import { MountedAmmo, MountedEquipment } from '../models/mounted-equipment.model'; import { type CriticalSlot } from '../models/force-serialization'; import { DataService } from './data.service'; -import { AmmoEquipment, ArmorEquipment, StructureEquipment, WeaponEquipment, type Equipment } from '../models/equipment.model'; +import { AmmoEquipment, ArmorEquipment, isCoolantPodEquipment, StructureEquipment, WeaponEquipment, type Equipment } from '../models/equipment.model'; import type { CBTForceUnit } from '../models/cbt-force-unit.model'; import { getBattleArmorTrooperNumber, normalizeBattleArmorTrooperLocation } from '../models/battle-armor-location.model'; import { materializeIntrinsicOneShotAmmoForInventory } from '../utils/ammo-interaction.util'; - +import { normalizeElectronicSuiteDefaults } from '../utils/ecm-state.util'; +import { reconcileMachineGunArrayLinks } from '../utils/mga-state.util'; export const CRITICAL_ONLY_INVENTORY_EXCLUDED_EQUIPMENT = new Set(); @@ -496,7 +497,11 @@ export class UnitInitializerService { private getCriticalOnlyInventoryEntries(unit: CBTForceUnit, existingIds: Set, currentInventory: MountedEquipment[]): MountedEquipment[] { const critSlotsById = new Map(); for (const critSlot of unit.getCritSlots()) { - if (!critSlot.id || existingIds.has(critSlot.id) || !critSlot.eq || critSlot.eq instanceof AmmoEquipment || this.isCriticalOnlyInventoryExcluded(critSlot)) continue; + if (!critSlot.id + || existingIds.has(critSlot.id) + || !critSlot.eq + || (critSlot.eq instanceof AmmoEquipment && !isCoolantPodEquipment(critSlot.eq)) + || this.isCriticalOnlyInventoryExcluded(critSlot)) continue; const critSlots = critSlotsById.get(critSlot.id) ?? []; critSlots.push(critSlot); critSlotsById.set(critSlot.id, critSlots); @@ -505,7 +510,7 @@ export class UnitInitializerService { return Array.from(critSlotsById.entries()).map(([id, critSlots]) => { const existingEntry = currentInventory.find(item => item.id === id); const equipment = critSlots[0].eq; - return new MountedEquipment({ + const common = { owner: unit, id, name: critSlots[0].name || id.split('@')[0], @@ -518,7 +523,21 @@ export class UnitInitializerService { destroying: existingEntry?.pendingDestroyed(), critSlots, states: existingEntry?.states ? new Map(existingEntry.states) : new Map(), - }); + }; + if (isCoolantPodEquipment(equipment)) { + const originalTotalAmmo = critSlots.reduce((total, slot) => + total + (slot.totalAmmo + || Number(slot.el?.getAttribute('totalAmmo') ?? 0) + || equipment.getShots(unit.gameRules, unit.getEquipmentRegistry())), 0); + return new MountedAmmo({ + ...common, + equipment, + totalAmmo: existingEntry?.totalAmmo ?? originalTotalAmmo, + originalTotalAmmo, + consumed: existingEntry?.consumed ?? critSlots.reduce((total, slot) => total + (slot.consumed ?? 0), 0), + }); + } + return new MountedEquipment(common); }); } @@ -555,6 +574,8 @@ export class UnitInitializerService { materializedInventory, this.getDataService().getEquipmentRegistry(), )); + reconcileMachineGunArrayLinks(materializedInventory); + normalizeElectronicSuiteDefaults(materializedInventory); unit.setInventory(materializedInventory, true); } diff --git a/src/app/testing/unit-test-helpers.spec.ts b/src/app/testing/unit-test-helpers.spec.ts index 62de66114..3b72914b2 100644 --- a/src/app/testing/unit-test-helpers.spec.ts +++ b/src/app/testing/unit-test-helpers.spec.ts @@ -214,7 +214,7 @@ describe('CBTForceUnitTestHarness', () => { expect(resolveEquipmentActionPermission).not.toHaveBeenCalled(); }); - it('requires an explicitly operational C3 component before configuring its network', () => { + it('does not require an explicitly operational C3 component before configuring its network', () => { const resolveConfigureNetworkPermission = jasmine.createSpy('resolveConfigureNetworkPermission') .and.returnValue(true); const harness = createCBTForceUnitTestHarness({ resolveConfigureNetworkPermission }); @@ -225,8 +225,8 @@ describe('CBTForceUnitTestHarness', () => { harness.setEquipmentStatus(mounted, 'destroyed'); - expect(harness.unit.canPerformEquipmentAction(mounted, 'configure-network')).toBeFalse(); - expect(resolveConfigureNetworkPermission).toHaveBeenCalledTimes(1); + expect(harness.unit.canPerformEquipmentAction(mounted, 'configure-network')).toBeTrue(); + expect(resolveConfigureNetworkPermission).toHaveBeenCalledTimes(2); }); it('resolves lifecycle state through canonical unit helpers', () => { diff --git a/src/app/testing/unit-test-helpers.ts b/src/app/testing/unit-test-helpers.ts index 86380b193..af8ea11c2 100644 --- a/src/app/testing/unit-test-helpers.ts +++ b/src/app/testing/unit-test-helpers.ts @@ -210,6 +210,7 @@ export interface CBTForceUnitTestTurnState { heatDissipationBalance(): number; effectiveHeatDissipation(): number; addFiredHeat(amount: number): void; + removeFiredHeat(amount: number): void; markEquipmentStateChanged(): void; } @@ -288,6 +289,9 @@ export class CBTForceUnitTestHarness { addFiredHeat: (amount: number) => { if (Number.isFinite(amount) && amount > 0) firedHeat += amount; }, + removeFiredHeat: (amount: number) => { + if (Number.isFinite(amount) && amount > 0) firedHeat = Math.max(0, firedHeat - amount); + }, markEquipmentStateChanged: () => {}, }; @@ -436,11 +440,12 @@ export class CBTForceUnitTestHarness { this.unit.getEquipmentInstallationLocationStatus(entry) === 'destroyed' || (!entry.isRepairing() && resolveEquipmentStatus(entry) === 'destroyed'), canPerformEquipmentAction: (entry: MountedEquipment, action: EquipmentAction) => { + if (action === 'configure-network') { + return options.resolveConfigureNetworkPermission?.(entry) ?? false; + } if (resolveEquipmentStatus(entry) !== 'available' || (options.destroyed ?? false) || conditions.has('shutdown')) return false; - if (action === 'configure-network' - && !(options.resolveConfigureNetworkPermission?.(entry) ?? false)) return false; return rules.canPerformEquipmentAction(entry, action); }, canEditEquipmentState: (entry: MountedEquipment, edit: EquipmentStateEdit) => { diff --git a/src/app/utils/ecm-state.util.spec.ts b/src/app/utils/ecm-state.util.spec.ts new file mode 100644 index 000000000..596cc5f29 --- /dev/null +++ b/src/app/utils/ecm-state.util.spec.ts @@ -0,0 +1,217 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { PickerChoice } from '../components/picker/picker.interface'; +import { BAPHandler } from '../equipment-handlers/bap.handler'; +import { ECMHandler } from '../equipment-handlers/ecm.handler'; +import { NovaCewsHandler } from '../equipment-handlers/nova-cews.handler'; +import { ECMMode } from '../models/common.model'; +import { MiscEquipment } from '../models/equipment.model'; +import type { EquipmentFlag } from '../models/equipment-flags.type'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; +import { MountedEquipment } from '../models/mounted-equipment.model'; +import { + createHandlerCommandContext, + createHandlerQueryContext, + EquipmentInteractionRegistry, +} from '../services/equipment-interaction-registry.service'; +import type { DialogsService } from '../services/dialogs.service'; +import type { ToastService } from '../services/toast.service'; +import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; +import { + EQUIPMENT_POWER_OFF_STATE, + EQUIPMENT_POWER_ON_STATE, + EQUIPMENT_POWER_STATE_KEY, +} from './equipment-power-state.util'; +import { + ECM_MODE_STATE_KEY, + ECM_PENDING_MODE_STATE_KEY, + getEffectiveEcmMode, + isActiveProbeEffectivelyActive, + normalizeElectronicSuiteDefaults, + NOVA_CEWS_OFF_STATE, + NOVA_CEWS_ON_STATE, + NOVA_CEWS_STATE_KEY, +} from './ecm-state.util'; + +function fixture() { + const test = createTestEquipmentOwner({ resolveEquipmentActionPermission: () => true }); + const markEquipmentStateChanged = jasmine.createSpy('markEquipmentStateChanged'); + Object.assign(test.owner, { turnState: () => ({ markEquipmentStateChanged }) }); + spyOn(test.owner, 'setInventoryEntry').and.callThrough(); + + const add = ( + id: string, + flags: EquipmentFlag[], + states = new Map(), + ): MountedEquipment => { + const equipment = new MiscEquipment({ id, name: id, type: 'misc', flags }); + const mounted = new MountedEquipment({ + owner: test.owner, + id, + name: id, + equipment, + states, + }); + test.owner.setInventoryEntry(mounted); + return mounted; + }; + + return { ...test, add, markEquipmentStateChanged }; +} + +describe('electronic suite state', () => { + const queryContext = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); + const toastService = jasmine.createSpyObj('ToastService', ['showToast', 'toasts']); + const commandContext = createHandlerCommandContext( + EMPTY_EQUIPMENT_REGISTRY, + toastService, + jasmine.createSpyObj('DialogsService', ['createDialog']), + ); + + beforeEach(() => toastService.showToast.calls.reset()); + + it('uses one shared ECM control for every combined ECM/probe suite', () => { + const test = fixture(); + const ewEquipment = test.add('EW Equipment', ['F_EW_EQUIPMENT', 'F_ECM', 'F_BAP']); + const registry = new EquipmentInteractionRegistry(); + registry.register(new ECMHandler()); + registry.register(new BAPHandler()); + + expect(registry.getHandlers(ewEquipment).map(handler => handler.id)).toEqual(['ecm-handler']); + expect(registry.getChoices(ewEquipment, queryContext).map(choice => choice.label)).toEqual(['ECM Mode']); + }); + + it('normalizes implicit multiple-ECM defaults with Angel precedence', () => { + const test = fixture(); + const guardian = test.add('Guardian', ['F_ECM']); + const angel = test.add('Angel', ['F_ECM', 'F_ANGEL_ECM']); + + normalizeElectronicSuiteDefaults(test.inventory); + + expect(guardian.states.get(ECM_MODE_STATE_KEY)).toBe(ECMMode.OFF); + expect(angel.states.has(ECM_MODE_STATE_KEY)).toBeFalse(); + expect(getEffectiveEcmMode(guardian)).toBe(ECMMode.OFF); + expect(getEffectiveEcmMode(angel)).toBe(ECMMode.ECM); + }); + + it('hands ECM operation to the newly selected suite in the End Phase', () => { + const test = fixture(); + const first = test.add('Guardian 1', ['F_ECM'], new Map([[ECM_MODE_STATE_KEY, ECMMode.ECM]])); + const second = test.add('Guardian 2', ['F_ECM'], new Map([[ECM_MODE_STATE_KEY, ECMMode.OFF]])); + const handler = new ECMHandler(); + const selection = { label: 'ECCM', value: ECMMode.ECCM } as PickerChoice; + + handler.handleSelection(second, selection, commandContext); + + expect(second.states.get(ECM_PENDING_MODE_STATE_KEY)).toBe(ECMMode.ECCM); + expect(getEffectiveEcmMode(first)).toBe(ECMMode.ECM); + expect(getEffectiveEcmMode(second)).toBe(ECMMode.OFF); + + handler.onEndTurn(second); + + expect(first.states.get(ECM_MODE_STATE_KEY)).toBe(ECMMode.OFF); + expect(second.states.get(ECM_MODE_STATE_KEY)).toBe(ECMMode.ECCM); + expect(second.states.has(ECM_PENDING_MODE_STATE_KEY)).toBeFalse(); + expect(getEffectiveEcmMode(second)).toBe(ECMMode.ECCM); + }); + + it('makes the last queued ECM suite win without changing current-turn effects', () => { + const test = fixture(); + const current = test.add('Guardian 1', ['F_ECM'], new Map([[ECM_MODE_STATE_KEY, ECMMode.ECM]])); + const second = test.add('Guardian 2', ['F_ECM'], new Map([[ECM_MODE_STATE_KEY, ECMMode.OFF]])); + const third = test.add('Guardian 3', ['F_ECM'], new Map([[ECM_MODE_STATE_KEY, ECMMode.OFF]])); + const handler = new ECMHandler(); + + handler.handleSelection(second, { label: 'ECM', value: ECMMode.ECM }, commandContext); + handler.handleSelection(third, { label: 'ECCM', value: ECMMode.ECCM }, commandContext); + + expect(second.states.has(ECM_PENDING_MODE_STATE_KEY)).toBeFalse(); + expect(third.states.get(ECM_PENDING_MODE_STATE_KEY)).toBe(ECMMode.ECCM); + expect(getEffectiveEcmMode(current)).toBe(ECMMode.ECM); + expect(getEffectiveEcmMode(third)).toBe(ECMMode.OFF); + + handler.onEndTurn(second); + handler.onEndTurn(third); + expect(current.states.get(ECM_MODE_STATE_KEY)).toBe(ECMMode.OFF); + expect(second.states.get(ECM_MODE_STATE_KEY)).toBe(ECMMode.OFF); + expect(third.states.get(ECM_MODE_STATE_KEY)).toBe(ECMMode.ECCM); + }); + + it('rejects ECM modes that the mounted suite does not support', () => { + const test = fixture(); + const guardian = test.add('Guardian', ['F_ECM']); + const handler = new ECMHandler(); + + handler.handleSelection( + guardian, + { label: 'Synthetic Angel mode', value: ECMMode.ECM_ECCM }, + commandContext, + ); + + expect(guardian.states.has(ECM_PENDING_MODE_STATE_KEY)).toBeFalse(); + expect(test.markEquipmentStateChanged).not.toHaveBeenCalled(); + }); + + it('hands active-probe operation to the newly selected standalone probe', () => { + const test = fixture(); + const first = test.add('Probe 1', ['F_BAP'], new Map([ + [EQUIPMENT_POWER_STATE_KEY, EQUIPMENT_POWER_ON_STATE], + ])); + const second = test.add('Probe 2', ['F_BAP'], new Map([ + [EQUIPMENT_POWER_STATE_KEY, EQUIPMENT_POWER_OFF_STATE], + ])); + const handler = new BAPHandler(); + + handler.handleSelection(second, handler.getChoices(second, queryContext)[0], commandContext); + + expect(isActiveProbeEffectivelyActive(first)).toBeTrue(); + expect(isActiveProbeEffectivelyActive(second)).toBeFalse(); + + handler.onEndTurn(second); + + expect(first.states.get(EQUIPMENT_POWER_STATE_KEY)).toBe(EQUIPMENT_POWER_OFF_STATE); + expect(second.states.get(EQUIPMENT_POWER_STATE_KEY)).toBe(EQUIPMENT_POWER_ON_STATE); + expect(isActiveProbeEffectivelyActive(first)).toBeFalse(); + expect(isActiveProbeEffectivelyActive(second)).toBeTrue(); + }); + + it('powers a combined suite fully down when a standalone probe takes over', () => { + const test = fixture(); + const watchdog = test.add('Watchdog', ['F_WATCHDOG', 'F_ECM', 'F_BAP'], new Map([ + [ECM_MODE_STATE_KEY, ECMMode.ECM], + ])); + const probe = test.add('Bloodhound', ['F_BAP'], new Map([ + [EQUIPMENT_POWER_STATE_KEY, EQUIPMENT_POWER_OFF_STATE], + ])); + const handler = new BAPHandler(); + + handler.handleSelection(probe, handler.getChoices(probe, queryContext)[0], commandContext); + handler.onEndTurn(probe); + + expect(watchdog.states.get(ECM_MODE_STATE_KEY)).toBe(ECMMode.OFF); + expect(probe.states.get(EQUIPMENT_POWER_STATE_KEY)).toBe(EQUIPMENT_POWER_ON_STATE); + }); + + it('powers competing ECM and probe systems down when Nova CEWS takes over', () => { + const test = fixture(); + const nova = test.add('Nova CEWS', ['F_NOVA', 'F_ECM', 'F_BAP'], new Map([ + [NOVA_CEWS_STATE_KEY, NOVA_CEWS_OFF_STATE], + ])); + const guardian = test.add('Guardian', ['F_ECM'], new Map([ + [ECM_MODE_STATE_KEY, ECMMode.ECM], + ])); + const probe = test.add('Bloodhound', ['F_BAP'], new Map([ + [EQUIPMENT_POWER_STATE_KEY, EQUIPMENT_POWER_ON_STATE], + ])); + const handler = new NovaCewsHandler(); + + handler.handleSelection(nova, handler.getChoices(nova, queryContext)[0], commandContext); + handler.onEndTurn(nova); + + expect(nova.states.get(NOVA_CEWS_STATE_KEY)).toBe(NOVA_CEWS_ON_STATE); + expect(guardian.states.get(ECM_MODE_STATE_KEY)).toBe(ECMMode.OFF); + expect(probe.states.get(EQUIPMENT_POWER_STATE_KEY)).toBe(EQUIPMENT_POWER_OFF_STATE); + }); +}); diff --git a/src/app/utils/ecm-state.util.ts b/src/app/utils/ecm-state.util.ts index 4b910dfa6..e21bcf2be 100644 --- a/src/app/utils/ecm-state.util.ts +++ b/src/app/utils/ecm-state.util.ts @@ -4,8 +4,17 @@ import { ECMMode } from '../models/common.model'; import type { MountedEquipment } from '../models/mounted-equipment.model'; +import { + EQUIPMENT_POWER_OFF_STATE, + EQUIPMENT_POWER_ON_STATE, + EQUIPMENT_POWER_STATE_KEY, + EQUIPMENT_POWER_TURNING_OFF_STATE, + EQUIPMENT_POWER_TURNING_ON_STATE, + equipmentPowerState, +} from './equipment-power-state.util'; export const ECM_MODE_STATE_KEY = 'ecm_mode'; +export const ECM_PENDING_MODE_STATE_KEY = 'ecm_pending_mode'; export const NOVA_CEWS_STATE_KEY = ECM_MODE_STATE_KEY; export const NOVA_CEWS_ON_STATE = ECMMode.ECM; @@ -19,13 +28,104 @@ export type NovaCewsState = | typeof NOVA_CEWS_OFF_STATE | typeof NOVA_CEWS_TURNING_ON_STATE; -function defaultNovaCewsState(equipment: MountedEquipment): NovaCewsState { - const firstNovaMount = equipment.owner.getInventory().find(candidate => ( - candidate.equipment?.flags.has('F_NOVA') +function inventoryFor(equipment: MountedEquipment): MountedEquipment[] { + const inventory = [...equipment.owner.getInventory()]; + if (!inventory.some(candidate => candidate.id === equipment.id)) inventory.push(equipment); + return inventory; +} + +function isOperational(equipment: MountedEquipment): boolean { + return !equipment.owner.destroyed + && !equipment.owner.getCondition('shutdown') + && equipment.owner.isEquipmentOperational(equipment); +} + +function isNovaCews(equipment: MountedEquipment): boolean { + return equipment.equipment?.flags.has('F_NOVA') === true; +} + +function isEcmSuite(equipment: MountedEquipment): boolean { + return isNovaCews(equipment) || equipment.equipment?.flags.has('F_ECM') === true; +} + +function isProbeSuite(equipment: MountedEquipment): boolean { + return isNovaCews(equipment) || equipment.equipment?.flags.has('F_BAP') === true; +} + +function currentRawEcmMode(equipment: MountedEquipment): string { + if (isNovaCews(equipment)) { + const state = novaCewsState(equipment); + return state === NOVA_CEWS_ON_STATE || state === NOVA_CEWS_TURNING_OFF_STATE + ? ECMMode.ECM + : ECMMode.OFF; + } + return equipment.states.get(ECM_MODE_STATE_KEY) || ECMMode.ECM; +} + +function nextRawEcmMode(equipment: MountedEquipment): string { + if (isNovaCews(equipment)) { + const state = novaCewsState(equipment); + return state === NOVA_CEWS_ON_STATE || state === NOVA_CEWS_TURNING_ON_STATE + ? ECMMode.ECM + : ECMMode.OFF; + } + return equipment.states.get(ECM_PENDING_MODE_STATE_KEY) || currentRawEcmMode(equipment); +} + +function hasPendingEcmActivation(equipment: MountedEquipment): boolean { + if (isNovaCews(equipment)) return novaCewsState(equipment) === NOVA_CEWS_TURNING_ON_STATE; + const pendingMode = equipment.states.get(ECM_PENDING_MODE_STATE_KEY); + return pendingMode !== undefined && pendingMode !== ECMMode.OFF; +} + +function preferredEcmSuite( + equipment: MountedEquipment, + next: boolean, +): MountedEquipment | undefined { + const candidates = inventoryFor(equipment).filter(candidate => ( + isEcmSuite(candidate) + && isOperational(candidate) + && (next ? nextRawEcmMode(candidate) : currentRawEcmMode(candidate)) !== ECMMode.OFF )); - return !firstNovaMount || firstNovaMount === equipment - ? NOVA_CEWS_ON_STATE - : NOVA_CEWS_OFF_STATE; + const activating = next ? candidates.filter(hasPendingEcmActivation) : []; + const selection = activating.length > 0 ? activating : candidates; + return selection.find(candidate => candidate.equipment?.flags.has('F_ANGEL_ECM')) + ?? selection[0]; +} + +function currentRawProbePowered(equipment: MountedEquipment): boolean { + if (isEcmSuite(equipment)) return getEffectiveEcmMode(equipment) !== ECMMode.OFF; + const state = equipmentPowerState(equipment); + return state === EQUIPMENT_POWER_ON_STATE || state === EQUIPMENT_POWER_TURNING_OFF_STATE; +} + +function nextRawProbePowered(equipment: MountedEquipment): boolean { + if (isEcmSuite(equipment)) return getNextEffectiveEcmMode(equipment) !== ECMMode.OFF; + const state = equipmentPowerState(equipment); + return state === EQUIPMENT_POWER_ON_STATE || state === EQUIPMENT_POWER_TURNING_ON_STATE; +} + +function hasPendingProbeActivation(equipment: MountedEquipment): boolean { + if (isEcmSuite(equipment)) return hasPendingEcmActivation(equipment); + return equipmentPowerState(equipment) === EQUIPMENT_POWER_TURNING_ON_STATE; +} + +function preferredProbeSuite( + equipment: MountedEquipment, + next: boolean, +): MountedEquipment | undefined { + const candidates = inventoryFor(equipment).filter(candidate => ( + isProbeSuite(candidate) + && isOperational(candidate) + && (next ? nextRawProbePowered(candidate) : currentRawProbePowered(candidate)) + )); + const activating = next ? candidates.filter(hasPendingProbeActivation) : []; + if (activating.length > 0) return activating[0]; + + // A selected combined suite supplies both functions; otherwise the first + // standalone probe in mount order is the stable tabletop default. + return candidates.find(isEcmSuite) + ?? candidates[0]; } /** Missing and legacy non-Off ECM modes preserve the rules-default active state. */ @@ -34,40 +134,183 @@ export function novaCewsState(equipment: MountedEquipment | null | undefined): N case NOVA_CEWS_TURNING_OFF_STATE: return NOVA_CEWS_TURNING_OFF_STATE; case ECMMode.OFF: return NOVA_CEWS_OFF_STATE; case NOVA_CEWS_TURNING_ON_STATE: return NOVA_CEWS_TURNING_ON_STATE; - default: - if (!equipment) return NOVA_CEWS_ON_STATE; - return equipment.states.has(NOVA_CEWS_STATE_KEY) - ? NOVA_CEWS_ON_STATE - : defaultNovaCewsState(equipment); + default: return NOVA_CEWS_ON_STATE; } } -/** A pending End-Phase transition does not change the system's effects during the current turn. */ +/** Resolves the one ECM suite supplying effects during the current turn. */ +export function getEffectiveEcmMode(equipment: MountedEquipment): ECMMode | string { + const rawMode = currentRawEcmMode(equipment); + if (rawMode === ECMMode.OFF) return ECMMode.OFF; + return preferredEcmSuite(equipment, false)?.id === equipment.id ? rawMode : ECMMode.OFF; +} + +/** Resolves the queued End-Phase ECM selection without changing current-turn effects. */ +export function getNextEffectiveEcmMode(equipment: MountedEquipment): ECMMode | string { + const rawMode = nextRawEcmMode(equipment); + if (rawMode === ECMMode.OFF) return ECMMode.OFF; + return preferredEcmSuite(equipment, true)?.id === equipment.id ? rawMode : ECMMode.OFF; +} + +/** A pending End-Phase transition does not change the system's current effects. */ export function isNovaCewsEffectivelyActive(equipment: MountedEquipment | null | undefined): boolean { - if (!equipment) return false; - const state = novaCewsState(equipment); - if (state !== NOVA_CEWS_ON_STATE && state !== NOVA_CEWS_TURNING_OFF_STATE) return false; - - // Even malformed/legacy state containing multiple ON mounts must obey the - // rule that a unit can use only one Nova CEWS at a time. - const firstActiveMount = equipment.owner.getInventory().find(candidate => { - if (candidate.equipment?.flags.has('F_NOVA') !== true) return false; - const candidateState = novaCewsState(candidate); - return candidateState === NOVA_CEWS_ON_STATE - || candidateState === NOVA_CEWS_TURNING_OFF_STATE; - }); - return !firstActiveMount || firstActiveMount === equipment; + return !!equipment + && isNovaCews(equipment) + && getEffectiveEcmMode(equipment) !== ECMMode.OFF; } -/** Resolves the mode currently supplying effects, including delayed Nova CEWS transitions. */ -export function getEffectiveEcmMode(equipment: MountedEquipment): ECMMode | string { - if (equipment.equipment?.flags.has('F_NOVA')) { - return isNovaCewsEffectivelyActive(equipment) ? ECMMode.ECM : ECMMode.OFF; - } - return equipment.states.get(ECM_MODE_STATE_KEY) || ECMMode.ECM; +/** Resolves the Nova state presented by its single shared power control. */ +export function nextEffectiveNovaCewsState(equipment: MountedEquipment): NovaCewsState { + const state = novaCewsState(equipment); + if (state === NOVA_CEWS_TURNING_ON_STATE || state === NOVA_CEWS_TURNING_OFF_STATE) return state; + return getNextEffectiveEcmMode(equipment) === ECMMode.OFF + ? NOVA_CEWS_OFF_STATE + : NOVA_CEWS_ON_STATE; } /** Mode state only; callers remain responsible for equipment and unit availability. */ export function isEcmModeActive(equipment: MountedEquipment): boolean { return getEffectiveEcmMode(equipment) !== ECMMode.OFF; } + +/** Resolves the one active probe supplying effects during the current turn. */ +export function isActiveProbeEffectivelyActive(equipment: MountedEquipment): boolean { + return isProbeSuite(equipment) + && currentRawProbePowered(equipment) + && preferredProbeSuite(equipment, false)?.id === equipment.id; +} + +/** Resolves the state presented by a standalone active-probe power control. */ +export function nextEffectiveProbePowerState(equipment: MountedEquipment): string { + const state = equipmentPowerState(equipment); + if (state === EQUIPMENT_POWER_TURNING_ON_STATE || state === EQUIPMENT_POWER_TURNING_OFF_STATE) return state; + return nextRawProbePowered(equipment) + && preferredProbeSuite(equipment, true)?.id === equipment.id + ? EQUIPMENT_POWER_ON_STATE + : EQUIPMENT_POWER_OFF_STATE; +} + +function switchEcmOff(equipment: MountedEquipment): boolean { + const changed = equipment.setState(ECM_MODE_STATE_KEY, ECMMode.OFF); + return equipment.deleteState(ECM_PENDING_MODE_STATE_KEY) || changed; +} + +function switchStandaloneProbeOff(equipment: MountedEquipment): boolean { + return equipment.setState(EQUIPMENT_POWER_STATE_KEY, EQUIPMENT_POWER_OFF_STATE); +} + +function cancelPendingEcmActivation(equipment: MountedEquipment): boolean { + if (isNovaCews(equipment)) { + return novaCewsState(equipment) === NOVA_CEWS_TURNING_ON_STATE + && equipment.setState(NOVA_CEWS_STATE_KEY, NOVA_CEWS_OFF_STATE); + } + const pendingMode = equipment.states.get(ECM_PENDING_MODE_STATE_KEY); + return pendingMode !== undefined + && pendingMode !== ECMMode.OFF + && equipment.deleteState(ECM_PENDING_MODE_STATE_KEY); +} + +function cancelPendingProbeActivation(equipment: MountedEquipment): boolean { + return equipmentPowerState(equipment) === EQUIPMENT_POWER_TURNING_ON_STATE + && equipment.setState(EQUIPMENT_POWER_STATE_KEY, EQUIPMENT_POWER_OFF_STATE); +} + +/** + * Makes the most recently selected suite win a queued End-Phase handoff while + * preserving every suite's current-turn effects. + */ +export function cancelConflictingElectronicSuiteActivations(selected: MountedEquipment): boolean { + const claimsEcm = isEcmSuite(selected); + const claimsProbe = isProbeSuite(selected); + if (!claimsEcm && !claimsProbe) return false; + + let changed = false; + for (const other of selected.owner.getInventory()) { + if (other.id === selected.id) continue; + const conflictsWithEcm = claimsEcm && isEcmSuite(other); + const conflictsWithProbe = claimsProbe && isProbeSuite(other); + if (!conflictsWithEcm && !conflictsWithProbe) continue; + + const otherChanged = isEcmSuite(other) + ? cancelPendingEcmActivation(other) + : cancelPendingProbeActivation(other); + if (otherChanged) { + other.owner.setInventoryEntry(other); + changed = true; + } + } + return changed; +} + +/** + * Commits the exclusivity part of an End-Phase electronic-suite handoff. + * A combined ECM/probe suite powers fully down when either of its functions + * conflicts with the newly selected suite. + */ +export function deactivateConflictingElectronicSuites(selected: MountedEquipment): void { + const claimsEcm = isEcmSuite(selected); + const claimsProbe = isProbeSuite(selected); + if (!claimsEcm && !claimsProbe) return; + + for (const other of selected.owner.getInventory()) { + if (other.id === selected.id) continue; + const conflictsWithEcm = claimsEcm && isEcmSuite(other); + const conflictsWithProbe = claimsProbe && isProbeSuite(other); + if (!conflictsWithEcm && !conflictsWithProbe) continue; + + const changed = isEcmSuite(other) + ? switchEcmOff(other) + : switchStandaloneProbeOff(other); + if (changed) other.owner.setInventoryEntry(other); + } +} + +function hasEcmTransition(equipment: MountedEquipment): boolean { + if (isNovaCews(equipment)) { + const state = novaCewsState(equipment); + return state === NOVA_CEWS_TURNING_ON_STATE || state === NOVA_CEWS_TURNING_OFF_STATE; + } + return equipment.states.has(ECM_PENDING_MODE_STATE_KEY); +} + +function hasProbeTransition(equipment: MountedEquipment): boolean { + if (isEcmSuite(equipment)) return hasEcmTransition(equipment); + const state = equipmentPowerState(equipment); + return state === EQUIPMENT_POWER_TURNING_ON_STATE || state === EQUIPMENT_POWER_TURNING_OFF_STATE; +} + +/** + * Repairs the implicit all-on state produced by equipment defaults at load. + * Explicit End-Phase transitions are preserved so a saved handoff is not + * collapsed early. + */ +export function normalizeElectronicSuiteDefaults(inventory: readonly MountedEquipment[]): void { + const ecmSuites = inventory.filter(equipment => ( + isEcmSuite(equipment) + && isOperational(equipment) + && currentRawEcmMode(equipment) !== ECMMode.OFF + )); + if (ecmSuites.length > 1 && !ecmSuites.some(hasEcmTransition)) { + const kept = ecmSuites.find(equipment => equipment.equipment?.flags.has('F_ANGEL_ECM')) + ?? ecmSuites[0]; + for (const equipment of ecmSuites) { + if (equipment.id !== kept.id) switchEcmOff(equipment); + } + } + + const activeProbes = inventory.filter(equipment => { + if (!isProbeSuite(equipment) || !isOperational(equipment)) return false; + return isEcmSuite(equipment) + ? currentRawEcmMode(equipment) !== ECMMode.OFF + : currentRawProbePowered(equipment); + }); + if (activeProbes.length > 1 && !activeProbes.some(hasProbeTransition)) { + const kept = activeProbes.find(isEcmSuite) + ?? activeProbes[0]; + for (const equipment of activeProbes) { + if (equipment.id === kept.id) continue; + if (isEcmSuite(equipment)) switchEcmOff(equipment); + else switchStandaloneProbeOff(equipment); + } + } +} diff --git a/src/app/utils/equipment-power-state.util.ts b/src/app/utils/equipment-power-state.util.ts new file mode 100644 index 000000000..5655aadfd --- /dev/null +++ b/src/app/utils/equipment-power-state.util.ts @@ -0,0 +1,28 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { MountedEquipment } from '../models/mounted-equipment.model'; + +export const EQUIPMENT_POWER_STATE_KEY = 'powerState'; +export const EQUIPMENT_POWER_ON_STATE = 'enabled'; +export const EQUIPMENT_POWER_TURNING_ON_STATE = 'enabling'; +export const EQUIPMENT_POWER_OFF_STATE = 'disabled'; +export const EQUIPMENT_POWER_TURNING_OFF_STATE = 'disabling'; + +/** Missing state is the tabletop default: installed electronics begin switched on. */ +export function equipmentPowerState(equipment: MountedEquipment): string { + const state = equipment.states.get(EQUIPMENT_POWER_STATE_KEY); + return state === EQUIPMENT_POWER_ON_STATE + || state === EQUIPMENT_POWER_TURNING_ON_STATE + || state === EQUIPMENT_POWER_OFF_STATE + || state === EQUIPMENT_POWER_TURNING_OFF_STATE + ? state + : EQUIPMENT_POWER_ON_STATE; +} + +/** End-Phase changes retain the prior turn's effects until committed. */ +export function isEquipmentEffectivelyPowered(equipment: MountedEquipment): boolean { + const state = equipmentPowerState(equipment); + return state === EQUIPMENT_POWER_ON_STATE || state === EQUIPMENT_POWER_TURNING_OFF_STATE; +} diff --git a/src/app/utils/force-viewer-electronics-display.util.spec.ts b/src/app/utils/force-viewer-electronics-display.util.spec.ts index fc09842ca..def9e0ff6 100644 --- a/src/app/utils/force-viewer-electronics-display.util.spec.ts +++ b/src/app/utils/force-viewer-electronics-display.util.spec.ts @@ -37,10 +37,15 @@ function cbtUnit( let mounts: MountedEquipment[] = []; const unit = Object.create(CBTForceUnit.prototype) as CBTForceUnit; Object.defineProperties(unit, { + destroyed: { value: false }, getInventory: { value: () => mounts }, getMountedEquipmentByFlag: { value: (flag: EquipmentFlag) => mounts.filter(mount => mount.equipment?.flags.has(flag)), }, + getCondition: { value: () => false }, + isEquipmentOperational: { + value: (mount: MountedEquipment) => !unavailable.has(mount.id), + }, canPerformEquipmentAction: { value: (mount: MountedEquipment) => !unavailable.has(mount.id), }, diff --git a/src/app/utils/hpg-state.util.ts b/src/app/utils/hpg-state.util.ts new file mode 100644 index 000000000..8ffcd6670 --- /dev/null +++ b/src/app/utils/hpg-state.util.ts @@ -0,0 +1,56 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import { WeaponEquipment } from '../models/equipment.model'; +import type { MountedEquipment } from '../models/mounted-equipment.model'; + +export const HPG_STATE_KEY = 'hpgState'; +export const HPG_IDLE_STATE = 'idle'; +export const HPG_CHARGING_STATE = 'charging'; +export const HPG_CHARGED_STATE = 'charged'; +export const HPG_TRANSMITTING_STATE = 'transmitting'; +export const HPG_COOLDOWN_STATE = 'cooldown'; +export const HPG_COOLDOWN_TURNS_STATE_KEY = 'hpgCooldownTurns'; + +export function isGroundMobileHpg(equipment: MountedEquipment): boolean { + return equipment.equipment?.hasAllFlags(['F_MOBILE_HPG', 'F_MEK_EQUIPMENT']) === true; +} + +export function hpgState(equipment: MountedEquipment): string { + const state = equipment.states.get(HPG_STATE_KEY); + return state === HPG_CHARGING_STATE + || state === HPG_CHARGED_STATE + || state === HPG_TRANSMITTING_STATE + || state === HPG_COOLDOWN_STATE + ? state + : HPG_IDLE_STATE; +} + +export function isHpgBlockingWeaponAttacks(equipment: MountedEquipment): boolean { + const state = hpgState(equipment); + return state === HPG_CHARGING_STATE || state === HPG_TRANSMITTING_STATE; +} + +export function unitHasBusyHpg(unit: CBTForceUnit): boolean { + return unit.getInventory().some(entry => + entry.equipment?.hasFlag('F_MOBILE_HPG') === true + && unit.isEquipmentOperational(entry) + && isHpgBlockingWeaponAttacks(entry)); +} + +export function unitHasTransmittingGroundMobileHpg(unit: CBTForceUnit): boolean { + return unit.getInventory().some(entry => + isGroundMobileHpg(entry) + && unit.isEquipmentOperational(entry) + && hpgState(entry) === HPG_TRANSMITTING_STATE); +} + +/** Planned inventory selections are the unit's weapon attacks for this turn. */ +export function unitHasSelectedWeaponAttack(unit: CBTForceUnit): boolean { + return unit.getInventory().some(entry => + entry.equipment instanceof WeaponEquipment + && !entry.isPhysicalWeapon() + && (unit.isInventoryControlEntrySelected?.(entry.id) ?? false)); +} diff --git a/src/app/utils/inventory-control.util.ts b/src/app/utils/inventory-control.util.ts index 66f214e38..7745e9ab3 100644 --- a/src/app/utils/inventory-control.util.ts +++ b/src/app/utils/inventory-control.util.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { AmmoEquipment, ArmorEquipment, WeaponEquipment } from '../models/equipment.model'; +import { AmmoEquipment, ArmorEquipment, isCoolantPodEquipment, WeaponEquipment } from '../models/equipment.model'; import type { WeaponType } from '../models/weapon-types.model'; import type { EquipmentRegistry } from '../models/equipment-lookup'; import type { CBTForceUnit, EquipmentAction } from '../models/cbt-force-unit.model'; @@ -617,7 +617,7 @@ function buildInventoryControlRow( const unitRules = entry.owner.rules; const fieldGunComponent = unitRules instanceof InfantryRules ? unitRules.getFieldGunComponent(entry) : null; const hasModelDisplay = entry.isIntrinsicPhysicalAttack() - || (!!entry.equipment && !(entry.equipment instanceof AmmoEquipment)); + || (!!entry.equipment && (!(entry.equipment instanceof AmmoEquipment) || isCoolantPodEquipment(entry.equipment))); const linkedWeaponEnhancement = isLinkedWeaponEnhancement(entry); if (entry.el && !entry.el.classList.contains('inventoryEntry') && !fieldGunComponent && !linkedWeaponEnhancement) return null; if (!entry.el && !fieldGunComponent && !hasModelDisplay) return null; diff --git a/src/app/utils/mga-state.util.spec.ts b/src/app/utils/mga-state.util.spec.ts new file mode 100644 index 000000000..a91417acb --- /dev/null +++ b/src/app/utils/mga-state.util.spec.ts @@ -0,0 +1,100 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { WeaponEquipment } from '../models/equipment.model'; +import { MountedEquipment } from '../models/mounted-equipment.model'; +import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; +import { machineGunArrayMembers, reconcileMachineGunArrayLinks } from './mga-state.util'; + +function equipment(id: string, flags: ('F_MG' | 'F_MGA')[], rackSize: number): WeaponEquipment { + return new WeaponEquipment({ + id, + name: id, + type: 'weapon', + flags, + weapon: { ammoType: rackSize === 1 ? 'MG_LIGHT' : rackSize === 3 ? 'MG_HEAVY' : 'MG', rackSize }, + }); +} + +function mounted( + owner: ReturnType['owner'], + id: string, + type: WeaponEquipment, + location = 'LT', +): MountedEquipment { + return new MountedEquipment({ + owner, + id, + name: type.name, + equipment: type, + locations: new Set([location]), + }); +} + +describe('MGA state utilities', () => { + it('infers flat same-location, same-type members and caps each array at four', () => { + const { owner } = createTestEquipmentOwner(); + const arrayType = equipment('MGA', ['F_MGA'], 2); + const gunType = equipment('MG', ['F_MG'], 2); + const firstArray = mounted(owner, 'array-1', arrayType); + const secondArray = mounted(owner, 'array-2', arrayType); + const guns = Array.from({ length: 6 }, (_, index) => mounted(owner, `gun-${index + 1}`, gunType)); + const wrongLocation = mounted(owner, 'wrong-location', gunType, 'RT'); + const wrongType = mounted(owner, 'wrong-type', equipment('Light MG', ['F_MG'], 1)); + + reconcileMachineGunArrayLinks([ + guns[0], firstArray, guns[1], guns[2], guns[3], + secondArray, guns[4], guns[5], wrongLocation, wrongType, + ]); + + expect(machineGunArrayMembers(firstArray)).toEqual(guns.slice(0, 4)); + expect(machineGunArrayMembers(secondArray)).toEqual(guns.slice(4)); + expect(wrongLocation.parent).toBeFalsy(); + expect(wrongType.parent).toBeFalsy(); + }); + + it('preserves explicit bay membership instead of greedily replacing it', () => { + const { owner } = createTestEquipmentOwner(); + const arrayType = equipment('MGA', ['F_MGA'], 2); + const gunType = equipment('MG', ['F_MG'], 2); + const array = mounted(owner, 'array', arrayType); + const first = mounted(owner, 'first', gunType); + const explicit = mounted(owner, 'explicit', gunType); + array.setLinkedEquipment([explicit]); + + reconcileMachineGunArrayLinks([first, array, explicit]); + + expect(machineGunArrayMembers(array)).toEqual([explicit]); + expect(first.parent).toBeFalsy(); + }); + + it('uses critical-slot boundaries to separate multiple flat arrays in one location', () => { + const { owner } = createTestEquipmentOwner(); + const arrayType = equipment('MGA', ['F_MGA'], 2); + const gunType = equipment('MG', ['F_MG'], 2); + const makeMounted = (id: string, type: WeaponEquipment) => new MountedEquipment({ + owner, + id, + name: type.name, + equipment: type, + locations: new Set(['LT']), + }); + const guns = Array.from({ length: 6 }, (_, index) => makeMounted(`gun-${index + 1}`, gunType)); + const firstArray = makeMounted('array-1', arrayType); + const secondArray = makeMounted('array-2', arrayType); + Object.assign(owner, { + getCritSlots: () => [ + ...guns.slice(0, 3).map((gun, slot) => ({ id: gun.id, loc: 'LT', slot })), + { id: firstArray.id, loc: 'LT', slot: 3 }, + ...guns.slice(3).map((gun, index) => ({ id: gun.id, loc: 'LT', slot: index + 4 })), + { id: secondArray.id, loc: 'LT', slot: 7 }, + ], + }); + + reconcileMachineGunArrayLinks([...guns, firstArray, secondArray]); + + expect(machineGunArrayMembers(firstArray)).toEqual(guns.slice(0, 3)); + expect(machineGunArrayMembers(secondArray)).toEqual(guns.slice(3)); + }); +}); diff --git a/src/app/utils/mga-state.util.ts b/src/app/utils/mga-state.util.ts new file mode 100644 index 000000000..33abdb538 --- /dev/null +++ b/src/app/utils/mga-state.util.ts @@ -0,0 +1,144 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { WeaponEquipment } from '../models/equipment.model'; +import type { MountedEquipment } from '../models/mounted-equipment.model'; + +export const MGA_ACTIVATION_STATE_KEY = 'mgaActivation'; +export const MGA_ACTIVE_STATE = 'active'; +export const MGA_TURNING_ON_STATE = 'turning-on'; +export const MGA_OFF_STATE = 'off'; +export const MGA_TURNING_OFF_STATE = 'turning-off'; + +export type MgaActivationState = + | typeof MGA_ACTIVE_STATE + | typeof MGA_TURNING_ON_STATE + | typeof MGA_OFF_STATE + | typeof MGA_TURNING_OFF_STATE; + +/** MGAs begin active unless the owning player turns them off during an End Phase. */ +export function machineGunArrayActivationState(array: MountedEquipment): MgaActivationState { + const state = array.states.get(MGA_ACTIVATION_STATE_KEY); + return state === MGA_ACTIVE_STATE + || state === MGA_TURNING_ON_STATE + || state === MGA_OFF_STATE + || state === MGA_TURNING_OFF_STATE + ? state + : MGA_ACTIVE_STATE; +} + +/** A pending End-Phase change retains the state that applies during the current turn. */ +export function isMachineGunArrayEffectivelyActive(array: MountedEquipment): boolean { + const state = machineGunArrayActivationState(array); + return state === MGA_ACTIVE_STATE || state === MGA_TURNING_OFF_STATE; +} + +export function isMachineGunArray(equipment: MountedEquipment): boolean { + return equipment.equipment instanceof WeaponEquipment + && equipment.equipment.hasFlag('F_MGA'); +} + +export function machineGunArrayController(equipment: MountedEquipment): MountedEquipment | null { + return equipment.parent && isMachineGunArray(equipment.parent) + ? equipment.parent + : null; +} + +export function isMachineGunArrayMember(equipment: MountedEquipment): boolean { + return machineGunArrayController(equipment) !== null; +} + +/** Returns only same-location, same-type machine guns that are valid members of this array. */ +export function machineGunArrayMembers(array: MountedEquipment): MountedEquipment[] { + if (!isMachineGunArray(array)) return []; + return [...(array.linkedWith ?? [])].filter(member => isCompatibleMachineGunArrayMember(array, member)); +} + +export function operationalMachineGunArrayMembers( + array: MountedEquipment, + isOperational: (member: MountedEquipment) => boolean = member => member.owner.isEquipmentOperational(member), +): MountedEquipment[] { + return machineGunArrayMembers(array).filter(isOperational); +} + +/** + * Restores MGA bays when a record-sheet SVG exports the array and its guns as flat rows. + * Explicit nested links win; otherwise arrays claim up to four compatible, unclaimed guns + * in inventory order, matching MegaMek's loader fallback. + */ +export function reconcileMachineGunArrayLinks(inventory: readonly MountedEquipment[]): void { + const arrays = inventory.filter(isMachineGunArray); + const claimed = new Set(); + + for (const array of arrays) { + const explicitMembers = machineGunArrayMembers(array); + if (explicitMembers.length === 0) continue; + explicitMembers.forEach(member => claimed.add(member)); + } + + for (const array of arrays) { + if (machineGunArrayMembers(array).length > 0) continue; + const criticalSlotMembers = inferCriticalSlotMachineGunArrayMembers(array, inventory, claimed); + const inferredMembers = criticalSlotMembers.length > 0 ? criticalSlotMembers : inventory + .filter(candidate => !claimed.has(candidate) + && (!candidate.parent || candidate.parent === array) + && isCompatibleMachineGunArrayMember(array, candidate)) + .slice(0, 4); + if (inferredMembers.length === 0) continue; + array.setLinkedEquipment(inferredMembers); + inferredMembers.forEach(member => claimed.add(member)); + } +} + +function inferCriticalSlotMachineGunArrayMembers( + array: MountedEquipment, + inventory: readonly MountedEquipment[], + claimed: ReadonlySet, +): MountedEquipment[] { + const candidatesById = new Map(inventory + .filter(candidate => !claimed.has(candidate) + && (!candidate.parent || candidate.parent === array) + && isCompatibleMachineGunArrayMember(array, candidate)) + .map(candidate => [candidate.id, candidate])); + if (candidatesById.size === 0) return []; + + const arrayLocations = array.locations; + const slots = array.owner.getCritSlots() + .filter(slot => !arrayLocations?.size || (!!slot.loc && arrayLocations.has(slot.loc))) + .sort((first, second) => (first.slot ?? Number.MAX_SAFE_INTEGER) - (second.slot ?? Number.MAX_SAFE_INTEGER)); + if (slots.length === 0) return []; + + const members: MountedEquipment[] = []; + let started = false; + for (const slot of slots) { + const candidate = candidatesById.get(slot.id); + if (candidate) { + if (!members.includes(candidate)) members.push(candidate); + started = true; + if (members.length >= 4) break; + } else if (started) { + break; + } + } + return members; +} + +function isCompatibleMachineGunArrayMember(array: MountedEquipment, candidate: MountedEquipment): boolean { + const arrayType = array.equipment; + const candidateType = candidate.equipment; + return candidate !== array + && arrayType instanceof WeaponEquipment + && candidateType instanceof WeaponEquipment + && candidateType.hasFlag('F_MG') + && !candidateType.hasFlag('F_MGA') + && candidateType.rackSize === arrayType.rackSize + && mountedLocationsOverlap(array, candidate); +} + +function mountedLocationsOverlap(first: MountedEquipment, second: MountedEquipment): boolean { + const firstLocations = first.locations; + const secondLocations = second.locations; + if (!firstLocations?.size || !secondLocations?.size) return true; + return [...firstLocations].some(location => secondLocations.has(location)); +} diff --git a/src/app/utils/shield-mode.util.ts b/src/app/utils/shield-mode.util.ts new file mode 100644 index 000000000..dd2106589 --- /dev/null +++ b/src/app/utils/shield-mode.util.ts @@ -0,0 +1,98 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { MountedEquipment } from '../models/mounted-equipment.model'; + +export const SHIELD_MODE_STATE_KEY = 'shieldMode'; +export const SHIELD_INACTIVE_MODE = 'None'; +export const SHIELD_RAISED_MODE = 'Active'; +export const SHIELD_PASSIVE_MODE = 'Passive'; + +export type ShieldMode = + | typeof SHIELD_INACTIVE_MODE + | typeof SHIELD_RAISED_MODE + | typeof SHIELD_PASSIVE_MODE; + +export interface ShieldModeOption { + readonly label: string; + readonly value: ShieldMode; +} + +const CORE_SHIELD_MODES: readonly ShieldModeOption[] = [ + // MegaMek serializes the Core lowered state as its legacy "None" mode. + { label: 'Lowered', value: SHIELD_INACTIVE_MODE }, + { label: 'Raised', value: SHIELD_RAISED_MODE }, +]; + +const TW_SHIELD_MODES: readonly ShieldModeOption[] = [ + { label: 'Inactive', value: SHIELD_INACTIVE_MODE }, + { label: 'Active', value: SHIELD_RAISED_MODE }, + { label: 'Passive', value: SHIELD_PASSIVE_MODE }, +]; + +export function shieldModeOptions(mounted: MountedEquipment): readonly ShieldModeOption[] { + return mounted.owner.gameRules.id === 'core2026' ? CORE_SHIELD_MODES : TW_SHIELD_MODES; +} + +export function selectedShieldMode(mounted: MountedEquipment): ShieldMode { + const selected = mounted.states.get(SHIELD_MODE_STATE_KEY); + const modes = shieldModeOptions(mounted); + return modes.some(mode => mode.value === selected) + ? selected as ShieldMode + : SHIELD_INACTIVE_MODE; +} + +export function setShieldMode(mounted: MountedEquipment, mode: ShieldMode): boolean { + if (!shieldModeOptions(mounted).some(option => option.value === mode)) return false; + if (!mounted.setState(SHIELD_MODE_STATE_KEY, mode)) return false; + mounted.owner.setInventoryEntry(mounted); + return true; +} + +export function isShieldRaised(mounted: MountedEquipment): boolean { + return selectedShieldMode(mounted) === SHIELD_RAISED_MODE; +} + +export function shieldMountingArm(mounted: MountedEquipment): 'LA' | 'RA' | null { + return Array.from(mounted.locations ?? []).find((location): location is 'LA' | 'RA' => + location === 'LA' || location === 'RA') + ?? mounted.critSlots + ?.map(slot => slot.loc) + .find((location): location is 'LA' | 'RA' => location === 'LA' || location === 'RA') + ?? null; +} + +/** Whether this shield mode prevents attacks from this mounted location. */ +export function shieldProtectsLocation( + mounted: MountedEquipment, + location: string, + rearMounted = false, +): boolean { + const arm = shieldMountingArm(mounted); + if (!arm) return false; + + switch (selectedShieldMode(mounted)) { + case SHIELD_RAISED_MODE: + if (mounted.owner.gameRules.id === 'core2026') { + // Core shields cover the center torso and the mounting side, but + // not the head. Rear-mounted weapons may still fire. + if (rearMounted || location === 'HD') return false; + if (location === 'CT') return true; + return arm === 'LA' + ? location === 'LA' || location === 'LT' || location === 'LL' + : location === 'RA' || location === 'RT' || location === 'RL'; + } + if (location === 'CT') return !rearMounted; + if (location === 'HD') return true; + return arm === 'LA' + ? location === 'LA' || location === 'LT' || location === 'LL' + : location === 'RA' || location === 'RT' || location === 'RL'; + case SHIELD_PASSIVE_MODE: + return !rearMounted && (arm === 'LA' + ? location === 'LA' || location === 'LT' + : location === 'RA' || location === 'RT'); + case SHIELD_INACTIVE_MODE: + return location === arm; + } +} From e8a3368697b79234d8710fd96120c96420011d2e Mon Sep 17 00:00:00 2001 From: exeea Date: Sat, 29 Aug 2026 23:43:44 +0200 Subject: [PATCH 80/87] test fix --- src/app/services/unit-search-filters.service.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/services/unit-search-filters.service.spec.ts b/src/app/services/unit-search-filters.service.spec.ts index a28f2481b..90f42e6e4 100644 --- a/src/app/services/unit-search-filters.service.spec.ts +++ b/src/app/services/unit-search-filters.service.spec.ts @@ -5405,7 +5405,7 @@ describe('UnitSearchFiltersService search telemetry', () => { const request = (service as any).buildWorkerSearchRequest((service as any).getWorkerCorpusVersion()); - expect(request.executionQuery).toContain('chassis=Longbow'); + expect(request.executionQuery).toContain('chassis="Longbow"'); }); it('serializes multistate era selections into worker execution queries', () => { From d923711341a89d4f29ee8d6a0bdb443dd90ec629 Mon Sep 17 00:00:00 2001 From: exeea Date: Sat, 29 Aug 2026 23:46:14 +0200 Subject: [PATCH 81/87] . --- src/app/services/unit-search-filters.service.spec.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/app/services/unit-search-filters.service.spec.ts b/src/app/services/unit-search-filters.service.spec.ts index 90f42e6e4..6a27d82fc 100644 --- a/src/app/services/unit-search-filters.service.spec.ts +++ b/src/app/services/unit-search-filters.service.spec.ts @@ -5385,13 +5385,8 @@ describe('UnitSearchFiltersService search telemetry', () => { }); it('preserves semantic-only chassis filters in worker execution queries', () => { - if (!benchmarkBundle || benchmarkBundle.units.units.length < 2) { - pending('Real unit data could not be loaded for the worker semantic filter test.'); - return; - } - const worker = new FakeSearchWorker(); - const bundle = buildSmallBundle(benchmarkBundle); + const bundle = createStandaloneBundle(); bundle.units.units[0].name = 'Longbow Prime'; bundle.units.units[0].chassis = 'Longbow'; bundle.units.units[1].name = 'Catapult Prime'; From 0dd96997f9c9fb65a24fc81e95460ccae40a51f0 Mon Sep 17 00:00:00 2001 From: exeea Date: Sun, 30 Aug 2026 11:06:20 +0200 Subject: [PATCH 82/87] run2 filter --- src/app/services/unit-search-filters.model.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/services/unit-search-filters.model.ts b/src/app/services/unit-search-filters.model.ts index 5364197b5..5872ce84b 100644 --- a/src/app/services/unit-search-filters.model.ts +++ b/src/app/services/unit-search-filters.model.ts @@ -490,6 +490,7 @@ export const RANGE_FILTERS: readonly RangeFilterConfig[] = Object.freeze([ { key: '_maxRange', semanticKey: 'range', label: 'Range', curve: 0, game: GameSystem.CLASSIC }, { key: 'walk', semanticKey: 'walk', label: 'Walk MP', curve: 0.9, game: GameSystem.CLASSIC }, { key: 'run', semanticKey: 'run', label: 'Run MP', curve: 0.9, game: GameSystem.CLASSIC }, + { key: 'run2', semanticKey: 'runMax', label: 'Run MP (max w/mod)', curve: 0.9, game: GameSystem.CLASSIC }, { key: 'jump', semanticKey: 'jump', label: 'Jump MP', curve: 0.9, game: GameSystem.CLASSIC }, { key: 'umu', semanticKey: 'umu', label: 'UMU MP', curve: 0.9, game: GameSystem.CLASSIC }, { key: 'year', semanticKey: 'year', label: 'Intro Year', curve: 1 }, From fded5cd92ce72f5aaa1f6e0874704ae75a3a802f Mon Sep 17 00:00:00 2001 From: exeea Date: Sun, 30 Aug 2026 11:11:46 +0200 Subject: [PATCH 83/87] rototype Medium Pulse Laser: +6 heat (1D6 roll: 6) --- .../equipment-dialog.component.spec.ts | 2 +- .../equipment-dialog.model.ts | 4 +- .../weapons-equipment-panel.component.spec.ts | 58 ++++++++++++++++++ .../weapons-equipment-panel.component.ts | 61 ++++++++++++++++--- .../prototype-laser.handler.spec.ts | 27 +++++--- .../prototype-laser.handler.ts | 14 +++-- .../equipment-handlers/weapon-ammo.handler.ts | 2 +- .../equipment-interaction-registry.service.ts | 19 +++++- 8 files changed, 157 insertions(+), 30 deletions(-) diff --git a/src/app/components/equipment-dialog/equipment-dialog.component.spec.ts b/src/app/components/equipment-dialog/equipment-dialog.component.spec.ts index 5e490dc9a..5e034a4ea 100644 --- a/src/app/components/equipment-dialog/equipment-dialog.component.spec.ts +++ b/src/app/components/equipment-dialog/equipment-dialog.component.spec.ts @@ -107,7 +107,7 @@ function createContext(): EquipmentDialogContext { registry: { getChoices: () => [], handleSelection: () => false, - afterInventoryControlFire: () => undefined, + afterInventoryControlFire: async () => [], inventoryControlRules: () => ({}) }, queryContext: createHandlerQueryContext(equipmentCatalog), diff --git a/src/app/components/equipment-dialog/equipment-dialog.model.ts b/src/app/components/equipment-dialog/equipment-dialog.model.ts index 5a17f5ec1..19fa858da 100644 --- a/src/app/components/equipment-dialog/equipment-dialog.model.ts +++ b/src/app/components/equipment-dialog/equipment-dialog.model.ts @@ -5,7 +5,7 @@ import type { Signal } from '@angular/core'; import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; import type { MountedEquipment } from '../../models/mounted-equipment.model'; -import type { HandlerChoice, HandlerCommandContext, HandlerQueryContext } from '../../services/equipment-interaction-registry.service'; +import type { HandlerChoice, HandlerCommandContext, HandlerQueryContext, InventoryControlFireResult } from '../../services/equipment-interaction-registry.service'; import type { InventoryControlRules } from '../../utils/inventory-control.util'; export type EquipmentDialogTab = 'weapons' | 'ammo'; @@ -13,7 +13,7 @@ export type EquipmentDialogTab = 'weapons' | 'ammo'; export interface EquipmentDialogRegistry { getChoices(entry: MountedEquipment, context: HandlerQueryContext): HandlerChoice[]; handleSelection(entry: MountedEquipment, choice: HandlerChoice, context: HandlerCommandContext): boolean | Promise; - afterInventoryControlFire(entry: MountedEquipment): void | Promise; + afterInventoryControlFire(entry: MountedEquipment): Promise; applyInventoryControlAmmoConsumption?(entry: MountedEquipment, count: number, context: HandlerQueryContext): number; inventoryControlRules(context: HandlerQueryContext): InventoryControlRules; } diff --git a/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts b/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts index ca3397773..0c7f114d7 100644 --- a/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts +++ b/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts @@ -47,6 +47,7 @@ import { SHIELD_INACTIVE_MODE, SHIELD_RAISED_MODE } from '../../utils/shield-mod import { C3Handler } from '../../equipment-handlers/c3.handler'; import { MgaActivationHandler } from '../../equipment-handlers/mga-activation.handler'; import { MGA_ACTIVATION_STATE_KEY, MGA_OFF_STATE } from '../../utils/mga-state.util'; +import { PrototypeLaserHandler } from '../../equipment-handlers/prototype-laser.handler'; function weapon(id: string, ammoType: Extract = 'NA', rackSize = 0, ranges: number[] = [1, 2, 3, 4], toHitModifier = 0, heat = 0): WeaponEquipment { const flags: EquipmentFlag[] = ammoType === 'MRM' @@ -3007,6 +3008,63 @@ describe('WeaponsEquipmentPanelComponent', () => { expect(turnState.heatSources()).toContain(jasmine.objectContaining({ id: 'weapons', value: 10 })); }); + it('commits and reports the exact random heat rolled by a prototype laser', async () => { + const prototype = entry({ + id: 'prototype-medium-pulse-laser', + equipment: weapon('ISMediumPulseLaserPrototype', 'NA', 0, [1, 2, 3, 4], 0, 4), + el: svgEntry('Prototype Medium Pulse Laser4*') + }); + spyOn(Math, 'random').and.returnValue(5 / 6); + const { component, dialogsService, heat, turnState } = createComponent( + [prototype], + {}, + [], + new Map(), + { + handlers: [new PrototypeLaserHandler()], + heatDissipation: 3, + heatNext: 10, + }, + ); + const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; + + component.toggleSelected(row); + await component.consumeSelectedHeatAndAmmo(); + + expect((turnState.addFiredHeat as jasmine.Spy).calls.allArgs()).toEqual([[4], [6]]); + expect(turnState.heatSources()).toContain(jasmine.objectContaining({ id: 'weapons', value: 10 })); + expect(heat.next).toBe(17); + expect(dialogsService.showNoticeHtml).toHaveBeenCalledWith( + jasmine.stringMatching(/Heat Projection: \+10[\s\S]*ISMediumPulseLaserPrototype: \+6 heat \(1D6 roll: 6\)/), + 'Weapons Fired', + ); + }); + + it('applies dissipation to random prototype-laser heat before updating a manual heat target', async () => { + const prototype = entry({ + id: 'prototype-medium-pulse-laser', + equipment: weapon('ISMediumPulseLaserPrototype', 'NA', 0, [1, 2, 3, 4], 0, 4), + }); + spyOn(Math, 'random').and.returnValue(5 / 6); + const { component, heat } = createComponent( + [prototype], + {}, + [], + new Map(), + { + handlers: [new PrototypeLaserHandler()], + heatDissipation: 20, + heatNext: 0, + }, + ); + const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; + + component.toggleSelected(row); + await component.consumeSelectedHeatAndAmmo(); + + expect(heat.next).toBe(0); + }); + it('uses only the remaining dissipation after heat was applied this turn', () => { const laser = entry({ id: 'laser', diff --git a/src/app/components/equipment-dialog/weapons-equipment-panel.component.ts b/src/app/components/equipment-dialog/weapons-equipment-panel.component.ts index 4fc21475b..41b4ac34b 100644 --- a/src/app/components/equipment-dialog/weapons-equipment-panel.component.ts +++ b/src/app/components/equipment-dialog/weapons-equipment-panel.component.ts @@ -8,7 +8,7 @@ import { Overlay } from '@angular/cdk/overlay'; import { ComponentPortal } from '@angular/cdk/portal'; import { outputToObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop'; import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; -import type { HandlerChoice } from '../../services/equipment-interaction-registry.service'; +import type { HandlerChoice, InventoryControlFireResult } from '../../services/equipment-interaction-registry.service'; import { OverlayManagerService } from '../../services/overlay-manager.service'; import { INVENTORY_MODE_CHOICE_LABEL, INVENTORY_MODE_HANDLER_ID } from '../../equipment-handlers/inventory-mode.handler'; import { changeAmmoEntriesRemaining, getAmmoControlEntriesForWeapon, getAmmoEntryRemaining, setAmmoEntryValue } from '../../utils/ammo-interaction.util'; @@ -136,6 +136,10 @@ interface AmmoConsumptionSummaryItem { count: number; } +interface InventoryControlFireSummaryItem extends InventoryControlFireResult { + label: string; +} + interface DragPreviewCellSizing { path: number[]; width: number; @@ -914,12 +918,25 @@ export class WeaponsEquipmentPanelComponent { this.unit().setHeat(heatProjection.final); } } - await this.runSelectedFireHooks(selectedRows); + const fireSummary = await this.runSelectedFireHooks(selectedRows); + const additionalHeat = fireSummary.reduce( + (total, result) => total + this.normalizedAdditionalHeat(result), + 0, + ); + if (heatProjection && additionalHeat > 0) { + this.unit().turnState().addFiredHeat(additionalHeat); + if (hasManualHeatTarget) { + this.unit().setHeat(Math.max( + 0, + heatProjection.pending + additionalHeat - heatProjection.dissipation, + )); + } + } this.inventoryControl().markInventoryViewChanged(); const ammoSummary = Array.from(requests.values()) .map(request => this.consumedAmmoSummaryItem(request)); await this.context().commandContext.dialogsService.showNoticeHtml( - this.consumptionSummaryHtml(ammoSummary, heatProjection), + this.consumptionSummaryHtml(ammoSummary, heatProjection, fireSummary), 'Weapons Fired' ); } @@ -962,10 +979,18 @@ export class WeaponsEquipmentPanelComponent { } } - private async runSelectedFireHooks(selectedRows: InventoryControlRow[]): Promise { + private async runSelectedFireHooks( + selectedRows: InventoryControlRow[], + ): Promise { + const summary: InventoryControlFireSummaryItem[] = []; for (const row of selectedRows) { - await this.context().registry.afterInventoryControlFire(row.entry); + const results = await this.context().registry.afterInventoryControlFire(row.entry); + summary.push(...results.map(result => ({ + ...result, + label: row.display.name, + }))); } + return summary; } private consumedAmmoSummaryItem(request: AmmoConsumptionRequest): AmmoConsumptionSummaryItem { @@ -979,12 +1004,34 @@ export class WeaponsEquipmentPanelComponent { }; } - private consumptionSummaryHtml(ammoSummary: AmmoConsumptionSummaryItem[], heatProjection: SelectedHeatProjection | null): string { + private consumptionSummaryHtml( + ammoSummary: AmmoConsumptionSummaryItem[], + heatProjection: SelectedHeatProjection | null, + fireSummary: readonly InventoryControlFireSummaryItem[], + ): string { const ammoHtml = ammoSummary.length > 0 ? `Ammo consumed:
    ${ammoSummary.map(item => `
  • ${item.count} ammo from ${this.escapeHtml(item.label)}
  • `).join('')}
` : '

No ammo consumed.

'; if (!heatProjection) return ammoHtml; - return `${ammoHtml}

Heat Projection: +${heatProjection.selection}

`; + const additionalHeat = fireSummary.reduce( + (total, result) => total + this.normalizedAdditionalHeat(result), + 0, + ); + const additionalHeatItems = fireSummary + .filter(result => this.normalizedAdditionalHeat(result) > 0) + .map(result => { + const detail = result.detail ? ` (${this.escapeHtml(result.detail)})` : ''; + return `
  • ${this.escapeHtml(result.label)}: +${this.normalizedAdditionalHeat(result)} heat${detail}
  • `; + }); + const additionalHeatHtml = additionalHeatItems.length > 0 + ? `Additional heat:
      ${additionalHeatItems.join('')}
    ` + : ''; + return `${ammoHtml}

    Heat Projection: +${heatProjection.selection + additionalHeat}

    ${additionalHeatHtml}`; + } + + private normalizedAdditionalHeat(result: InventoryControlFireResult): number { + const value = result.additionalHeat ?? 0; + return Number.isFinite(value) ? Math.max(0, value) : 0; } private escapeHtml(value: string): string { diff --git a/src/app/equipment-handlers/prototype-laser.handler.spec.ts b/src/app/equipment-handlers/prototype-laser.handler.spec.ts index 0c3487479..1f94d2b34 100644 --- a/src/app/equipment-handlers/prototype-laser.handler.spec.ts +++ b/src/app/equipment-handlers/prototype-laser.handler.spec.ts @@ -38,7 +38,7 @@ describe('PrototypeLaserHandler', () => { const handler = new PrototypeLaserHandler(); const context = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); - it('marks ground prototype heat as variable and rolls the extra heat after firing', () => { + it('marks ground prototype heat as variable and returns the rolled extra heat after firing', () => { const medium = fixture('ISMediumPulseLaserPrototype'); spyOn(Math, 'random').and.returnValue(5 / 6); @@ -49,29 +49,36 @@ describe('PrototypeLaserHandler', () => { context, )).toEqual({ value: 10, weakened: false, suffix: '*' }); - handler.afterInventoryControlFire(medium.mounted); - expect(medium.addFiredHeat).toHaveBeenCalledOnceWith(6); + expect(handler.afterInventoryControlFire(medium.mounted)).toEqual({ + additionalHeat: 6, + detail: '1D6 roll: 6', + }); + expect(medium.addFiredHeat).not.toHaveBeenCalled(); expect(medium.setHeat).not.toHaveBeenCalled(); }); - it('adds random ground prototype heat to an existing manual heat target', () => { + it('leaves committing a manual heat target to the firing workflow', () => { const medium = fixture('ISMediumPulseLaserPrototype'); medium.mounted.owner.setHeat(14); medium.setHeat.calls.reset(); spyOn(Math, 'random').and.returnValue(5 / 6); - handler.afterInventoryControlFire(medium.mounted); + const result = handler.afterInventoryControlFire(medium.mounted); - expect(medium.addFiredHeat).toHaveBeenCalledOnceWith(6); - expect(medium.setHeat).toHaveBeenCalledOnceWith(20); + expect(result?.additionalHeat).toBe(6); + expect(medium.addFiredHeat).not.toHaveBeenCalled(); + expect(medium.setHeat).not.toHaveBeenCalled(); }); it('uses 1D3 extra heat for the small prototype pulse laser', () => { const small = fixture('ISSmallPulseLaserPrototype'); spyOn(Math, 'random').and.returnValue(5 / 6); - handler.afterInventoryControlFire(small.mounted); - expect(small.addFiredHeat).toHaveBeenCalledOnceWith(3); + expect(handler.afterInventoryControlFire(small.mounted)).toEqual({ + additionalHeat: 3, + detail: '1D3 (1D6 roll: 6)', + }); + expect(small.addFiredHeat).not.toHaveBeenCalled(); }); it('uses maximum extra heat for aerospace firing without a random post-fire roll', () => { @@ -82,7 +89,7 @@ describe('PrototypeLaserHandler', () => { { value: 12, weakened: false }, context, )).toEqual({ value: 18, weakened: false }); - handler.afterInventoryControlFire(aero.mounted); + expect(handler.afterInventoryControlFire(aero.mounted)).toBeUndefined(); expect(aero.addFiredHeat).not.toHaveBeenCalled(); }); diff --git a/src/app/equipment-handlers/prototype-laser.handler.ts b/src/app/equipment-handlers/prototype-laser.handler.ts index 465608622..e9ab03df1 100644 --- a/src/app/equipment-handlers/prototype-laser.handler.ts +++ b/src/app/equipment-handlers/prototype-laser.handler.ts @@ -9,6 +9,7 @@ import { EquipmentInteractionHandler, type HandlerCommandContext, type HandlerQueryContext, + type InventoryControlFireResult, } from '../services/equipment-interaction-registry.service'; const PROTOTYPE_LASER_MAX_EXTRA_HEAT = new Map([ @@ -52,17 +53,18 @@ export class PrototypeLaserHandler extends EquipmentInteractionHandler { return { ...effect, suffix: '*' }; } - override afterInventoryControlFire(equipment: MountedEquipment): void { + override afterInventoryControlFire(equipment: MountedEquipment): InventoryControlFireResult | void { if (equipment.owner.getUnit().type === 'Aero') return; const maximum = this.maximumExtraHeat(equipment); if (maximum === 0) return; const roll = Math.floor(Math.random() * 6) + 1; const extraHeat = maximum === 3 ? Math.ceil(roll / 2) : roll; - const manualHeatTarget = equipment.owner.getHeat().next; - equipment.owner.turnState().addFiredHeat(extraHeat); - if (manualHeatTarget !== undefined) { - equipment.owner.setHeat(manualHeatTarget + extraHeat); - } + return { + additionalHeat: extraHeat, + detail: maximum === 3 + ? `1D3 (1D6 roll: ${roll})` + : `1D6 roll: ${roll}`, + }; } private maximumExtraHeat(equipment: MountedEquipment): 0 | 3 | 6 { diff --git a/src/app/equipment-handlers/weapon-ammo.handler.ts b/src/app/equipment-handlers/weapon-ammo.handler.ts index 56c2a4d25..1501db3ae 100644 --- a/src/app/equipment-handlers/weapon-ammo.handler.ts +++ b/src/app/equipment-handlers/weapon-ammo.handler.ts @@ -57,7 +57,7 @@ export class WeaponAmmoHandler extends EquipmentInteractionHandler { registry: { getChoices: () => [], handleSelection: () => false, - afterInventoryControlFire: () => undefined, + afterInventoryControlFire: async () => [], inventoryControlRules: () => ({}) }, queryContext: createHandlerQueryContext(equipmentCatalog), diff --git a/src/app/services/equipment-interaction-registry.service.ts b/src/app/services/equipment-interaction-registry.service.ts index bb2f5681c..e1333594d 100644 --- a/src/app/services/equipment-interaction-registry.service.ts +++ b/src/app/services/equipment-interaction-registry.service.ts @@ -53,6 +53,14 @@ export interface CriticalDelayedExplosionHandling { readonly explosion: CriticalDelayedExplosion | null; } +/** Additional state produced by an inventory-control firing hook. */ +export interface InventoryControlFireResult { + /** Heat generated in addition to the firing heat already shown for the entry. */ + readonly additionalHeat?: number; + /** Plain-text explanation shown alongside the additional heat. */ + readonly detail?: string; +} + /** Returns the original set when no change is needed. */ export function setEffectiveWeaponType( types: ReadonlySet, @@ -174,7 +182,9 @@ export abstract class EquipmentInteractionHandler { /** * Hook called after a mounted equipment entry is fired/consumed from the weapons panel. */ - afterInventoryControlFire?(equipment: MountedEquipment): void | Promise; + afterInventoryControlFire?( + equipment: MountedEquipment, + ): InventoryControlFireResult | void | Promise; /** * Hook called immediately before pending equipment and critical-slot damage is committed. @@ -436,10 +446,13 @@ export class EquipmentInteractionRegistry { : equipment.owner.canPerformEquipmentAction(equipment, choice.action ?? 'change-mode'); } - async afterInventoryControlFire(equipment: MountedEquipment): Promise { + async afterInventoryControlFire(equipment: MountedEquipment): Promise { + const results: InventoryControlFireResult[] = []; for (const handler of this.getHandlers(equipment)) { - await handler.afterInventoryControlFire?.(equipment); + const result = await handler.afterInventoryControlFire?.(equipment); + if (result) results.push(result); } + return results; } beforeEquipmentStateCommit(equipment: MountedEquipment): void { From fefe3cf5f036fb5e6d15ee58af4f32e6326aa30e Mon Sep 17 00:00:00 2001 From: exeea Date: Sun, 30 Aug 2026 21:55:28 +0200 Subject: [PATCH 84/87] faction exclusive filter is now era-aware --- .../unit-details-dialog.component.ts | 11 +- .../unit-search/unit-search.component.html | 4 +- .../unit-search/unit-search.component.spec.ts | 67 ++++---- .../unit-search/unit-search.component.ts | 57 +++---- src/app/models/unit-summary.model.ts | 6 +- src/app/services/data.service.spec.ts | 5 + src/app/services/data.service.ts | 16 +- src/app/services/unit-runtime.service.spec.ts | 12 ++ src/app/services/unit-runtime.service.ts | 27 ++- .../unit-search-filters.service.spec.ts | 123 +++++++++++--- .../services/unit-search-filters.service.ts | 148 +++++++++-------- .../unit-search-index.service.spec.ts | 30 ++++ src/app/services/unit-search-index.service.ts | 115 +++++++------ src/app/unit-search.worker.spec.ts | 120 +++++++++----- src/app/unit-search.worker.ts | 88 +++++----- .../utils/semantic-filter-ast.util.spec.ts | 101 +++++++++++- src/app/utils/semantic-filter-ast.util.ts | 156 +++++++++--------- src/app/utils/unit-filter-kernel.util.ts | 6 +- src/app/utils/unit-search-adv-options.util.ts | 4 +- .../utils/unit-search-executor.util.spec.ts | 16 +- src/app/utils/unit-search-executor.util.ts | 28 ++-- .../utils/unit-search-worker-protocol.util.ts | 6 +- .../unit-search-worker-request.util.spec.ts | 17 ++ .../utils/unit-search-worker-request.util.ts | 10 +- .../unit-search-worker-result.util.spec.ts | 44 +++-- .../utils/unit-search-worker-result.util.ts | 24 +-- 26 files changed, 799 insertions(+), 442 deletions(-) diff --git a/src/app/components/unit-details-dialog/unit-details-dialog.component.ts b/src/app/components/unit-details-dialog/unit-details-dialog.component.ts index a50c59b86..47c2cdaa1 100644 --- a/src/app/components/unit-details-dialog/unit-details-dialog.component.ts +++ b/src/app/components/unit-details-dialog/unit-details-dialog.component.ts @@ -43,6 +43,7 @@ export interface UnitDetailsDialogData { unitIndex: number; gunnerySkill?: number; pilotingSkill?: number; + /** Search normalization context keyed by unit UUID. */ searchResultContexts?: ReadonlyMap; hideAddButton?: boolean; /** When true, ADD only emits the unit without adding to force */ @@ -137,8 +138,8 @@ export class UnitDetailsDialogComponent { }); readonly searchResultContext = computed(() => { const currentUnit = this.unitList()[this.unitIndex()]; - const unitName = currentUnit instanceof ForceUnit ? currentUnit.getUnit().name : currentUnit?.name; - return unitName ? this.data.searchResultContexts?.get(unitName) ?? null : null; + const unitUuid = currentUnit instanceof ForceUnit ? currentUnit.getUnit().uuid : currentUnit?.uuid; + return unitUuid ? this.data.searchResultContexts?.get(unitUuid) ?? null : null; }); gunnerySkill = computed(() => { const currentUnit = this.unitList()[this.unitIndex()] @@ -167,8 +168,8 @@ export class UnitDetailsDialogComponent { isSwipeAnimating = signal(false); incomingUnit = signal(null); readonly incomingSearchResultContext = computed(() => { - const unitName = this.incomingUnit()?.name; - return unitName ? this.data.searchResultContexts?.get(unitName) ?? null : null; + const unitUuid = this.incomingUnit()?.uuid; + return unitUuid ? this.data.searchResultContexts?.get(unitUuid) ?? null : null; }); readonly incomingGunnerySkill = computed(() => { const context = this.incomingSearchResultContext(); @@ -861,4 +862,4 @@ export class UnitDetailsDialogComponent { this.incomingPanelOffset.set('100%'); this.incomingUnit.set(null); } -} \ No newline at end of file +} diff --git a/src/app/components/unit-search/unit-search.component.html b/src/app/components/unit-search/unit-search.component.html index b02578186..9dcd83dbc 100644 --- a/src/app/components/unit-search/unit-search.component.html +++ b/src/app/components/unit-search/unit-search.component.html @@ -260,7 +260,7 @@ minBufferPx="900" maxBufferPx="1600" tabindex="0">
    - @for (unit of row; let columnIndex = $index; track unit.name) { + @for (unit of row; let columnIndex = $index; track unit.uuid) {
    @if (gameService.isAlphaStrike()) { diff --git a/src/app/components/unit-search/unit-search.component.spec.ts b/src/app/components/unit-search/unit-search.component.spec.ts index 271075c88..2ca2b30a7 100644 --- a/src/app/components/unit-search/unit-search.component.spec.ts +++ b/src/app/components/unit-search/unit-search.component.spec.ts @@ -155,7 +155,7 @@ describe('UnitSearchComponent card virtualization', () => { }; const dataServiceStub = { - getUnitByName: jasmine.createSpy('getUnitByName').and.returnValue(undefined), + getUnitByUuid: jasmine.createSpy('getUnitByUuid').and.returnValue(undefined), }; const taggingServiceStub = { @@ -218,8 +218,8 @@ describe('UnitSearchComponent card virtualization', () => { filtersServiceStub.setBvNormalizationSettings.calls.reset(); forceBuilderServiceStub.addUnit.calls.reset(); forceBuilderServiceStub.addUnit.and.resolveTo(true); - dataServiceStub.getUnitByName.calls.reset(); - dataServiceStub.getUnitByName.and.returnValue(undefined); + dataServiceStub.getUnitByUuid.calls.reset(); + dataServiceStub.getUnitByUuid.and.returnValue(undefined); filtersServiceStub.setSearchText.and.callFake((text: string) => { filtersServiceStub.searchText.set(text); return text; @@ -274,7 +274,7 @@ describe('UnitSearchComponent card virtualization', () => { style="height: 640px;">
    - @for (unit of row; let columnIndex = $index; track unit.name) { + @for (unit of row; let columnIndex = $index; track unit.uuid) {
    {{ unit.name }}
    @@ -286,7 +286,7 @@ describe('UnitSearchComponent card virtualization', () => {
    {{ inlinePanelUnit()?.name }}
    } @if (expandedView()) { - @for (unit of displayedUnits(); track unit.name) { + @for (unit of displayedUnits(); track unit.uuid) { { expect(calculateDataTableMinWidth(columns)).toBe(2180); }); - it('provides stable unit keys for variable-height measurements across result objects', () => { + it('provides stable UUID keys for variable-height measurements across result reordering', () => { const fixture = TestBed.createComponent(UnitSearchComponent); const component = fixture.componentInstance; - filteredUnitsSignal.set([createUnit('Short'), createUnit('Tall')]); + const short = createUnit('Short'); + const tall = createUnit('Tall'); + filteredUnitsSignal.set([short, tall]); fixture.detectChanges(); - expect(component.displayedUnitKeys()).toEqual(['Short', 'Tall']); + expect(component.displayedUnitKeys()).toEqual([short.uuid, tall.uuid]); - filteredUnitsSignal.set([createUnit('Tall'), createUnit('Short')]); + filteredUnitsSignal.set([tall, short]); fixture.detectChanges(); - expect(component.displayedUnitKeys()).toEqual(['Tall', 'Short']); + expect(component.displayedUnitKeys()).toEqual([tall.uuid, short.uuid]); }); it('removes selected units that are no longer displayed', () => { const fixture = TestBed.createComponent(UnitSearchComponent); const component = fixture.componentInstance; - filteredUnitsSignal.set([createUnit('Visible'), createUnit('Removed')]); + const visible = createUnit('Visible'); + const removed = createUnit('Removed'); + filteredUnitsSignal.set([visible, removed]); fixture.detectChanges(); - component.selectedUnits.set(new Set(['Visible', 'Removed'])); + component.selectedUnits.set(new Set([visible.uuid, removed.uuid])); - filteredUnitsSignal.set([createUnit('Visible')]); + filteredUnitsSignal.set([visible]); fixture.detectChanges(); - expect([...component.selectedUnits()]).toEqual(['Visible']); + expect([...component.selectedUnits()]).toEqual([visible.uuid]); }); it('clears selection when no selected units remain displayed', () => { const fixture = TestBed.createComponent(UnitSearchComponent); const component = fixture.componentInstance; - filteredUnitsSignal.set([createUnit('Removed')]); + const removed = createUnit('Removed'); + filteredUnitsSignal.set([removed]); fixture.detectChanges(); - component.selectedUnits.set(new Set(['Removed'])); + component.selectedUnits.set(new Set([removed.uuid])); filteredUnitsSignal.set([]); fixture.detectChanges(); @@ -428,7 +433,7 @@ describe('UnitSearchComponent card virtualization', () => { fixture.detectChanges(); component.multiSelectUnit(unit); - expect(component.selectedUnits().has(unit.name)).toBeTrue(); + expect(component.selectedUnits().has(unit.uuid)).toBeTrue(); filtersServiceStub.classicBvNormalizationSettings.update(settings => ({ ...settings, @@ -439,15 +444,15 @@ describe('UnitSearchComponent card virtualization', () => { expect(component.selectedUnits().size).toBe(0); }); - it('adds every selected displayed unit with the active pilot skills', async () => { + it('adds every UUID-selected unit when display names collide', async () => { const fixture = TestBed.createComponent(UnitSearchComponent); const component = fixture.componentInstance; - const first = createUnit('First'); - const second = createUnit('Second'); + const first = createUnit('Duplicate Name'); + const second = createUnit('Duplicate Name'); filteredUnitsSignal.set([first, second]); - dataServiceStub.getUnitByName.and.callFake((name: string) => name === first.name ? first : second); + dataServiceStub.getUnitByUuid.and.callFake((uuid: string) => uuid === first.uuid ? first : second); fixture.detectChanges(); - component.selectedUnits.set(new Set([first.name, second.name])); + component.selectedUnits.set(new Set([first.uuid, second.uuid])); await component.addSelectedUnits(); @@ -465,29 +470,29 @@ describe('UnitSearchComponent card virtualization', () => { const first = createUnit('First'); const second = createUnit('Second'); filteredUnitsSignal.set([first, second]); - dataServiceStub.getUnitByName.and.callFake((name: string) => name === first.name ? first : second); + dataServiceStub.getUnitByUuid.and.callFake((uuid: string) => uuid === first.uuid ? first : second); forceBuilderServiceStub.addUnit.and.resolveTo(false); fixture.detectChanges(); - component.selectedUnits.set(new Set([first.name, second.name])); + component.selectedUnits.set(new Set([first.uuid, second.uuid])); await component.addSelectedUnits(); expect(forceBuilderServiceStub.addUnit).toHaveBeenCalledOnceWith(first, 4, 5); - expect(dataServiceStub.getUnitByName).toHaveBeenCalledOnceWith(first.name); + expect(dataServiceStub.getUnitByUuid).toHaveBeenCalledOnceWith(first.uuid); expect(component.selectedUnits().size).toBe(0); }); - it('skips a selected name when its unit data is unavailable', async () => { + it('skips a selected UUID when its unit data is unavailable', async () => { const fixture = TestBed.createComponent(UnitSearchComponent); const component = fixture.componentInstance; const missing = createUnit('Missing'); filteredUnitsSignal.set([missing]); fixture.detectChanges(); - component.selectedUnits.set(new Set([missing.name])); + component.selectedUnits.set(new Set([missing.uuid])); await component.addSelectedUnits(); - expect(dataServiceStub.getUnitByName).toHaveBeenCalledOnceWith(missing.name); + expect(dataServiceStub.getUnitByUuid).toHaveBeenCalledOnceWith(missing.uuid); expect(forceBuilderServiceStub.addUnit).not.toHaveBeenCalled(); expect(component.selectedUnits().size).toBe(0); }); @@ -914,19 +919,19 @@ describe('UnitSearchComponent card virtualization', () => { filteredUnitsSignal.set([atlas, nova]); fixture.detectChanges(); - component.selectedUnits.set(new Set([atlas.name, nova.name])); + component.selectedUnits.set(new Set([atlas.uuid, nova.uuid])); const atlasGroup = component.groupedUnits().find(group => group.chassis === 'Atlas'); expect(atlasGroup).toBeDefined(); component.onCompactGroupClick(atlasGroup!); fixture.detectChanges(); - expect([...component.selectedUnits()]).toEqual([atlas.name, nova.name]); + expect([...component.selectedUnits()]).toEqual([atlas.uuid, nova.uuid]); component.clearVariantGroupFilter(); fixture.detectChanges(); - expect([...component.selectedUnits()]).toEqual([atlas.name, nova.name]); + expect([...component.selectedUnits()]).toEqual([atlas.uuid, nova.uuid]); }); it('navigates search results with global up and down shortcuts', () => { diff --git a/src/app/components/unit-search/unit-search.component.ts b/src/app/components/unit-search/unit-search.component.ts index 0fbe0c0a7..2d1c8fa32 100644 --- a/src/app/components/unit-search/unit-search.component.ts +++ b/src/app/components/unit-search/unit-search.component.ts @@ -321,6 +321,7 @@ export class UnitSearchComponent { focused = signal(false); viewModeMenuOpen = signal(false); activeIndex = signal(null); + /** UUIDs of the selected search results. */ selectedUnits = signal>(new Set()); private readonly selectedUnitContexts = new Map(); readonly activeVariantGroupFilter = signal(null); @@ -360,7 +361,7 @@ export class UnitSearchComponent { return units.filter(unit => unitMatchesVariantGroup(unit, variantGroupFilter)); }); - readonly displayedUnitKeys = computed(() => this.displayedUnits().map(unit => unit.name)); + readonly displayedUnitKeys = computed(() => this.displayedUnits().map(unit => unit.uuid)); readonly activeVariantGroupRepresentativeUnit = computed(() => { return this.displayedUnits()[0] ?? this.activeVariantGroupFilter()?.representativeUnit ?? null; }); @@ -487,7 +488,7 @@ export class UnitSearchComponent { private inlinePanelIndex = computed(() => { const unit = this.inlinePanelUnit(); if (!unit) return -1; - return this.displayedUnits().findIndex(u => u.name === unit.name); + return this.displayedUnits().findIndex(u => u.uuid === unit.uuid); }); /** Whether there is a previous unit to navigate to in the inline panel */ @@ -778,15 +779,15 @@ export class UnitSearchComponent { }); }); effect(() => { - const filteredNames = new Set(this.filtersService.filteredUnits().map(unit => unit.name)); - const displayedNames = new Set(this.displayedUnits().map(unit => unit.name)); + const filteredUuids = new Set(this.filtersService.filteredUnits().map(unit => unit.uuid)); + const displayedUuids = new Set(this.displayedUnits().map(unit => unit.uuid)); untracked(() => { const selected = this.selectedUnits(); - if (![...selected].every(name => filteredNames.has(name))) { - this.selectedUnits.set(new Set([...selected].filter(name => filteredNames.has(name)))); + if (![...selected].every(uuid => filteredUuids.has(uuid))) { + this.selectedUnits.set(new Set([...selected].filter(uuid => filteredUuids.has(uuid)))); } const inlineUnit = this.inlinePanelUnit(); - if (inlineUnit && !displayedNames.has(inlineUnit.name)) { + if (inlineUnit && !displayedUuids.has(inlineUnit.uuid)) { this.inlinePanelUnit.set(null); } }); @@ -1081,7 +1082,7 @@ export class UnitSearchComponent { readonly unitTableRowClass = (unit: UnitSummary, index: number) => ({ 'is-selected': this.isUnitSelected(unit), 'is-active': this.activeIndex() === index, - 'is-panel-selected': this.showInlinePanel() && this.inlinePanelUnit()?.name === unit.name, + 'is-panel-selected': this.showInlinePanel() && this.inlinePanelUnit()?.uuid === unit.uuid, }); focusInput() { @@ -1573,9 +1574,9 @@ export class UnitSearchComponent { showUnitDetails(unit: UnitSummary) { const filteredUnits = this.displayedUnits(); - const filteredUnitIndex = filteredUnits.findIndex(u => u.name === unit.name); + const filteredUnitIndex = filteredUnits.findIndex(u => u.uuid === unit.uuid); const searchResultContexts = new Map( - filteredUnits.map(resultUnit => [resultUnit.name, this.getSearchResultContext(resultUnit)]), + filteredUnits.map(resultUnit => [resultUnit.uuid, this.getSearchResultContext(resultUnit)]), ); const ref = this.dialogsService.createDialog(UnitDetailsDialogComponent, { data: { @@ -1829,14 +1830,14 @@ export class UnitSearchComponent { event.stopPropagation(); // Determine which units to tag: selected units if any. - const selectedNames = this.selectedUnits(); + const selectedUuids = this.selectedUnits(); const allUnits = this.displayedUnits(); let unitsToTag: UnitSummary[]; - if (selectedNames.size > 0) { + if (selectedUuids.size > 0) { // Always include the clicked unit, even if not in the selection - const selectedSet = new Set(selectedNames); - selectedSet.add(unit.name); - unitsToTag = allUnits.filter(u => selectedSet.has(u.name)); + const selectedSet = new Set(selectedUuids); + selectedSet.add(unit.uuid); + unitsToTag = allUnits.filter(u => selectedSet.has(u.uuid)); } else { unitsToTag = [unit]; } @@ -2024,12 +2025,12 @@ export class UnitSearchComponent { multiSelectUnit(unit: UnitSummary, event?: Event) { event?.stopPropagation(); const selected = new Set(this.selectedUnits()); - if (selected.has(unit.name)) { - selected.delete(unit.name); - this.selectedUnitContexts.delete(unit.name); + if (selected.has(unit.uuid)) { + selected.delete(unit.uuid); + this.selectedUnitContexts.delete(unit.uuid); } else { - selected.add(unit.name); - this.selectedUnitContexts.set(unit.name, this.getSearchResultContext(unit)); + selected.add(unit.uuid); + this.selectedUnitContexts.set(unit.uuid, this.getSearchResultContext(unit)); } this.selectedUnits.set(selected); } @@ -2047,7 +2048,7 @@ export class UnitSearchComponent { if (this.showInlinePanel()) { // Update activeIndex to match clicked unit const filteredUnits = this.displayedUnits(); - const index = filteredUnits.findIndex(u => u.name === unit.name); + const index = filteredUnits.findIndex(u => u.uuid === unit.uuid); if (index >= 0) { this.activeIndex.set(index); } @@ -2088,7 +2089,7 @@ export class UnitSearchComponent { } isUnitSelected(unit: UnitSummary): boolean { - return this.selectedUnits().has(unit.name); + return this.selectedUnits().has(unit.uuid); } clearSelection() { @@ -2100,20 +2101,20 @@ export class UnitSearchComponent { selectAll() { const allUnits = this.displayedUnits(); - const allNames = new Set(allUnits.map(u => u.name)); - this.selectedUnits.set(allNames); + const allUuids = new Set(allUnits.map(u => u.uuid)); + this.selectedUnits.set(allUuids); this.selectedUnitContexts.clear(); for (const unit of allUnits) { - this.selectedUnitContexts.set(unit.name, this.getSearchResultContext(unit)); + this.selectedUnitContexts.set(unit.uuid, this.getSearchResultContext(unit)); } } async addSelectedUnits() { const selectedUnits = this.selectedUnits(); - for (let selectedUnit of selectedUnits) { - const unit = this.dataService.getUnitByName(selectedUnit); + for (const selectedUnitUuid of selectedUnits) { + const unit = this.dataService.getUnitByUuid(selectedUnitUuid); if (unit) { - const context = this.selectedUnitContexts.get(selectedUnit) + const context = this.selectedUnitContexts.get(selectedUnitUuid) ?? this.getSearchResultContext(unit); if (!await this.forceBuilderService.addUnit( unit, diff --git a/src/app/models/unit-summary.model.ts b/src/app/models/unit-summary.model.ts index 3a9c8d35a..3cceddd32 100644 --- a/src/app/models/unit-summary.model.ts +++ b/src/app/models/unit-summary.model.ts @@ -158,9 +158,9 @@ export interface UnitFluffCatalog { } export interface UnitSummary { - uuid: string; // Unique identifier of the unit - name: string; // Internal unique name - id: number; // MUL id + uuid: string; // Stable, unique internal unit identity + name: string; // Catalog/URL name; not guaranteed unique + id: number; // External MUL id; not guaranteed unique chassis: string; model: string; year: number; diff --git a/src/app/services/data.service.spec.ts b/src/app/services/data.service.spec.ts index c3405de29..0d1716c0c 100644 --- a/src/app/services/data.service.spec.ts +++ b/src/app/services/data.service.spec.ts @@ -56,6 +56,7 @@ describe('DataService', () => { }; const unitRuntimeServiceMock = { getUnitByName: jasmine.createSpy('getUnitByName').and.returnValue(undefined), + getUnitByUuid: jasmine.createSpy('getUnitByUuid').and.returnValue(undefined), applyTagDataToUnits: jasmine.createSpy('applyTagDataToUnits'), applyPublicTagsToUnits: jasmine.createSpy('applyPublicTagsToUnits'), loadUnitTags: jasmine.createSpy('loadUnitTags').and.resolveTo(undefined), @@ -153,6 +154,8 @@ describe('DataService', () => { userStateServiceMock.uuid.and.returnValue('user-1'); unitRuntimeServiceMock.getUnitByName.calls.reset(); unitRuntimeServiceMock.getUnitByName.and.returnValue(undefined); + unitRuntimeServiceMock.getUnitByUuid.calls.reset(); + unitRuntimeServiceMock.getUnitByUuid.and.returnValue(undefined); unitRuntimeServiceMock.applyTagDataToUnits.calls.reset(); unitRuntimeServiceMock.applyPublicTagsToUnits.calls.reset(); unitRuntimeServiceMock.loadUnitTags.calls.reset(); @@ -280,8 +283,10 @@ describe('DataService', () => { it('delegates unit lookup to the runtime service', () => { service.getUnitByName('Mad Cat Prime'); + service.getUnitByUuid('unit-uuid'); expect(unitRuntimeServiceMock.getUnitByName).toHaveBeenCalledOnceWith('Mad Cat Prime'); + expect(unitRuntimeServiceMock.getUnitByUuid).toHaveBeenCalledOnceWith('unit-uuid'); }); it('resolves equipment names through the catalog registry', () => { diff --git a/src/app/services/data.service.ts b/src/app/services/data.service.ts index b3e605ed4..f844147c9 100644 --- a/src/app/services/data.service.ts +++ b/src/app/services/data.service.ts @@ -307,6 +307,14 @@ export class DataService { return this.unitRuntimeService.getUnitByName(name); } + public getUnitsByName(name: string): readonly UnitSummary[] { + return this.unitRuntimeService.getUnitsByName(name); + } + + public getUnitByUuid(uuid: string): UnitSummary | undefined { + return this.unitRuntimeService.getUnitByUuid(uuid); + } + public getUnitFluff(unit: Pick): Promise { return this.unitsFluffCatalog.getUnitFluff(unit); } @@ -432,8 +440,8 @@ export class DataService { return this.unitSearchIndexService.getIndexedFilterValues(filterKey); } - public getIndexedASSpecials(unitName: string): ParsedASSpecials | undefined { - return this.unitSearchIndexService.getIndexedASSpecials(unitName); + public getIndexedASSpecials(unitUuid: string): ParsedASSpecials | undefined { + return this.unitSearchIndexService.getIndexedASSpecials(unitUuid); } public getSearchWorkerIndexSnapshot(): UnitSearchWorkerIndexSnapshot { @@ -444,6 +452,10 @@ export class DataService { return this.unitSearchIndexService.getSearchWorkerFactionEraSnapshot(); } + public getFactionEraUnitUuids(eraNames: readonly string[], factionNames: readonly string[]): ReadonlySet { + return this.unitSearchIndexService.getFactionEraUnitUuids(eraNames, factionNames); + } + public getDropdownOptionUniverse(filterKey: string): UnitSearchDropdownOption[] { return this.unitSearchIndexService.getDropdownOptionUniverse(filterKey); } diff --git a/src/app/services/unit-runtime.service.spec.ts b/src/app/services/unit-runtime.service.spec.ts index 52e93c2f1..bd160b2de 100644 --- a/src/app/services/unit-runtime.service.spec.ts +++ b/src/app/services/unit-runtime.service.spec.ts @@ -64,6 +64,18 @@ describe('UnitRuntimeService', () => { expect(service.getUnitByName('MAD CAT PRIME')).toBe(unit); }); + it('retrieves distinct units by UUID even when names collide', () => { + const first = createEmptyUnit({ uuid: 'uuid-a', name: 'Duplicate Name' }); + const second = createEmptyUnit({ uuid: 'uuid-b', name: 'Duplicate Name' }); + + service.preprocessUnits([first, second]); + + expect(service.getUnitsByName('duplicate name')).toEqual([first, second]); + expect(service.getUnitByName('Duplicate Name')).toBe(second); + expect(service.getUnitByUuid(first.uuid)).toBe(first); + expect(service.getUnitByUuid(second.uuid)).toBe(second); + }); + it('precomputes mixed-aware tech-base display values before indexing', () => { const units = [ createEmptyUnit({ name: 'Inner Sphere', techBase: 'Inner Sphere', mixed: false }), diff --git a/src/app/services/unit-runtime.service.ts b/src/app/services/unit-runtime.service.ts index d8a1fee12..ef2dc7e28 100644 --- a/src/app/services/unit-runtime.service.ts +++ b/src/app/services/unit-runtime.service.ts @@ -20,17 +20,26 @@ export class UnitRuntimeService { private readonly publicTagsService = inject(PublicTagsService); private readonly unitSearchIndexService = inject(UnitSearchIndexService); - private unitNameMap = new Map(); + private unitsByNameMap = new Map(); + private unitUuidMap = new Map(); private static getUnitNameKey(name: string): string { return name.toLowerCase(); } public preprocessUnits(units: UnitSummary[]): void { - this.unitNameMap.clear(); + this.unitsByNameMap.clear(); + this.unitUuidMap.clear(); for (const unit of units) { unit._techBaseDisplay = getUnitTechBaseDisplay(unit); - this.unitNameMap.set(UnitRuntimeService.getUnitNameKey(unit.name), unit); + const nameKey = UnitRuntimeService.getUnitNameKey(unit.name); + const matchingUnits = this.unitsByNameMap.get(nameKey); + if (matchingUnits) { + matchingUnits.push(unit); + } else { + this.unitsByNameMap.set(nameKey, [unit]); + } + this.unitUuidMap.set(unit.uuid, unit); } this.unitSearchIndexService.prepareUnits(units); } @@ -105,7 +114,15 @@ export class UnitRuntimeService { } public getUnitByName(name: string): UnitSummary | undefined { - return this.unitNameMap.get(UnitRuntimeService.getUnitNameKey(name)); + return this.getUnitsByName(name).at(-1); + } + + public getUnitsByName(name: string): readonly UnitSummary[] { + return this.unitsByNameMap.get(UnitRuntimeService.getUnitNameKey(name)) ?? []; + } + + public getUnitByUuid(uuid: string): UnitSummary | undefined { + return this.unitUuidMap.get(uuid); } private findEraForYear(year: number, eras: Era[]): Era | undefined { @@ -130,4 +147,4 @@ export class UnitRuntimeService { } } } -} \ No newline at end of file +} diff --git a/src/app/services/unit-search-filters.service.spec.ts b/src/app/services/unit-search-filters.service.spec.ts index 6a27d82fc..62d2098b5 100644 --- a/src/app/services/unit-search-filters.service.spec.ts +++ b/src/app/services/unit-search-filters.service.spec.ts @@ -86,6 +86,7 @@ function cloneUnit(value: T): T { function prepareUnitForSearch(unit: UnitSummary, index: number): UnitSummary { const clone = cloneUnit(unit); + clone.uuid = `${unit.uuid}__${index}`; clone.id = index + 1; clone.name = `${unit.name}__${index}`; clone._nameTags = clone._nameTags ?? []; @@ -358,6 +359,16 @@ function createStandaloneBundle(): BenchmarkBundle { }; } +function createWorkerEntries(bundle: BenchmarkBundle, unitNames: readonly string[]) { + return unitNames.map((unitName) => { + const unit = bundle.units.units.find(candidate => candidate.name === unitName); + if (!unit) { + throw new Error(`Missing test unit: ${unitName}`); + } + return { unitUuid: unit.uuid }; + }); +} + const FREE_WORLDS_LEAGUE_FACTION = 'Free Worlds League'; const FEDERATED_SUNS_FACTION = 'Federated Suns'; const CLAN_WOLF_FACTION = 'Clan Wolf'; @@ -1455,7 +1466,7 @@ describe('UnitSearchFiltersService search telemetry', () => { revision: executeMessage.request.revision, corpusVersion: executeMessage.request.corpusVersion, telemetryQuery: executeMessage.request.telemetryQuery, - entries: bundle.units.units.map((unit) => ({ unitName: unit.name })), + entries: bundle.units.units.map((unit) => ({ unitUuid: unit.uuid })), stages: [], totalMs: 1, unitCount: bundle.units.units.length, @@ -1483,6 +1494,10 @@ describe('UnitSearchFiltersService search telemetry', () => { expect(request.executionQuery).toBe('pv>0 or pv<0'); expect(request.telemetryQuery).toBe('pv>0 or pv<0'); + + service.setFilter('type', ['Mek']); + const filteredRequest = (service as any).buildWorkerSearchRequest(corpusVersion); + expect(filteredRequest.executionQuery).toBe('(pv>0 or pv<0) type=Mek'); }); it('distinguishes force packs by subtype when chassis and type match', () => { @@ -3040,7 +3055,7 @@ describe('UnitSearchFiltersService search telemetry', () => { revision: executeMessage.request.revision, corpusVersion: executeMessage.request.corpusVersion, telemetryQuery: executeMessage.request.telemetryQuery, - entries: ['Very Common Crab', 'Unknown Crab'].map(unitName => ({ unitName })), + entries: createWorkerEntries(bundle, ['Very Common Crab', 'Unknown Crab']), stages: [], totalMs: 1, unitCount: bundle.units.units.length, @@ -3105,7 +3120,7 @@ describe('UnitSearchFiltersService search telemetry', () => { revision: executeMessage.request.revision, corpusVersion: executeMessage.request.corpusVersion, telemetryQuery: executeMessage.request.telemetryQuery, - entries: ['Known Crab', 'Unknown Crab'].map(unitName => ({ unitName })), + entries: createWorkerEntries(bundle, ['Known Crab', 'Unknown Crab']), stages: [], totalMs: 1, unitCount: bundle.units.units.length, @@ -3438,13 +3453,13 @@ describe('UnitSearchFiltersService search telemetry', () => { expect(Array.from((service as any).getSemanticIndexedUnitIds('era', 'Age of War', { factionNames: ['Draconis Combine'], - }) ?? [])).toEqual(['Combine Scout']); + }) ?? [])).toEqual([bundle.units.units[0].uuid]); expect(Array.from((service as any).getSemanticIndexedUnitIds('era', 'Age of War', { factionNames: ['Federated Suns'], - }) ?? [])).toEqual(['Suns Raider']); + }) ?? [])).toEqual([bundle.units.units[1].uuid]); expect(Array.from((service as any).getSemanticIndexedUnitIds('faction', 'Draconis Combine', { eraNames: ['Age of War'], - }) ?? [])).toEqual(['Combine Scout']); + }) ?? [])).toEqual([bundle.units.units[0].uuid]); expect(Array.from((service as any).getSemanticIndexedUnitIds('faction', 'Federated Suns', { eraNames: ['Succession Wars'], }) ?? [])).toEqual([]); @@ -3714,7 +3729,7 @@ describe('UnitSearchFiltersService search telemetry', () => { revision: executeMessage.request.revision, corpusVersion: executeMessage.request.corpusVersion, telemetryQuery: executeMessage.request.telemetryQuery, - entries: bundle.units.units.map((unit) => ({ unitName: unit.name })), + entries: bundle.units.units.map((unit) => ({ unitUuid: unit.uuid })), stages: [], totalMs: 1, unitCount: bundle.units.units.length, @@ -3869,7 +3884,7 @@ describe('UnitSearchFiltersService search telemetry', () => { revision: firstExecuteMessage.request.revision, corpusVersion: firstExecuteMessage.request.corpusVersion, telemetryQuery: firstExecuteMessage.request.telemetryQuery, - entries: ['BattleMaster C3', 'Common Dominion Mek'].map(unitName => ({ unitName })), + entries: createWorkerEntries(bundle, ['BattleMaster C3', 'Common Dominion Mek']), stages: [], totalMs: 1, unitCount: bundle.units.units.length, @@ -3980,7 +3995,7 @@ describe('UnitSearchFiltersService search telemetry', () => { revision: firstExecuteMessage.request.revision, corpusVersion: firstExecuteMessage.request.corpusVersion, telemetryQuery: firstExecuteMessage.request.telemetryQuery, - entries: [{ unitName: 'BattleMaster C3' }], + entries: createWorkerEntries(bundle, ['BattleMaster C3']), stages: [], totalMs: 1, unitCount: bundle.units.units.length, @@ -4067,7 +4082,7 @@ describe('UnitSearchFiltersService search telemetry', () => { revision: executeMessage.request.revision, corpusVersion: executeMessage.request.corpusVersion, telemetryQuery: executeMessage.request.telemetryQuery, - entries: ['Rare Salvage Crab', 'Common Requisition Crab'].map(unitName => ({ unitName })), + entries: createWorkerEntries(bundle, ['Rare Salvage Crab', 'Common Requisition Crab']), stages: [], totalMs: 1, unitCount: bundle.units.units.length, @@ -4191,7 +4206,7 @@ describe('UnitSearchFiltersService search telemetry', () => { revision: executeMessage.request.revision, corpusVersion: executeMessage.request.corpusVersion, telemetryQuery: executeMessage.request.telemetryQuery, - entries: ['BattleMaster C3', 'Common Dominion Mek'].map(unitName => ({ unitName })), + entries: createWorkerEntries(bundle, ['BattleMaster C3', 'Common Dominion Mek']), stages: [], totalMs: 1, unitCount: bundle.units.units.length, @@ -4300,7 +4315,7 @@ describe('UnitSearchFiltersService search telemetry', () => { revision: executeMessage.request.revision, corpusVersion: executeMessage.request.corpusVersion, telemetryQuery: executeMessage.request.telemetryQuery, - entries: [{ unitName: 'BattleMaster C3' }], + entries: createWorkerEntries(bundle, ['BattleMaster C3']), stages: [], totalMs: 1, unitCount: bundle.units.units.length, @@ -4367,7 +4382,7 @@ describe('UnitSearchFiltersService search telemetry', () => { revision: executeMessage.request.revision, corpusVersion: executeMessage.request.corpusVersion, telemetryQuery: executeMessage.request.telemetryQuery, - entries: ['Very Common Crab', 'Unknown Crab'].map(unitName => ({ unitName })), + entries: createWorkerEntries(bundle, ['Very Common Crab', 'Unknown Crab']), stages: [], totalMs: 1, unitCount: bundle.units.units.length, @@ -4496,7 +4511,7 @@ describe('UnitSearchFiltersService search telemetry', () => { revision: executeMessage.request.revision, corpusVersion: executeMessage.request.corpusVersion, telemetryQuery: executeMessage.request.telemetryQuery, - entries: ['Known Unit', 'Unknown Unit'].map(unitName => ({ unitName })), + entries: createWorkerEntries(bundle, ['Known Unit', 'Unknown Unit']), stages: [], totalMs: 1, unitCount: bundle.units.units.length, @@ -4636,7 +4651,7 @@ describe('UnitSearchFiltersService search telemetry', () => { revision: executeMessage.request.revision, corpusVersion: executeMessage.request.corpusVersion, telemetryQuery: executeMessage.request.telemetryQuery, - entries: ['Low Unit', 'Unknown Unit', 'High Unit'].map(unitName => ({ unitName })), + entries: createWorkerEntries(bundle, ['Low Unit', 'Unknown Unit', 'High Unit']), stages: [], totalMs: 1, unitCount: bundle.units.units.length, @@ -5671,8 +5686,61 @@ describe('UnitSearchFiltersService search telemetry', () => { expect(service.filteredUnits().map(unit => unit.name)).toEqual(['Test Mek']); const workerSnapshot = (service as any).getWorkerCorpusSnapshot((service as any).getWorkerCorpusVersion()); - expect(workerSnapshot.factionEraIndex['Clan Invasion']?.['Clan Coyote']).toEqual(['Test Mek']); - expect(workerSnapshot.factionEraIndex['Jihad']?.['Clan Coyote']).toEqual(['Test Tank']); + expect(workerSnapshot.factionEraIndex['Clan Invasion']?.['Clan Coyote']).toEqual([bundle.units.units[0].uuid]); + expect(workerSnapshot.factionEraIndex['Jihad']?.['Clan Coyote']).toEqual([bundle.units.units[1].uuid]); + }); + + it('evaluates exclusive semantic faction filters within a UI-selected era', async () => { + const bundle = createStandaloneBundle(); + bundle.eras.eras = [ + { + id: 1, + name: 'Clan Invasion', + img: '', + years: { from: 3049, to: 3061 }, + units: [1, 2], + factions: [], + }, + { + id: 2, + name: 'Jihad', + img: '', + years: { from: 3067, to: 3081 }, + units: [2], + factions: [], + }, + ]; + bundle.factions.factions = [ + { + id: 1, + name: 'Clan Coyote', + group: 'IS Clan', + img: '', + eras: { + 1: new Set([1, 2]), + }, + }, + { + id: 2, + name: 'Federated Suns', + group: 'Inner Sphere', + img: '', + eras: { + 2: new Set([2]), + }, + }, + ]; + + const { service } = createService(bundle); + service.setSearchText('faction=="Clan Coyote"'); + await flushAsyncWork(); + + expect(service.filteredUnits().map(unit => unit.name)).toEqual(['Test Mek']); + + service.setFilter('era', ['Clan Invasion']); + await flushAsyncWork(); + + expect(service.filteredUnits().map(unit => unit.name)).toEqual(['Test Mek', 'Test Tank']); }); it('requires faction membership in every selected multistate era', async () => { @@ -5999,10 +6067,11 @@ describe('UnitSearchFiltersService search telemetry', () => { return; } - const { dataService, service } = createService(buildSmallBundle(benchmarkBundle)); + const bundle = buildSmallBundle(benchmarkBundle); + const { dataService, service } = createService(bundle); const initialTagIds = dataService.getIndexedUnitIds('_tags', 'tag-a'); - expect(initialTagIds?.has('Test Mek')).toBeTrue(); + expect(initialTagIds?.has(bundle.units.units[0].uuid)).toBeTrue(); (dataService as any).applyTagDataToUnits({ tags: { @@ -6028,8 +6097,8 @@ describe('UnitSearchFiltersService search telemetry', () => { const namedTagOptions = tagOptions.filter(option => typeof option !== 'number'); expect(dataService.getIndexedUnitIds('_tags', 'tag-a')).toBeUndefined(); - expect(indexedAlphaIds?.has('Test Mek')).toBeTrue(); - expect(indexedBetaIds?.has('Test Tank')).toBeTrue(); + expect(indexedAlphaIds?.has(bundle.units.units[0].uuid)).toBeTrue(); + expect(indexedBetaIds?.has(bundle.units.units[1].uuid)).toBeTrue(); expect(dropdownUniverse).toEqual(['alpha-tag', 'beta-tag']); expect(namedTagOptions.map(option => option.name)).toEqual(['alpha-tag', 'beta-tag']); }); @@ -6431,7 +6500,7 @@ describe('UnitSearchFiltersService search telemetry', () => { revision: executeMessage.request.revision, corpusVersion: executeMessage.request.corpusVersion, telemetryQuery: executeMessage.request.telemetryQuery, - entries: [{ unitName: bundle.units.units[0].name }], + entries: [{ unitUuid: bundle.units.units[0].uuid }], stages: [], totalMs: 1, unitCount: 2, @@ -6465,7 +6534,7 @@ describe('UnitSearchFiltersService search telemetry', () => { corpusVersion: executeMessage.request.corpusVersion, telemetryQuery: executeMessage.request.telemetryQuery, entries: [{ - unitName: unit.name, + unitUuid: unit.uuid, match: { kind: 'pv', adjustedValue: unit.as.PV, skill: 8 }, }], stages: [], @@ -6517,7 +6586,7 @@ describe('UnitSearchFiltersService search telemetry', () => { revision: firstExecute.request.revision, corpusVersion: firstExecute.request.corpusVersion, telemetryQuery: firstExecute.request.telemetryQuery, - entries: [{ unitName: bundle.units.units[0].name }], + entries: [{ unitUuid: bundle.units.units[0].uuid }], stages: [], totalMs: 1, unitCount: 2, @@ -6532,7 +6601,7 @@ describe('UnitSearchFiltersService search telemetry', () => { revision: secondExecute.request.revision, corpusVersion: secondExecute.request.corpusVersion, telemetryQuery: secondExecute.request.telemetryQuery, - entries: [{ unitName: bundle.units.units[1].name }], + entries: [{ unitUuid: bundle.units.units[1].uuid }], stages: [], totalMs: 1, unitCount: 2, @@ -6580,7 +6649,7 @@ describe('UnitSearchFiltersService search telemetry', () => { revision: initialExecute.request.revision, corpusVersion: initialExecute.request.corpusVersion, telemetryQuery: initialExecute.request.telemetryQuery, - entries: bundle.units.units.map((unit) => ({ unitName: unit.name })), + entries: bundle.units.units.map((unit) => ({ unitUuid: unit.uuid })), stages: [], totalMs: 1, unitCount: bundle.units.units.length, @@ -6603,7 +6672,7 @@ describe('UnitSearchFiltersService search telemetry', () => { revision: updatedExecute.request.revision, corpusVersion: updatedExecute.request.corpusVersion, telemetryQuery: updatedExecute.request.telemetryQuery, - entries: [{ unitName: 'BattleMaster C3' }], + entries: createWorkerEntries(bundle, ['BattleMaster C3']), stages: [], totalMs: 1, unitCount: bundle.units.units.length, diff --git a/src/app/services/unit-search-filters.service.ts b/src/app/services/unit-search-filters.service.ts index 568f74095..4ce628bd1 100644 --- a/src/app/services/unit-search-filters.service.ts +++ b/src/app/services/unit-search-filters.service.ts @@ -192,7 +192,7 @@ export class UnitSearchFiltersService { return []; } - const contextUnitIdSet = contextUnitIds ?? new Set(contextUnits.map(unit => unit.name)); + const contextUnitIdSet = contextUnitIds ?? new Set(contextUnits.map(unit => unit.uuid)); const availableOptions = universe.map(option => { const indexedIds = this.dataService.getIndexedUnitIds(conf.key, option.name); const available = indexedIds ? setHasAny(indexedIds, contextUnitIdSet) : false; @@ -304,7 +304,7 @@ export class UnitSearchFiltersService { readonly advOptionsTelemetry = this.advOptionsTelemetryState.asReadonly(); private readonly workerSearchEnabled = signal(this.canUseSearchWorker()); private readonly rawWorkerResultUnitsState = signal([]); - private readonly workerNormalizationMatchesState = signal>(new Map()); + private readonly workerNormalizationMatchesByUnitUuid = signal>(new Map()); private readonly formationTargetExistingUnitsState = signal([]); readonly formationTarget = computed(() => { if (this.hasSemanticFormationTarget()) { @@ -1992,15 +1992,14 @@ export class UnitSearchFiltersService { return !conf || !this.shouldStripFilterFromWorker(conf.key); }) .map(token => token.rawText); - const executionQuery = this.isComplexQuery() - ? this.searchText().trim() - : buildWorkerExecutionQuery({ - effectiveFilterState: workerUiOnlyFilterState, - effectiveTextSearch: this.effectiveTextSearch(), - semanticTokenTexts: workerSemanticTokenTexts, - gameSystem, - totalRangesCache: this.totalRangesCache, - }); + const executionQuery = buildWorkerExecutionQuery({ + effectiveFilterState: workerUiOnlyFilterState, + effectiveTextSearch: this.effectiveTextSearch(), + semanticTokenTexts: workerSemanticTokenTexts, + preservedQuery: this.isComplexQuery() ? this.searchText() : undefined, + gameSystem, + totalRangesCache: this.totalRangesCache, + }); this.searchRequestRevision += 1; @@ -2060,20 +2059,20 @@ export class UnitSearchFiltersService { return; } - const hydrated = hydrateWorkerSearchResult(result, unitName => this.dataService.getUnitByName(unitName)); + const hydrated = hydrateWorkerSearchResult(result, unitUuid => this.dataService.getUnitByUuid(unitUuid)); const normalization = this.activeNormalization(); - const normalizationMatches = new Map(hydrated.normalizationMatchesByUnitName); + const normalizationMatches = new Map(hydrated.normalizationMatchesByUnitUuid); const hydratedResults = normalization ? hydrated.units.filter(unit => { - const workerMatch = normalizationMatches.get(unit.name); + const workerMatch = normalizationMatches.get(unit.uuid); const match = workerMatch?.kind === normalization.kind ? workerMatch : this.findNormalizationMatch(unit, normalization); if (!match) { - normalizationMatches.delete(unit.name); + normalizationMatches.delete(unit.uuid); return false; } - normalizationMatches.set(unit.name, match); + normalizationMatches.set(unit.uuid, match); return true; }) : hydrated.units; @@ -2088,7 +2087,7 @@ export class UnitSearchFiltersService { .reduce((totalMs, stage) => totalMs + stage.durationMs, 0); this.rawWorkerResultUnitsState.set(hydratedResults); - this.workerNormalizationMatchesState.set(normalizationMatches); + this.workerNormalizationMatchesByUnitUuid.set(normalizationMatches); this.workerResultRevision.set(result.revision); this.updateSearchTelemetry(buildWorkerSearchTelemetrySnapshot(result, { timestamp: Date.now(), @@ -2311,13 +2310,13 @@ export class UnitSearchFiltersService { const scoreResolver = this.unitAvailabilitySource.getMegaMekAvailabilityScoreResolver(context); const scores = new Map(); for (const unit of units) { - scores.set(unit.name, scoreResolver(unit)); + scores.set(unit.uuid, scoreResolver(unit)); } const sortResults = () => { const sorted = [...units]; sorted.sort((left, right) => { - let comparison = (scores.get(left.name) ?? 0) - (scores.get(right.name) ?? 0); + let comparison = (scores.get(left.uuid) ?? 0) - (scores.get(right.uuid) ?? 0); if (comparison === 0) { comparison = compareUnitsByName(left, right); } @@ -2783,7 +2782,7 @@ export class UnitSearchFiltersService { if (!isCountableFilter) { const universeNames = this.getIndexedUniverseNames(filterKey); if (universeNames.length > 0) { - const contextUnitIds = new Set(units.map(unit => unit.name)); + const contextUnitIds = new Set(units.map(unit => unit.uuid)); let constrainedUnitIds: Set | null = andEntries.length === 0 ? new Set(contextUnitIds) : null; @@ -2819,10 +2818,10 @@ export class UnitSearchFiltersService { const constrainedSelections = Object.values(selection).filter(selected => ( selected.state === 'and' || selected.state === 'not' )); - const unitsByName = new Map(units.map(unit => [unit.name, unit])); + const unitsByUuid = new Map(units.map(unit => [unit.uuid, unit])); for (const unitId of Array.from(constrainedUnitIds)) { - const unit = unitsByName.get(unitId); + const unit = unitsByUuid.get(unitId); if (!unit || !unitMatchesASSpecialSelections( getProperty(unit, filterKey), constrainedSelections, @@ -3049,31 +3048,13 @@ export class UnitSearchFiltersService { return this.unitAvailabilitySource.unitBelongsToFaction(unit, faction); } - private getUnitIdsForEraInFactionScope(eraName: string, factionNames: readonly string[]): Set { - const era = this.dataService.getEraByName(eraName); - if (!era || factionNames.length === 0) { - return new Set(); - } - - const contextEraIds = new Set([era.id]); - const unitIds = new Set(); - for (const factionName of factionNames) { - for (const unitId of this.getUnitIdsForFaction(factionName, contextEraIds)) { - unitIds.add(unitId); - } - } - - return unitIds; - } - private unitBelongsToEraInScope(unit: UnitSummary, era: Era, scope?: AvailabilityFilterScope): boolean { if (scope?.factionNames === undefined) { return this.unitAvailabilitySource.unitBelongsToEra(unit, era); } if (!this.unitAvailabilitySource.useMegaMekAvailability()) { - return this.getUnitIdsForEraInFactionScope(era.name, scope.factionNames) - .has(this.unitAvailabilitySource.getUnitAvailabilityKey(unit)); + return this.dataService.getFactionEraUnitUuids([era.name], scope.factionNames).has(unit.uuid); } const context = this.buildAvailabilityFilterContext({ @@ -3183,6 +3164,17 @@ export class UnitSearchFiltersService { : new Set(); } + /** Convert MegaMek's name-keyed availability results at the external boundary. */ + private getMegaMekUnitUuids(unitNames: ReadonlySet): ReadonlySet { + const unitUuids = new Set(); + for (const unitName of unitNames) { + for (const unit of this.dataService.getUnitsByName(unitName)) { + unitUuids.add(unit.uuid); + } + } + return unitUuids; + } + private getSemanticIndexedUnitIds( filterKey: string, value: string, @@ -3190,23 +3182,11 @@ export class UnitSearchFiltersService { ): ReadonlySet | undefined { if (!this.unitAvailabilitySource.useMegaMekAvailability()) { if (filterKey === 'era' && scope?.factionNames !== undefined) { - return this.getUnitIdsForEraInFactionScope(value, scope.factionNames); + return this.dataService.getFactionEraUnitUuids([value], scope.factionNames); } if (filterKey === 'faction' && scope?.eraNames !== undefined) { - const faction = this.dataService.getFactionByName(value); - if (!faction) { - return undefined; - } - - const contextEraIds = new Set( - scope.eraNames - .map((eraName) => this.dataService.getEraByName(eraName)?.id) - .filter((eraId): eraId is number => eraId !== undefined), - ); - return contextEraIds.size === 0 - ? new Set() - : this.unitAvailabilitySource.getFactionUnitIds(faction, contextEraIds); + return this.dataService.getFactionEraUnitUuids(scope.eraNames, [value]); } return this.dataService.getIndexedUnitIds(filterKey, value); @@ -3219,7 +3199,9 @@ export class UnitSearchFiltersService { } if (scope?.factionNames === undefined) { - return this.unitAvailabilitySource.getVisibleEraUnitIds(era); + return this.getMegaMekUnitUuids( + this.unitAvailabilitySource.getVisibleEraUnitIds(era), + ); } const context = this.buildAvailabilityFilterContext({ @@ -3228,7 +3210,9 @@ export class UnitSearchFiltersService { }); return context === null ? new Set() - : this.unitAvailabilitySource.getMegaMekMembershipUnitIds(context); + : this.getMegaMekUnitUuids( + this.unitAvailabilitySource.getMegaMekMembershipUnitIds(context), + ); } if (filterKey === 'faction') { @@ -3238,7 +3222,9 @@ export class UnitSearchFiltersService { } if (scope?.eraNames === undefined) { - return this.unitAvailabilitySource.getFactionUnitIds(faction); + return this.getMegaMekUnitUuids( + this.unitAvailabilitySource.getFactionUnitIds(faction), + ); } const contextEraIds = new Set( @@ -3248,7 +3234,9 @@ export class UnitSearchFiltersService { ); return contextEraIds.size === 0 ? new Set() - : this.unitAvailabilitySource.getFactionUnitIds(faction, contextEraIds); + : this.getMegaMekUnitUuids( + this.unitAvailabilitySource.getFactionUnitIds(faction, contextEraIds), + ); } if (filterKey === 'availabilityFrom') { @@ -3258,13 +3246,17 @@ export class UnitSearchFiltersService { } if (value === MEGAMEK_AVAILABILITY_UNKNOWN) { - return this.unitAvailabilitySource.getMegaMekUnknownUnitIds(context); + return this.getMegaMekUnitUuids( + this.unitAvailabilitySource.getMegaMekUnknownUnitIds(context), + ); } - return this.unitAvailabilitySource.getMegaMekAvailabilityUnitIds({ - ...context, - availabilityFrom: new Set([value as MegaMekAvailabilityFrom]), - }); + return this.getMegaMekUnitUuids( + this.unitAvailabilitySource.getMegaMekAvailabilityUnitIds({ + ...context, + availabilityFrom: new Set([value as MegaMekAvailabilityFrom]), + }), + ); } if (filterKey === 'availabilityRarity') { @@ -3273,9 +3265,11 @@ export class UnitSearchFiltersService { return new Set(); } - return value === MEGAMEK_AVAILABILITY_UNKNOWN - ? this.unitAvailabilitySource.getMegaMekUnknownUnitIds(context) - : this.unitAvailabilitySource.getMegaMekRarityUnitIds(value as MegaMekAvailabilityRarity, context); + return this.getMegaMekUnitUuids( + value === MEGAMEK_AVAILABILITY_UNKNOWN + ? this.unitAvailabilitySource.getMegaMekUnknownUnitIds(context) + : this.unitAvailabilitySource.getMegaMekRarityUnitIds(value as MegaMekAvailabilityRarity, context), + ); } return this.dataService.getIndexedUnitIds(filterKey, value); @@ -3460,7 +3454,7 @@ export class UnitSearchFiltersService { }, getAvailabilityLookupKey: unit => this.unitAvailabilitySource.getUnitAvailabilityKey(unit), getIndexedUnitIds: (filterKey, value) => this.dataService.getIndexedUnitIds(filterKey, value), - getIndexedASSpecials: unitName => this.dataService.getIndexedASSpecials(unitName), + getIndexedASSpecials: unitUuid => this.dataService.getIndexedASSpecials(unitUuid), unitMatchesAvailabilityFrom: (unit, availabilityFromName, scope) => this.unitMatchesAvailabilityFrom(unit, availabilityFromName, scope), unitMatchesAvailabilityRarity: (unit, rarityName, scope) => @@ -3495,6 +3489,11 @@ export class UnitSearchFiltersService { const executionParsedQuery = options.ignoreFormationTarget ? stripSemanticFieldsFromParseResult(parsedQuery, FORMATION_TARGET_SEMANTIC_FIELDS) : parsedQuery; + const uiOnlyFilterState = this.stripFormationTargetFilterState( + this.getUiOnlyFilterState(this.getApplicableFilterState(this.filterState()), this.semanticFilterKeys()), + ); + const resolvedUiEras = this.resolveEraNamesFromFilter(uiOnlyFilterState['era']); + const uiEraNames = [...new Set([...resolvedUiEras.or, ...resolvedUiEras.and])]; const megaMekRaritySortScope = this.megaMekRaritySortScope(); const megaMekRaritySortContext = this.megaMekRaritySortContext(); const megaMekRaritySortScoreResolver = megaMekRaritySortContext === null @@ -3505,8 +3504,11 @@ export class UnitSearchFiltersService { units: this.units, parsedQuery: executionParsedQuery, searchTokens: this.searchTokens(), - uiOnlyFilterState: this.stripFormationTargetFilterState(this.getUiOnlyFilterState(this.getApplicableFilterState(this.filterState()), this.semanticFilterKeys())), + uiOnlyFilterState, uiOnlyFilterDependencies: this.getUnitFilterKernelDependencies(), + // UI-only filters run after the AST. Pass their era scope into the + // AST so exclusive faction matching is evaluated in that era. + initialAvailabilityScope: uiEraNames.length > 0 ? { eraNames: uiEraNames } : undefined, gameSystem: this.gameService.currentGameSystem(), sortKey: this.selectedSort(), sortDirection: this.selectedSortDirection(), @@ -3533,7 +3535,7 @@ export class UnitSearchFiltersService { }, getIndexedUnitIds: (filterKey: string, value: string, scope?: AvailabilityFilterScope) => this.getSemanticIndexedUnitIds(filterKey, value, scope), getIndexedFilterValues: (filterKey: string) => this.getSemanticIndexedFilterValues(filterKey), - getIndexedASSpecials: (unitId: string) => this.dataService.getIndexedASSpecials(unitId), + getIndexedASSpecials: (unitUuid: string) => this.dataService.getIndexedASSpecials(unitUuid), availabilitySortScope: megaMekRaritySortScope, getMegaMekRaritySortScore: megaMekRaritySortScoreResolver ? (unit: UnitSummary) => megaMekRaritySortScoreResolver(unit) @@ -3995,7 +3997,7 @@ export class UnitSearchFiltersService { targetPv: { min: 0, max: DEFAULT_ALPHA_STRIKE_PV_NORMALIZATION_MAX }, skill: { min: 0, max: 8 }, }); - this.workerNormalizationMatchesState.set(new Map()); + this.workerNormalizationMatchesByUnitUuid.set(new Map()); this.refreshWorkerSearchIfNeeded(); } @@ -4159,8 +4161,8 @@ export class UnitSearchFiltersService { const normalization = this.activeNormalization(); const normalizedMatch = normalization ? !this.workerSearchActive() || !this.isSearchSettled() - ? this.uncappedSyncSearch().execution.normalizationMatchesByUnitName.get(unit.name) - : this.workerNormalizationMatchesState().get(unit.name) + ? this.uncappedSyncSearch().execution.normalizationMatchesByUnitUuid.get(unit.uuid) + : this.workerNormalizationMatchesByUnitUuid().get(unit.uuid) ?? this.findNormalizationMatch(unit, normalization) : null; if (normalizedMatch) { diff --git a/src/app/services/unit-search-index.service.spec.ts b/src/app/services/unit-search-index.service.spec.ts index 179ed696c..777399291 100644 --- a/src/app/services/unit-search-index.service.spec.ts +++ b/src/app/services/unit-search-index.service.spec.ts @@ -11,6 +11,7 @@ function createUnit(overrides: TestUnitOverrides): UnitSummary { const { as: asOverrides, ...unitOverrides } = overrides; return createEmptyUnit({ + uuid: unitOverrides.uuid ?? unitOverrides.name ?? 'Unit', id: 1, name: 'Unit', chassis: 'Unit', @@ -35,6 +36,35 @@ function createUnit(overrides: TestUnitOverrides): UnitSummary { } describe('UnitSearchIndexService', () => { + it('uses UUID postings while expanding duplicate MUL ids for era and faction membership', () => { + const service = new UnitSearchIndexService(); + const first = createUnit({ id: 42, uuid: 'uuid-a', name: 'Duplicate Name' }); + const second = createUnit({ id: 42, uuid: 'uuid-b', name: 'Duplicate Name' }); + const era = { + id: 1, + name: 'Test Era', + img: '', + years: { from: 3000, to: 3100 }, + units: new Set([42]), + factions: new Set(), + }; + const faction = { + id: 1, + name: 'Test Faction', + group: 'Other' as const, + img: '', + eras: { 1: new Set([42]) }, + }; + + service.rebuildIndexes([first, second], [era], [faction]); + + const expectedUuids = new Set([first.uuid, second.uuid]); + expect(service.getIndexedUnitIds('type', 'Mek')).toEqual(expectedUuids); + expect(service.getIndexedUnitIds('era', era.name)).toEqual(expectedUuids); + expect(service.getIndexedUnitIds('faction', faction.name)).toEqual(expectedUuids); + expect(service.getFactionEraUnitUuids([era.name], [faction.name])).toEqual(expectedUuids); + }); + it('indexes canonical Alpha Strike special tokens, nested turret abilities, and observed parameter shapes', () => { const service = new UnitSearchIndexService(); service.rebuildIndexes([ diff --git a/src/app/services/unit-search-index.service.ts b/src/app/services/unit-search-index.service.ts index e8300c28e..ba78ca239 100644 --- a/src/app/services/unit-search-index.service.ts +++ b/src/app/services/unit-search-index.service.ts @@ -298,35 +298,35 @@ export class UnitSearchIndexService { this.asSpecialFieldCounts = new Map(); this.asSpecialsByUnit = buildASSpecialsByUnitIndex( units, - unit => unit.name, + unit => unit.uuid, unit => unit.as?.specials, ); - const unitNamesByMulId = this.createUnitNamesByMulId(units); + const unitUuidsByMulId = this.createUnitUuidsByMulId(units); for (const unit of units) { - this.addSearchIndexValue('type', unit.type, unit.name); - this.addSearchIndexValue('subtype', unit.subtype, unit.name); - this.addSearchIndexValue('_techBaseDisplay', unit._techBaseDisplay, unit.name); - this.addSearchIndexValue('role', unit.role, unit.name); - this.addSearchIndexValue('weightClass', unit.weightClass, unit.name); - this.addSearchIndexValue('level', String(unit.level), unit.name); - this.addSearchIndexValue('c3', unit.c3, unit.name); - this.addSearchIndexValue('moveType', unit.moveType, unit.name); - this.addSearchIndexValue('as.TP', unit.as?.TP, unit.name); - this.addASSpecialIndexValues(this.asSpecialsByUnit.get(unit.name), unit.name); - this.addSearchIndexValues('as._motive', this.getASMotiveDisplayNames(unit), unit.name); - this.addSearchIndexValues('source', getUnitSourceFilterValues(unit), unit.name); - this.addSearchIndexValues('rulesRefs', unit.rulesRefs?.flat() ?? [], unit.name); - this.addSearchIndexValues('componentName', unit.comp.map(component => component.n), unit.name); + this.addSearchIndexValue('type', unit.type, unit.uuid); + this.addSearchIndexValue('subtype', unit.subtype, unit.uuid); + this.addSearchIndexValue('_techBaseDisplay', unit._techBaseDisplay, unit.uuid); + this.addSearchIndexValue('role', unit.role, unit.uuid); + this.addSearchIndexValue('weightClass', unit.weightClass, unit.uuid); + this.addSearchIndexValue('level', String(unit.level), unit.uuid); + this.addSearchIndexValue('c3', unit.c3, unit.uuid); + this.addSearchIndexValue('moveType', unit.moveType, unit.uuid); + this.addSearchIndexValue('as.TP', unit.as?.TP, unit.uuid); + this.addASSpecialIndexValues(this.asSpecialsByUnit.get(unit.uuid), unit.uuid); + this.addSearchIndexValues('as._motive', this.getASMotiveDisplayNames(unit), unit.uuid); + this.addSearchIndexValues('source', getUnitSourceFilterValues(unit), unit.uuid); + this.addSearchIndexValues('rulesRefs', unit.rulesRefs?.flat() ?? [], unit.uuid); + this.addSearchIndexValues('componentName', unit.comp.map(component => component.n), unit.uuid); this.addComponentCountValues(unit); this.prepareUnitWeaponTypes(unit); - this.addSearchIndexValues('weaponType', unit._weaponTypes ?? [], unit.name); - this.addSearchIndexValues('features', unit.features ?? [], unit.name); - this.addSearchIndexValues('quirks', unit.quirks ?? [], unit.name); - this.addSearchIndexValues('_tags', getMergedTags(unit), unit.name); + this.addSearchIndexValues('weaponType', unit._weaponTypes ?? [], unit.uuid); + this.addSearchIndexValues('features', unit.features ?? [], unit.uuid); + this.addSearchIndexValues('quirks', unit.quirks ?? [], unit.uuid); + this.addSearchIndexValues('_tags', getMergedTags(unit), unit.uuid); for (const filter of BOOLEAN_FILTERS) { - this.addSearchIndexValue(filter.key, getBooleanFilterUnitValue(filter, unit[filter.key as keyof UnitSummary]) ? 'yes' : 'no', unit.name); + this.addSearchIndexValue(filter.key, getBooleanFilterUnitValue(filter, unit[filter.key as keyof UnitSummary]) ? 'yes' : 'no', unit.uuid); } } @@ -336,8 +336,8 @@ export class UnitSearchIndexService { : undefined; for (const referenceId of era.units as Set) { if (!extinctReferenceIdsForEra?.has(referenceId)) { - for (const unitName of unitNamesByMulId.get(referenceId) ?? []) { - this.addSearchIndexValue('era', era.name, unitName); + for (const unitUuid of unitUuidsByMulId.get(referenceId) ?? []) { + this.addSearchIndexValue('era', era.name, unitUuid); } } } @@ -346,8 +346,8 @@ export class UnitSearchIndexService { for (const faction of factions) { for (const referenceIds of Object.values(faction.eras) as Set[]) { for (const referenceId of referenceIds) { - for (const unitName of unitNamesByMulId.get(referenceId) ?? []) { - this.addSearchIndexValue('faction', faction.name, unitName); + for (const unitUuid of unitUuidsByMulId.get(referenceId) ?? []) { + this.addSearchIndexValue('faction', faction.name, unitUuid); } } } @@ -358,7 +358,7 @@ export class UnitSearchIndexService { } this.rebuildDropdownOptionUniverse(eras, factions); - this.factionEraSnapshot = this.createFactionEraSnapshot(unitNamesByMulId, eras, factions); + this.factionEraSnapshot = this.createFactionEraSnapshot(unitUuidsByMulId, eras, factions); } public rebuildTagSearchIndex(units: UnitSummary[]): void { @@ -374,7 +374,7 @@ export class UnitSearchIndexService { unitIds = new Set(); tagIndex.set(tag, unitIds); } - unitIds.add(unit.name); + unitIds.add(unit.uuid); } } @@ -399,8 +399,8 @@ export class UnitSearchIndexService { return this.searchFilterValues.get(filterKey) ?? []; } - public getIndexedASSpecials(unitName: string): ParsedASSpecials | undefined { - return this.asSpecialsByUnit.get(unitName); + public getIndexedASSpecials(unitUuid: string): ParsedASSpecials | undefined { + return this.asSpecialsByUnit.get(unitUuid); } public getSearchWorkerIndexSnapshot(): UnitSearchWorkerIndexSnapshot { @@ -408,8 +408,8 @@ export class UnitSearchIndexService { for (const [filterKey, valueMap] of this.searchFilterIndex.entries()) { snapshot[filterKey] = {}; - for (const [value, unitNames] of valueMap.entries()) { - snapshot[filterKey][value] = Array.from(unitNames); + for (const [value, unitUuids] of valueMap.entries()) { + snapshot[filterKey][value] = Array.from(unitUuids); } } @@ -422,6 +422,23 @@ export class UnitSearchIndexService { ); } + public getFactionEraUnitUuids( + eraNames: readonly string[], + factionNames: readonly string[], + ): ReadonlySet { + const unitUuids = new Set(); + + for (const eraName of eraNames) { + for (const factionName of factionNames) { + for (const unitUuid of this.factionEraSnapshot[eraName]?.[factionName] ?? []) { + unitUuids.add(unitUuid); + } + } + } + + return unitUuids; + } + public getDropdownOptionUniverse(filterKey: string): UnitSearchDropdownOption[] { return this.dropdownOptionUniverse.get(filterKey)?.map(option => ({ ...option })) ?? []; } @@ -477,13 +494,13 @@ export class UnitSearchIndexService { this.dropdownOptionUniverse.set('faction', factions.map(faction => ({ name: faction.name, img: faction.img }))); } - private addASSpecialIndexValues(parsedSpecials: ParsedASSpecials | undefined, unitName: string): void { + private addASSpecialIndexValues(parsedSpecials: ParsedASSpecials | undefined, unitUuid: string): void { for (const occurrence of parsedSpecials?.occurrences ?? []) { if (!occurrence.token) { continue; } - this.addSearchIndexValue('as.specials', occurrence.token, unitName); + this.addSearchIndexValue('as.specials', occurrence.token, unitUuid); const currentFieldCount = this.asSpecialFieldCounts.get(occurrence.token) ?? 0; if (occurrence.values.length > currentFieldCount) { this.asSpecialFieldCounts.set(occurrence.token, occurrence.values.length); @@ -491,7 +508,7 @@ export class UnitSearchIndexService { } } - private createFactionEraSnapshot(unitNamesByMulId: Map, eras: Era[], factions: Faction[]): UnitSearchWorkerFactionEraSnapshot { + private createFactionEraSnapshot(unitUuidsByMulId: Map, eras: Era[], factions: Faction[]): UnitSearchWorkerFactionEraSnapshot { const snapshot: UnitSearchWorkerFactionEraSnapshot = {}; const erasById = new Map(eras.map(era => [era.id, era])); @@ -506,35 +523,35 @@ export class UnitSearchIndexService { continue; } - const unitNames: string[] = []; + const unitUuids: string[] = []; for (const referenceId of referenceIds) { - unitNames.push(...(unitNamesByMulId.get(referenceId) ?? [])); + unitUuids.push(...(unitUuidsByMulId.get(referenceId) ?? [])); } snapshot[era.name] ??= {}; - snapshot[era.name][faction.name] = unitNames; + snapshot[era.name][faction.name] = unitUuids; } } return snapshot; } - private createUnitNamesByMulId(units: UnitSummary[]): Map { - const unitNamesByMulId = new Map(); + private createUnitUuidsByMulId(units: UnitSummary[]): Map { + const unitUuidsByMulId = new Map(); for (const unit of units) { - const names = unitNamesByMulId.get(unit.id); - if (names) { - names.push(unit.name); + const uuids = unitUuidsByMulId.get(unit.id); + if (uuids) { + uuids.push(unit.uuid); } else { - unitNamesByMulId.set(unit.id, [unit.name]); + unitUuidsByMulId.set(unit.id, [unit.uuid]); } } - return unitNamesByMulId; + return unitUuidsByMulId; } - private addSearchIndexValue(filterKey: string, value: string | undefined, unitName: string): void { + private addSearchIndexValue(filterKey: string, value: string | undefined, unitUuid: string): void { if (!value) { return; } @@ -552,12 +569,12 @@ export class UnitSearchIndexService { filterIndex.set(normalizedValue, unitIds); } - unitIds.add(unitName); + unitIds.add(unitUuid); } - private addSearchIndexValues(filterKey: string, values: Iterable, unitName: string): void { + private addSearchIndexValues(filterKey: string, values: Iterable, unitUuid: string): void { for (const value of values) { - this.addSearchIndexValue(filterKey, value, unitName); + this.addSearchIndexValue(filterKey, value, unitUuid); } } @@ -570,7 +587,7 @@ export class UnitSearchIndexService { this.componentCountIndex.set(normalizedName, unitCounts); } - unitCounts.set(unit.name, (unitCounts.get(unit.name) || 0) + component.q); + unitCounts.set(unit.uuid, (unitCounts.get(unit.uuid) || 0) + component.q); } } diff --git a/src/app/unit-search.worker.spec.ts b/src/app/unit-search.worker.spec.ts index 7d558b8f1..c0684a8a2 100644 --- a/src/app/unit-search.worker.spec.ts +++ b/src/app/unit-search.worker.spec.ts @@ -62,26 +62,27 @@ function createUnit(name: string): UnitSummary { function createSnapshot(): UnitSearchWorkerCorpusSnapshot { const unitName = 'Masakari Prime'; + const unit = createUnit(unitName); return { corpusVersion: '1:0', - units: [createUnit(unitName)], + units: [unit], indexes: { era: { - 'Clan Invasion': [unitName], - ilClan: [unitName], + 'Clan Invasion': [unit.uuid], + ilClan: [unit.uuid], }, faction: { - 'Clan Jade Falcon': [unitName], - 'Clan Wolf': [unitName], + 'Clan Jade Falcon': [unit.uuid], + 'Clan Wolf': [unit.uuid], }, }, factionEraIndex: { 'Clan Invasion': { - 'Clan Jade Falcon': [unitName], + 'Clan Jade Falcon': [unit.uuid], }, ilClan: { - 'Clan Wolf': [unitName], + 'Clan Wolf': [unit.uuid], }, }, }; @@ -116,8 +117,8 @@ describe('unit-search worker', () => { units: [mixedClan, nonmixedClan], indexes: { _techBaseDisplay: { - 'Mixed (Clan)': ['Mixed Clan Unit'], - Clan: ['Clan Unit'], + 'Mixed (Clan)': [mixedClan.uuid], + Clan: [nonmixedClan.uuid], }, }, factionEraIndex: {}, @@ -128,12 +129,12 @@ describe('unit-search worker', () => { ...baseRequest, executionQuery: 'tech="Mixed (Clan)"', telemetryQuery: 'tech="Mixed (Clan)"', - }).entries).toEqual([{ unitName: 'Mixed Clan Unit' }]); + }).entries).toEqual([{ unitUuid: mixedClan.uuid }]); expect(__test__.buildResultMessage(runtime, { ...baseRequest, executionQuery: 'tech=Clan', telemetryQuery: 'tech=Clan', - }).entries).toEqual([{ unitName: 'Clan Unit' }]); + }).entries).toEqual([{ unitUuid: nonmixedClan.uuid }]); }); it('requires faction membership in every selected multistate era', () => { @@ -143,6 +144,49 @@ describe('unit-search worker', () => { expect(result.entries).toEqual([]); }); + it('evaluates exclusive faction membership only within the queried era', () => { + const exclusiveUnit = createUnit('Exclusive Unit'); + const sharedInLaterEra = createUnit('Shared In Later Era'); + const runtime = __test__.hydrateCorpus({ + corpusVersion: '1:0', + units: [exclusiveUnit, sharedInLaterEra], + indexes: { + era: { + 'Clan Invasion': [exclusiveUnit.uuid, sharedInLaterEra.uuid], + Jihad: [sharedInLaterEra.uuid], + }, + faction: { + 'Clan Coyote': [exclusiveUnit.uuid, sharedInLaterEra.uuid], + 'Federated Suns': [sharedInLaterEra.uuid], + }, + }, + factionEraIndex: { + 'Clan Invasion': { + 'Clan Coyote': [exclusiveUnit.uuid, sharedInLaterEra.uuid], + }, + Jihad: { + 'Federated Suns': [sharedInLaterEra.uuid], + }, + }, + }); + const request = { + ...createRequest(), + executionQuery: 'era="Clan Invasion" faction=="Clan Coyote"', + telemetryQuery: 'era="Clan Invasion" faction=="Clan Coyote"', + }; + + expect(__test__.buildResultMessage(runtime, request).entries).toEqual([ + { unitUuid: exclusiveUnit.uuid }, + { unitUuid: sharedInLaterEra.uuid }, + ]); + + expect(__test__.buildResultMessage(runtime, { + ...request, + executionQuery: 'faction=="Clan Coyote"', + telemetryQuery: 'faction=="Clan Coyote"', + }).entries).toEqual([{ unitUuid: exclusiveUnit.uuid }]); + }); + it('emits normalization metadata only in canonical result entries', () => { const unit = createUnit('Normalized Unit'); const runtime = __test__.hydrateCorpus({ @@ -167,7 +211,7 @@ describe('unit-search worker', () => { }); expect(result.entries).toEqual([{ - unitName: 'Normalized Unit', + unitUuid: unit.uuid, match: { kind: 'bv', adjustedValue: 1000, gunnery: 4, piloting: 5 }, }]); }); @@ -186,12 +230,12 @@ describe('unit-search worker', () => { units: [publishedCanon, unpublishedNonCanon], indexes: { canon: { - yes: ['Published Canon'], - no: ['Unpublished Non-Canon'], + yes: [publishedCanon.uuid], + no: [unpublishedNonCanon.uuid], }, published: { - yes: ['Published Canon'], - no: ['Unpublished Non-Canon'], + yes: [publishedCanon.uuid], + no: [unpublishedNonCanon.uuid], }, }, factionEraIndex: {}, @@ -202,17 +246,17 @@ describe('unit-search worker', () => { ...baseRequest, executionQuery: 'published:yes', telemetryQuery: 'published:yes', - }).entries).toEqual([{ unitName: 'Published Canon' }]); + }).entries).toEqual([{ unitUuid: publishedCanon.uuid }]); expect(__test__.buildResultMessage(runtime, { ...baseRequest, executionQuery: 'published:no', telemetryQuery: 'published:no', - }).entries).toEqual([{ unitName: 'Unpublished Non-Canon' }]); + }).entries).toEqual([{ unitUuid: unpublishedNonCanon.uuid }]); expect(__test__.buildResultMessage(runtime, { ...baseRequest, executionQuery: 'canon:no', telemetryQuery: 'canon:no', - }).entries).toEqual([{ unitName: 'Unpublished Non-Canon' }]); + }).entries).toEqual([{ unitUuid: unpublishedNonCanon.uuid }]); }); it('matches a complete rulebook bucket in the worker', () => { @@ -226,12 +270,12 @@ describe('unit-search worker', () => { units: [unitA, unitB], indexes: { rulesRefs: { - Core: ['Unit A'], - TW: ['Unit A', 'Unit B'], - TM: ['Unit B'], - 'IO:AE': ['Unit A'], - Shrap01: ['Unit B'], - AAA: ['Unit B'], + Core: [unitA.uuid], + TW: [unitA.uuid, unitB.uuid], + TM: [unitB.uuid], + 'IO:AE': [unitA.uuid], + Shrap01: [unitB.uuid], + AAA: [unitB.uuid], }, }, factionEraIndex: {}, @@ -244,13 +288,13 @@ describe('unit-search worker', () => { telemetryQuery: executionQuery, }).entries; - expect(getEntries('rulesRefs=Core')).toEqual([{ unitName: 'Unit A' }]); + expect(getEntries('rulesRefs=Core')).toEqual([{ unitUuid: unitA.uuid }]); expect(getEntries('rulesRefs=TW')).toEqual([]); - expect(getEntries('rulesRefs=TW,IO:AE')).toEqual([{ unitName: 'Unit A' }]); + expect(getEntries('rulesRefs=TW,IO:AE')).toEqual([{ unitUuid: unitA.uuid }]); expect(getEntries('rulesRefs=TW,Shrap01')).toEqual([]); - expect(getEntries('rulesRefs=TW,Shrap01,AAA')).toEqual([{ unitName: 'Unit B' }]); - expect(getEntries('rulesRefs=IO:AE')).toEqual([{ unitName: 'Unit A' }]); - expect(getEntries('rulesRefs=Shrap01')).toEqual([{ unitName: 'Unit B' }]); + expect(getEntries('rulesRefs=TW,Shrap01,AAA')).toEqual([{ unitUuid: unitB.uuid }]); + expect(getEntries('rulesRefs=IO:AE')).toEqual([{ unitUuid: unitA.uuid }]); + expect(getEntries('rulesRefs=Shrap01')).toEqual([{ unitUuid: unitB.uuid }]); expect(getEntries('rulesRefs=AAA')).toEqual([]); }); @@ -267,9 +311,9 @@ describe('unit-search worker', () => { units: [lowAC, nestedHighAC, noAC], indexes: { 'as.specials': { - AC: ['Low AC', 'Nested High AC'], - TAG: ['No AC'], - TUR: ['Nested High AC'], + AC: [lowAC.uuid, nestedHighAC.uuid], + TAG: [noAC.uuid], + TUR: [nestedHighAC.uuid], }, }, factionEraIndex: {}, @@ -284,7 +328,7 @@ describe('unit-search worker', () => { executionQuery: query, telemetryQuery: query, gameSystem: GameSystem.ALPHA_STRIKE, - }).entries).toEqual([{ unitName: 'Nested High AC' }]); + }).entries).toEqual([{ unitUuid: nestedHighAC.uuid }]); }); it('keeps repeated specials constraints and implicit values identical in worker execution', () => { @@ -302,8 +346,8 @@ describe('unit-search worker', () => { units: [mediumOnly, longOnly, both, implicitSnarc], indexes: { 'as.specials': { - AC: ['Medium Only', 'Long Only', 'Both Ranges'], - SNARC: ['Implicit SNARC'], + AC: [mediumOnly.uuid, longOnly.uuid, both.uuid], + SNARC: [implicitSnarc.uuid], }, }, factionEraIndex: {}, @@ -316,8 +360,8 @@ describe('unit-search worker', () => { }).entries; expect(execute('specials&="AC*/>=4/*" specials&="AC*/*/>=3"')) - .toEqual([{ unitName: 'Both Ranges' }]); + .toEqual([{ unitUuid: both.uuid }]); expect(execute('specials="SNARC>=1"')) - .toEqual([{ unitName: 'Implicit SNARC' }]); + .toEqual([{ unitUuid: implicitSnarc.uuid }]); }); }); diff --git a/src/app/unit-search.worker.ts b/src/app/unit-search.worker.ts index da24ba730..fa7488543 100644 --- a/src/app/unit-search.worker.ts +++ b/src/app/unit-search.worker.ts @@ -34,11 +34,11 @@ import { buildASSpecialsByUnitIndex, type ParsedASSpecials } from './utils/as-sp interface WorkerCorpusRuntime { corpusVersion: string; units: UnitSummary[]; - allUnitNames: ReadonlySet; - indexedUnitIds: Map>>; + allUnitUuids: ReadonlySet; + indexedUnitUuids: Map>>; indexedFilterValues: Map; indexedASSpecials: Map; - factionEraUnitIds: Map>>; + factionEraUnitUuids: Map>>; forcePackToLookupKey: Map>; } @@ -53,13 +53,13 @@ function getUnitNameKey(name: string): string { return name.toLowerCase(); } -function buildIndexedUnitIds(indexes: UnitSearchWorkerIndexSnapshot): Map>> { +function buildIndexedUnitUuids(indexes: UnitSearchWorkerIndexSnapshot): Map>> { const result = new Map>>(); for (const [filterKey, valueMap] of Object.entries(indexes)) { const filterIndex = new Map>(); - for (const [value, unitNames] of Object.entries(valueMap)) { - filterIndex.set(value, new Set(unitNames)); + for (const [value, unitUuids] of Object.entries(valueMap)) { + filterIndex.set(value, new Set(unitUuids)); } result.set(filterKey, filterIndex); } @@ -77,13 +77,13 @@ function buildIndexedFilterValues(indexes: UnitSearchWorkerIndexSnapshot): Map>> { +function buildFactionEraUnitUuids(factionEraIndex: UnitSearchWorkerFactionEraSnapshot): Map>> { const result = new Map>>(); for (const [eraName, factionMap] of Object.entries(factionEraIndex)) { const eraIndex = new Map>(); - for (const [factionName, unitNames] of Object.entries(factionMap)) { - eraIndex.set(factionName, new Set(unitNames)); + for (const [factionName, unitUuids] of Object.entries(factionMap)) { + eraIndex.set(factionName, new Set(unitUuids)); } result.set(eraName, eraIndex); } @@ -91,13 +91,13 @@ function buildFactionEraUnitIds(factionEraIndex: UnitSearchWorkerFactionEraSnaps return result; } -function addUnitNames(target: Set, source: ReadonlySet | undefined): void { +function addUnitUuids(target: Set, source: ReadonlySet | undefined): void { if (!source || source.size === 0) { return; } - for (const unitName of source) { - target.add(unitName); + for (const unitUuid of source) { + target.add(unitUuid); } } @@ -130,15 +130,15 @@ function hydrateCorpus(snapshot: UnitSearchWorkerCorpusSnapshot): WorkerCorpusRu return { corpusVersion: snapshot.corpusVersion, units: snapshot.units, - allUnitNames: new Set(snapshot.units.map((unit) => unit.name)), - indexedUnitIds: buildIndexedUnitIds(snapshot.indexes), + allUnitUuids: new Set(snapshot.units.map((unit) => unit.uuid)), + indexedUnitUuids: buildIndexedUnitUuids(snapshot.indexes), indexedFilterValues: buildIndexedFilterValues(snapshot.indexes), indexedASSpecials: buildASSpecialsByUnitIndex( snapshot.units, - unit => unit.name, + unit => unit.uuid, unit => unit.as?.specials, ), - factionEraUnitIds: buildFactionEraUnitIds(snapshot.factionEraIndex), + factionEraUnitUuids: buildFactionEraUnitUuids(snapshot.factionEraIndex), forcePackToLookupKey: buildForcePackIndex(snapshot.units), }; } @@ -153,68 +153,68 @@ function buildResultMessage(runtime: WorkerCorpusRuntime, request: UnitSearchWor const parsedQuery = parseSemanticQueryAST(request.executionQuery, request.gameSystem); const parseDurationMs = getNowMs() - parseStartedAt; - const getFactionEraUnitNames = (eraName: string, factionNames: readonly string[]): ReadonlySet => { - const unitNames = new Set(); + const getFactionEraUnitUuids = (eraName: string, factionNames: readonly string[]): ReadonlySet => { + const unitUuids = new Set(); if (factionNames.length === 0) { - return unitNames; + return unitUuids; } - const eraFactionUnitIds = runtime.factionEraUnitIds.get(eraName); + const eraFactionUnitUuids = runtime.factionEraUnitUuids.get(eraName); for (const factionName of factionNames) { - addUnitNames(unitNames, eraFactionUnitIds?.get(factionName)); + addUnitUuids(unitUuids, eraFactionUnitUuids?.get(factionName)); } - return unitNames; + return unitUuids; }; - const getMembershipUnitNames = (scope?: AvailabilityFilterScope): ReadonlySet => { - const unitNames = new Set(); + const getMembershipUnitUuids = (scope?: AvailabilityFilterScope): ReadonlySet => { + const unitUuids = new Set(); if (scope?.eraNames !== undefined && scope.factionNames !== undefined) { for (const eraName of scope.eraNames) { - addUnitNames(unitNames, getFactionEraUnitNames(eraName, scope.factionNames)); + addUnitUuids(unitUuids, getFactionEraUnitUuids(eraName, scope.factionNames)); } - return unitNames; + return unitUuids; } if (scope?.eraNames !== undefined) { for (const eraName of scope.eraNames) { - addUnitNames(unitNames, runtime.indexedUnitIds.get('era')?.get(eraName)); + addUnitUuids(unitUuids, runtime.indexedUnitUuids.get('era')?.get(eraName)); } - return unitNames; + return unitUuids; } if (scope?.factionNames !== undefined) { for (const factionName of scope.factionNames) { - addUnitNames(unitNames, runtime.indexedUnitIds.get('faction')?.get(factionName)); + addUnitUuids(unitUuids, runtime.indexedUnitUuids.get('faction')?.get(factionName)); } - return unitNames; + return unitUuids; } - addUnitNames(unitNames, runtime.allUnitNames); + addUnitUuids(unitUuids, runtime.allUnitUuids); - return unitNames; + return unitUuids; }; - const getScopedEraUnitNames = ( + const getScopedEraUnitUuids = ( eraName: string, scope?: AvailabilityFilterScope, ): ReadonlySet => { - return getMembershipUnitNames( + return getMembershipUnitUuids( scope?.factionNames === undefined ? { eraNames: [eraName] } : { eraNames: [eraName], factionNames: scope.factionNames }, ); }; - const getScopedFactionUnitNames = ( + const getScopedFactionUnitUuids = ( factionName: string, eraNames?: readonly string[], ): ReadonlySet => { - return getMembershipUnitNames( + return getMembershipUnitUuids( eraNames === undefined ? { factionNames: [factionName] } : { eraNames: [...eraNames], factionNames: [factionName] }, @@ -235,14 +235,14 @@ function buildResultMessage(runtime: WorkerCorpusRuntime, request: UnitSearchWor scope?: AvailabilityFilterScope, ): ReadonlySet | undefined => { if (filterKey === 'era') { - return getScopedEraUnitNames(value, scope); + return getScopedEraUnitUuids(value, scope); } if (filterKey === 'faction') { - return getScopedFactionUnitNames(value, scope?.eraNames); + return getScopedFactionUnitUuids(value, scope?.eraNames); } - return runtime.indexedUnitIds.get(filterKey)?.get(value); + return runtime.indexedUnitUuids.get(filterKey)?.get(value); }; const getIndexedFilterValues = (filterKey: string): readonly string[] => { @@ -278,15 +278,15 @@ function buildResultMessage(runtime: WorkerCorpusRuntime, request: UnitSearchWor } return adjustPointValueForSkill(unit.as.PV, request.pilotGunnerySkill); }, - unitBelongsToEra: (unit: UnitSummary, eraName: string, scope?: AvailabilityFilterScope) => getScopedEraUnitNames(eraName, scope).has(unit.name), - unitBelongsToFaction: (unit: UnitSummary, factionName: string, eraNames?: readonly string[]) => getScopedFactionUnitNames(factionName, eraNames).has(unit.name), + unitBelongsToEra: (unit: UnitSummary, eraName: string, scope?: AvailabilityFilterScope) => getScopedEraUnitUuids(eraName, scope).has(unit.uuid), + unitBelongsToFaction: (unit: UnitSummary, factionName: string, eraNames?: readonly string[]) => getScopedFactionUnitUuids(factionName, eraNames).has(unit.uuid), unitBelongsToForcePack: (unit: UnitSummary, packName: string) => runtime.forcePackToLookupKey.get(packName)?.has(getUnitVariantGroupKey(unit)) ?? false, getAllEraNames: getEraFilterValues, getAllFactionNames: getFactionFilterValues, getDisplayName: (filterKey: string, value: string) => workerDisplayNameFns.get(filterKey)?.(value), getIndexedUnitIds, getIndexedFilterValues, - getIndexedASSpecials: unitId => runtime.indexedASSpecials.get(unitId), + getIndexedASSpecials: unitUuid => runtime.indexedASSpecials.get(unitUuid), }); const parseStage: SearchTelemetryStage = { @@ -301,8 +301,8 @@ function buildResultMessage(runtime: WorkerCorpusRuntime, request: UnitSearchWor corpusVersion: runtime.corpusVersion, telemetryQuery: request.telemetryQuery, entries: execution.results.map(unit => { - const match = execution.normalizationMatchesByUnitName.get(unit.name); - return match ? { unitName: unit.name, match } : { unitName: unit.name }; + const match = execution.normalizationMatchesByUnitUuid.get(unit.uuid); + return match ? { unitUuid: unit.uuid, match } : { unitUuid: unit.uuid }; }), stages: [parseStage, ...execution.telemetryStages], totalMs: parseDurationMs + execution.totalMs, diff --git a/src/app/utils/semantic-filter-ast.util.spec.ts b/src/app/utils/semantic-filter-ast.util.spec.ts index e5e34af81..078fe045c 100644 --- a/src/app/utils/semantic-filter-ast.util.spec.ts +++ b/src/app/utils/semantic-filter-ast.util.spec.ts @@ -3,7 +3,7 @@ // Author: Drake import { GameSystem } from '../models/common.model'; -import { filterUnitsWithAST, parseSemanticQueryAST, tokenizeForHighlight, type ParseResult } from './semantic-filter-ast.util'; +import { filterUnitsWithAST, getMatchingTextForUnit, parseSemanticQueryAST, tokenizeForHighlight, type ParseResult } from './semantic-filter-ast.util'; import { filterStateToSemanticText, tokensToFilterState } from './semantic-filter.util'; import { matchesSearch, parseSearchQuery } from './search.util'; @@ -300,6 +300,103 @@ describe('semantic filter exclusivity', () => { expect(membershipChecks).toBe(0); }); + it('keeps an explicit empty era scope distinct from global exclusivity', () => { + const units = [{ id: 1 }, { id: 2 }]; + const result = parseSemanticQueryAST('faction=="Clan Coyote"', GameSystem.CLASSIC); + const context = { + gameSystem: GameSystem.CLASSIC, + getProperty: () => undefined, + getUnitId, + getIndexedFilterValues: (filterKey: string) => filterKey === 'faction' + ? ['Clan Coyote', 'Federated Suns'] + : [], + getIndexedUnitIds: (filterKey: string, value: string, scope?: { eraNames?: readonly string[] }) => { + if (filterKey !== 'faction') { + return undefined; + } + if (scope?.eraNames !== undefined) { + return new Set(); + } + return value === 'Clan Coyote' ? new Set(['1']) : new Set(['2']); + }, + getAllFactionNames: () => ['Clan Coyote', 'Federated Suns'], + }; + + expect(filterUnitsWithAST(units, result.ast, context)).toEqual([units[0]]); + expect(filterUnitsWithAST(units, result.ast, context, { eraNames: [] })).toEqual([]); + + const fallbackContext = { + gameSystem: GameSystem.CLASSIC, + getProperty: () => undefined, + getUnitId, + unitBelongsToFaction: ( + unit: { id: number }, + factionName: string, + eraNames?: readonly string[], + ) => { + const membershipEra = unit.id === 1 && factionName === 'Clan Coyote' + ? 'Clan Invasion' + : unit.id === 2 && factionName === 'Federated Suns' + ? 'Jihad' + : null; + return membershipEra !== null + && (eraNames === undefined || eraNames.includes(membershipEra)); + }, + unitBelongsToEra: (unit: { id: number }, eraName: string) => ( + (unit.id === 1 && eraName === 'Clan Invasion') + || (unit.id === 2 && eraName === 'Jihad') + ), + getAllEraNames: () => ['Clan Invasion', 'Jihad'], + getAllFactionNames: () => ['Clan Coyote', 'Federated Suns'], + }; + + expect(filterUnitsWithAST(units, result.ast, fallbackContext)).toEqual([units[0]]); + expect(filterUnitsWithAST(units, result.ast, fallbackContext, { eraNames: [] })).toEqual([]); + + const scopedResult = parseSemanticQueryAST( + 'era="Clan Invasion" faction=="Clan Coyote"', + GameSystem.CLASSIC, + ); + expect(filterUnitsWithAST(units, scopedResult.ast, fallbackContext)).toEqual([units[0]]); + expect(filterUnitsWithAST(units, scopedResult.ast, fallbackContext, { eraNames: [] })).toEqual([]); + }); + + it('uses the same era scope when selecting relevance text from a complex query', () => { + const unit = { + id: 1, + factionEras: { + 'Clan Coyote': ['Clan Invasion'], + 'Federated Suns': ['Jihad'], + }, + }; + const result = parseSemanticQueryAST( + '(faction=="Clan Coyote" Atlas) OR (faction=="Federated Suns" Zzz)', + GameSystem.CLASSIC, + ); + const context = { + gameSystem: GameSystem.CLASSIC, + getProperty: () => undefined, + getUnitId, + matchesText: (_unit: typeof unit, text: string) => text === 'Atlas', + unitBelongsToFaction: ( + candidate: typeof unit, + factionName: string, + eraNames?: readonly string[], + ) => { + const membershipEras = candidate.factionEras[factionName as keyof typeof candidate.factionEras] ?? []; + return eraNames === undefined + ? membershipEras.length > 0 + : eraNames.some(eraName => membershipEras.includes(eraName)); + }, + getAllFactionNames: () => ['Clan Coyote', 'Federated Suns'], + }; + const scope = { eraNames: ['Clan Invasion'] }; + + expect(result.errors).toEqual([]); + expect(filterUnitsWithAST([unit], result.ast, context, scope)).toEqual([unit]); + expect(getMatchingTextForUnit(result.ast, unit, context, scope)).toEqual(['Atlas']); + }); + it('uses indexed results for wildcard external include filters without per-unit membership scans', () => { const units = [ { name: 'Unit 1', faction: ['Capellan Confederation'] }, @@ -1051,4 +1148,4 @@ describe('semantic filter exclusivity', () => { expect(result.result.tokens[0]).toEqual(jasmine.objectContaining({ values: ['CAR[2,nope]'] })); expect(result.names).toEqual([]); }); -}); \ No newline at end of file +}); diff --git a/src/app/utils/semantic-filter-ast.util.ts b/src/app/utils/semantic-filter-ast.util.ts index d449512ad..455f786c9 100644 --- a/src/app/utils/semantic-filter-ast.util.ts +++ b/src/app/utils/semantic-filter-ast.util.ts @@ -1078,7 +1078,7 @@ export function isComplexQuery(ast: GroupASTNode): boolean { export interface EvaluatorContext { /** Get a property value from a unit by key path (e.g., 'as.PV', 'bv') */ getProperty: (unit: any, key: string) => any; - /** Get a stable unit identifier for candidate prefiltering. */ + /** Get the unit UUID used by indexed candidate postings. */ getUnitId: (unit: any) => string; /** Get adjusted BV for a unit (with pilot skill modifiers) */ getAdjustedBV?: (unit: any) => number; @@ -1130,12 +1130,12 @@ export interface EvaluatorContext { * @returns The display name, or undefined if no lookup exists */ getDisplayName?: (filterKey: string, value: string) => string | undefined; - /** Get indexed unit ids for an exact stored filter value. */ - getIndexedUnitIds?: (filterKey: string, value: string, scope?: AvailabilityFilterScope) => ReadonlySet | undefined; + /** Get indexed unit UUIDs for an exact stored filter value. */ + getIndexedUnitIds?: (filterKey: string, value: string, scope?: AvailabilityFilterScope) => ReadonlySet | undefined; /** Get all stored values available in an index for a filter key. */ getIndexedFilterValues?: (filterKey: string) => readonly string[]; /** Get pre-parsed Alpha Strike special tuples for a unit. */ - getIndexedASSpecials?: (unitId: string) => ParsedASSpecials | undefined; + getIndexedASSpecials?: (unitUuid: string) => ParsedASSpecials | undefined; } type ParsedRangeValue = @@ -1235,7 +1235,7 @@ interface ExternalFilterRuntimeCache { allNamesByKey: Map; expandedValuesByKey: Map>; unitMatchedNamesByKey: WeakMap>>; - indexedResultsByKey: Map }>; + indexedResultsByKey: Map }>; } const externalFilterRuntimeCache = new WeakMap(); @@ -1247,7 +1247,7 @@ function getExternalFilterRuntimeCache(context: EvaluatorContext): ExternalFilte allNamesByKey: new Map(), expandedValuesByKey: new Map>(), unitMatchedNamesByKey: new WeakMap>>(), - indexedResultsByKey: new Map }>(), + indexedResultsByKey: new Map }>(), }; externalFilterRuntimeCache.set(context, cache); } @@ -1324,19 +1324,9 @@ function getUnitMatchedExternalNames( runtimeCache.unitMatchedNamesByKey.set(unit, unitCache); } - const scopeParts: string[] = []; - if (activeScope?.eraNames && activeScope.eraNames.length > 0) { - scopeParts.push(`era=${[...activeScope.eraNames].map(name => name.toLowerCase()).sort().join('\u0001')}`); - } - if (activeScope?.factionNames && activeScope.factionNames.length > 0) { - scopeParts.push(`faction=${[...activeScope.factionNames].map(name => name.toLowerCase()).sort().join('\u0001')}`); - } - if (activeScope?.availabilityFromNames && activeScope.availabilityFromNames.length > 0) { - scopeParts.push(`from=${[...activeScope.availabilityFromNames].map(name => name.toLowerCase()).sort().join('\u0001')}`); - } - - const cacheKey = scopeParts.length > 0 - ? `${filterKey}\u0001${scopeParts.join('\u0002')}` + const scopeKey = buildExternalFilterScopeCacheKey(activeScope); + const cacheKey = scopeKey + ? `${filterKey}\u0001${scopeKey}` : filterKey; const cached = unitCache.get(cacheKey); if (cached) { @@ -1356,19 +1346,21 @@ function getUnitMatchedExternalNames( function buildExternalFilterScopeCacheKey(activeScope?: AvailabilityFilterScope): string { const scopeParts: string[] = []; + const addNames = (key: string, names: readonly string[] | undefined): void => { + if (names === undefined) { + return; + } + + scopeParts.push(`${key}=${[...names].map(name => name.toLowerCase()).sort().join('\u0001')}`); + }; if (activeScope?.bridgeThroughMulMembership) { scopeParts.push('bridge=mul'); } - if (activeScope?.eraNames && activeScope.eraNames.length > 0) { - scopeParts.push(`era=${[...activeScope.eraNames].map(name => name.toLowerCase()).sort().join('\u0001')}`); - } - if (activeScope?.factionNames && activeScope.factionNames.length > 0) { - scopeParts.push(`faction=${[...activeScope.factionNames].map(name => name.toLowerCase()).sort().join('\u0001')}`); - } - if (activeScope?.availabilityFromNames && activeScope.availabilityFromNames.length > 0) { - scopeParts.push(`from=${[...activeScope.availabilityFromNames].map(name => name.toLowerCase()).sort().join('\u0001')}`); - } + addNames('era', activeScope?.eraNames); + addNames('faction', activeScope?.factionNames); + addNames('from', activeScope?.availabilityFromNames); + addNames('rarity', activeScope?.availabilityRarityNames); return scopeParts.join('\u0002'); } @@ -1388,7 +1380,7 @@ function buildIndexedExternalFilterCacheKey( } function addIndexedExternalUnitIds( - target: Set, + target: Set, context: EvaluatorContext, filterKey: string, names: Iterable, @@ -1411,8 +1403,8 @@ function buildIndexedExternalUnitIdSet( filterKey: string, names: Iterable, activeScope?: AvailabilityFilterScope, -): Set { - const unitIds = new Set(); +): Set { + const unitIds = new Set(); addIndexedExternalUnitIds(unitIds, context, filterKey, names, activeScope); return unitIds; } @@ -1423,7 +1415,7 @@ function getIndexedExternalFilterResult( operator: SemanticOperator, values: readonly string[], activeScope?: AvailabilityFilterScope, -): { mode: 'match' | 'exclude'; unitIds: Set } | null { +): { mode: 'match' | 'exclude'; unitIds: Set } | null { if (!context.getIndexedUnitIds || !context.getIndexedFilterValues) { return null; } @@ -1441,10 +1433,10 @@ function getIndexedExternalFilterResult( return cached; } - let result: { mode: 'match' | 'exclude'; unitIds: Set }; + let result: { mode: 'match' | 'exclude'; unitIds: Set }; if (operator === '!=') { - const excludedIds = new Set(); + const excludedIds = new Set(); for (const value of values) { addIndexedExternalUnitIds( excludedIds, @@ -1460,7 +1452,7 @@ function getIndexedExternalFilterResult( unitIds: excludedIds, }; } else if (operator === '&=') { - let matchingIds: Set | null = null; + let matchingIds: Set | null = null; for (const value of values) { const expandedNames = expandExternalFilterValue(context, filterKey, value, allNames); @@ -1480,7 +1472,7 @@ function getIndexedExternalFilterResult( result = { mode: 'match', - unitIds: matchingIds ?? new Set(), + unitIds: matchingIds ?? new Set(), }; } else { const allowedNamesByLower = new Map(); @@ -1501,7 +1493,7 @@ function getIndexedExternalFilterResult( ); if (operator === '==') { - const excludedIds = new Set(); + const excludedIds = new Set(); for (const name of allNames) { if (!allowedNamesByLower.has(name.toLowerCase())) { addIndexedExternalUnitIds(excludedIds, context, filterKey, [name], activeScope); @@ -1604,8 +1596,8 @@ function mergeActiveNames( inheritedNames: readonly string[] | undefined, scopedNames: readonly string[] | null, ): readonly string[] | undefined { - if (!inheritedNames || inheritedNames.length === 0) { - return scopedNames ? [...scopedNames] : inheritedNames; + if (inheritedNames === undefined) { + return scopedNames ? [...scopedNames] : undefined; } if (!scopedNames || scopedNames.length === 0) { @@ -1624,6 +1616,20 @@ function mergeActiveNames( return intersection; } +function getAndGroupAvailabilityScope( + group: GroupASTNode, + context: EvaluatorContext, + activeScope?: AvailabilityFilterScope, +): AvailabilityFilterScope { + return { + bridgeThroughMulMembership: activeScope?.bridgeThroughMulMembership, + eraNames: mergeActiveNames(activeScope?.eraNames, collectScopedNames(group, context, 'era')), + factionNames: mergeActiveNames(activeScope?.factionNames, collectScopedNames(group, context, 'faction')), + availabilityFromNames: mergeActiveNames(activeScope?.availabilityFromNames, collectScopedNames(group, context, 'availabilityFrom')), + availabilityRarityNames: activeScope?.availabilityRarityNames, + }; +} + /** * Evaluate a single filter config against a unit. * Returns true if the unit matches the filter config. @@ -1784,7 +1790,7 @@ function buildIndexedASSpecialCandidateSet( values: string[], context: EvaluatorContext, activeScope?: AvailabilityFilterScope, -): Set | null { +): Set | null { if (operator === '!=' || (operator !== '=' && operator !== '==' && operator !== '&=')) { return null; } @@ -1806,7 +1812,7 @@ function buildIndexedCandidateSetForConfig( values: string[], context: EvaluatorContext, activeScope?: AvailabilityFilterScope, -): Set | null { +): Set | null { if (!context.getIndexedUnitIds || !context.getIndexedFilterValues) { return null; } @@ -1828,7 +1834,7 @@ function buildIndexedCandidateSetForConfig( if (conf.external && operator !== '!=') { const indexedResult = getIndexedExternalFilterResult(context, conf.key, operator, values, activeScope); if (indexedResult && indexedResult.mode === 'match') { - return new Set(indexedResult.unitIds); + return new Set(indexedResult.unitIds); } } @@ -1841,7 +1847,7 @@ function buildIndexedCandidateSetForConfig( return null; } - const addStoredValueUnits = (storedValue: string, target: Set): void => { + const addStoredValueUnits = (storedValue: string, target: Set): void => { const unitIds = context.getIndexedUnitIds?.(conf.key, storedValue, activeScope); if (!unitIds) { return; @@ -1852,7 +1858,7 @@ function buildIndexedCandidateSetForConfig( }; if (operator === '=' || operator === '==') { - const candidateIds = new Set(); + const candidateIds = new Set(); for (const value of values) { for (const storedValue of matchIndexedStoredValues(conf.key, value, context)) { addStoredValueUnits(storedValue, candidateIds); @@ -1862,9 +1868,9 @@ function buildIndexedCandidateSetForConfig( } if (operator === '&=') { - let candidateIds: Set | null = null; + let candidateIds: Set | null = null; for (const value of values) { - const valueCandidateIds = new Set(); + const valueCandidateIds = new Set(); for (const storedValue of matchIndexedStoredValues(conf.key, value, context)) { addStoredValueUnits(storedValue, valueCandidateIds); } @@ -1880,7 +1886,7 @@ function buildIndexedCandidateSetForConfig( } } } - return candidateIds ?? new Set(); + return candidateIds ?? new Set(); } return null; @@ -1892,7 +1898,7 @@ function buildIndexedBooleanCandidateSet( values: string[], context: EvaluatorContext, activeScope?: AvailabilityFilterScope, -): Set | null { +): Set | null { if (operator === '&=') { return null; } @@ -1923,7 +1929,7 @@ function buildIndexedBooleanCandidateSet( } } - const candidateIds = new Set(); + const candidateIds = new Set(); for (const targetValue of targetValues) { const indexedIds = context.getIndexedUnitIds?.(conf.key, targetValue ? 'yes' : 'no', activeScope); if (!indexedIds) { @@ -1942,7 +1948,7 @@ function getIndexedCandidateIdsForFilter( filter: SemanticToken, context: EvaluatorContext, activeScope?: AvailabilityFilterScope, -): Set | null { +): Set | null { const matchingFilters = ADVANCED_FILTERS.filter(f => (f.semanticKey || f.key).toLowerCase() === filter.field.toLowerCase() ); @@ -1961,7 +1967,7 @@ function getIndexedCandidateIdsForFilter( for (const f of gameAgnostic) sortedFilters.push(f); for (const f of otherGame) sortedFilters.push(f); - const candidateSets: Set[] = []; + const candidateSets: Set[] = []; for (const conf of sortedFilters) { const candidateSet = buildIndexedCandidateSetForConfig(conf, filter.operator, filter.values, context, activeScope); if (!candidateSet) { @@ -1970,7 +1976,7 @@ function getIndexedCandidateIdsForFilter( candidateSets.push(candidateSet); } - const combined = new Set(); + const combined = new Set(); for (const candidateSet of candidateSets) { for (const unitId of candidateSet) { combined.add(unitId); @@ -1983,7 +1989,7 @@ function getIndexedCandidateIdsForNode( node: ASTNode, context: EvaluatorContext, activeScope?: AvailabilityFilterScope, -): Set | null { +): Set | null { switch (node.type) { case 'text': return null; @@ -1995,21 +2001,16 @@ function getIndexedCandidateIdsForNode( } if (node.operator === 'AND') { - const nextActiveScope: AvailabilityFilterScope = { - bridgeThroughMulMembership: activeScope?.bridgeThroughMulMembership, - eraNames: mergeActiveNames(activeScope?.eraNames, collectScopedNames(node, context, 'era')), - factionNames: mergeActiveNames(activeScope?.factionNames, collectScopedNames(node, context, 'faction')), - availabilityFromNames: mergeActiveNames(activeScope?.availabilityFromNames, collectScopedNames(node, context, 'availabilityFrom')), - }; + const nextActiveScope = getAndGroupAvailabilityScope(node, context, activeScope); const childCandidates = node.children .map(child => getIndexedCandidateIdsForNode(child, context, nextActiveScope)) - .filter((candidate): candidate is Set => candidate !== null); + .filter((candidate): candidate is Set => candidate !== null); if (childCandidates.length === 0) { return null; } - const intersection = new Set(childCandidates[0]); + const intersection = new Set(childCandidates[0]); for (let index = 1; index < childCandidates.length; index++) { const candidateSet = childCandidates[index]; for (const unitId of Array.from(intersection)) { @@ -2021,7 +2022,7 @@ function getIndexedCandidateIdsForNode( return intersection; } - const branchCandidates: Set[] = []; + const branchCandidates: Set[] = []; for (const child of node.children) { const candidateSet = getIndexedCandidateIdsForNode(child, context, activeScope); if (!candidateSet) { @@ -2030,7 +2031,7 @@ function getIndexedCandidateIdsForNode( branchCandidates.push(candidateSet); } - const union = new Set(); + const union = new Set(); for (const candidateSet of branchCandidates) { for (const unitId of candidateSet) { union.add(unitId); @@ -2538,12 +2539,7 @@ function evaluateGroup( if (group.children.length === 0) return true; if (group.operator === 'AND') { - const nextActiveScope: AvailabilityFilterScope = { - bridgeThroughMulMembership: activeScope?.bridgeThroughMulMembership, - eraNames: mergeActiveNames(activeScope?.eraNames, collectScopedNames(group, context, 'era')), - factionNames: mergeActiveNames(activeScope?.factionNames, collectScopedNames(group, context, 'faction')), - availabilityFromNames: mergeActiveNames(activeScope?.availabilityFromNames, collectScopedNames(group, context, 'availabilityFrom')), - }; + const nextActiveScope = getAndGroupAvailabilityScope(group, context, activeScope); // All children must match return group.children.every(child => evaluateASTNode(child, unit, context, nextActiveScope)); } else { @@ -2559,7 +2555,8 @@ function evaluateGroup( export function filterUnitsWithAST( units: any[], ast: GroupASTNode, - context: EvaluatorContext + context: EvaluatorContext, + initialScope?: AvailabilityFilterScope, ): any[] { // If AST has no children, return all units if (ast.children.length === 0) return units; @@ -2571,7 +2568,7 @@ export function filterUnitsWithAST( let candidateUnits = units; if (context.getIndexedUnitIds && context.getIndexedFilterValues) { - const candidateIds = getIndexedCandidateIdsForNode(ast, context); + const candidateIds = getIndexedCandidateIdsForNode(ast, context, initialScope); if (candidateIds) { candidateUnits = units.filter(unit => { const unitId = context.getUnitId(unit); @@ -2580,7 +2577,7 @@ export function filterUnitsWithAST( } } - return candidateUnits.filter(unit => evaluateASTNode(ast, unit, context)); + return candidateUnits.filter(unit => evaluateASTNode(ast, unit, context, initialScope)); } /** @@ -2613,15 +2610,17 @@ function hasTextNodes(node: ASTNode): boolean { export function getMatchingTextForUnit( ast: GroupASTNode, unit: any, - context: EvaluatorContext + context: EvaluatorContext, + initialScope?: AvailabilityFilterScope, ): string[] { - return collectMatchingText(ast, unit, context); + return collectMatchingText(ast, unit, context, initialScope); } function collectMatchingText( node: ASTNode, unit: any, - context: EvaluatorContext + context: EvaluatorContext, + activeScope?: AvailabilityFilterScope, ): string[] { if (node.type === 'text') { // Check if this text node matches the unit (use unescaped value for matching) @@ -2638,17 +2637,18 @@ function collectMatchingText( if (node.type === 'group') { if (node.operator === 'AND') { + const nextActiveScope = getAndGroupAvailabilityScope(node, context, activeScope); // For AND, collect all matching text from all children const texts: string[] = []; for (const child of node.children) { - texts.push(...collectMatchingText(child, unit, context)); + texts.push(...collectMatchingText(child, unit, context, nextActiveScope)); } return texts; } else { // For OR, find the first matching child and return its text for (const child of node.children) { - if (evaluateASTNode(child, unit, context)) { - return collectMatchingText(child, unit, context); + if (evaluateASTNode(child, unit, context, activeScope)) { + return collectMatchingText(child, unit, context, activeScope); } } return []; diff --git a/src/app/utils/unit-filter-kernel.util.ts b/src/app/utils/unit-filter-kernel.util.ts index 958560bed..f52b6eeb9 100644 --- a/src/app/utils/unit-filter-kernel.util.ts +++ b/src/app/utils/unit-filter-kernel.util.ts @@ -48,7 +48,7 @@ export interface UnitFilterKernelDependencies { getForcePackLookupSet: (packName: string) => ReadonlySet | undefined; getAvailabilityLookupKey: (unit: UnitSummary) => string; getIndexedUnitIds?: (filterKey: string, value: string) => ReadonlySet | undefined; - getIndexedASSpecials?: (unitName: string) => ParsedASSpecials | undefined; + getIndexedASSpecials?: (unitUuid: string) => ParsedASSpecials | undefined; } interface ApplyUnitFilterStateRequest { @@ -322,12 +322,12 @@ export function applyFilterStateToUnits(request: ApplyUnitFilterStateRequest): U ) : null; if (indexedCandidates) { - results = results.filter(unit => indexedCandidates.has(unit.name)); + results = results.filter(unit => indexedCandidates.has(unit.uuid)); } results = results.filter(unit => unitMatchesASSpecialSelections( dependencies.getProperty(unit, conf.key), specialSelections, - dependencies.getIndexedASSpecials?.(unit.name), + dependencies.getIndexedASSpecials?.(unit.uuid), )); continue; } diff --git a/src/app/utils/unit-search-adv-options.util.ts b/src/app/utils/unit-search-adv-options.util.ts index be2901cc1..9001e0707 100644 --- a/src/app/utils/unit-search-adv-options.util.ts +++ b/src/app/utils/unit-search-adv-options.util.ts @@ -31,7 +31,7 @@ export function getAdvOptionsContextSnapshot( export function getSnapshotUnitIds(snapshot: AdvOptionsContextSnapshot, units: UnitSummary[]): Set { if (!snapshot.unitIds) { - snapshot.unitIds = new Set(units.map(unit => unit.name)); + snapshot.unitIds = new Set(units.map(unit => unit.uuid)); } return snapshot.unitIds; } @@ -143,4 +143,4 @@ export function getSnapshotCountableValues( } return counts; -} \ No newline at end of file +} diff --git a/src/app/utils/unit-search-executor.util.spec.ts b/src/app/utils/unit-search-executor.util.spec.ts index 087710238..1900458e9 100644 --- a/src/app/utils/unit-search-executor.util.spec.ts +++ b/src/app/utils/unit-search-executor.util.spec.ts @@ -85,7 +85,7 @@ describe('unit-search-executor', () => { }); expect(execution.results).toEqual([unit]); - expect(execution.normalizationMatchesByUnitName.size).toBe(0); + expect(execution.normalizationMatchesByUnitUuid.size).toBe(0); }); it('normalizes Alpha Strike results and excludes units outside the target PV range', () => { @@ -121,7 +121,7 @@ describe('unit-search-executor', () => { }); expect(execution.results.map(unit => unit.name)).toEqual(['Matching']); - expect(execution.normalizationMatchesByUnitName.get('Matching')).toEqual({ + expect(execution.normalizationMatchesByUnitUuid.get(matching.uuid)).toEqual({ kind: 'pv', adjustedValue: 18, skill: 5, @@ -217,7 +217,7 @@ describe('unit-search-executor', () => { unitBelongsToForcePack: () => false, getAllEraNames: () => [], getAllFactionNames: () => [], - getIndexedASSpecials: unitId => unitId === unit.name ? indexedSpecials : undefined, + getIndexedASSpecials: unitUuid => unitUuid === unit.uuid ? indexedSpecials : undefined, }); expect(execution.results.map(result => result.name)).toEqual(['Indexed AC']); @@ -233,15 +233,15 @@ describe('unit-search-executor', () => { as: { ...createEmptyUnit().as, specials: ['TAG'] }, }); const parsedByUnit = new Map([ - [matching.name, parseASSpecials(matching.as.specials)], - [unrelated.name, parseASSpecials(unrelated.as.specials)], + [matching.uuid, parseASSpecials(matching.as.specials)], + [unrelated.uuid, parseASSpecials(unrelated.as.specials)], ]); const getIndexedUnitIds = jasmine.createSpy('getIndexedUnitIds') .and.callFake((_filterKey: string, token: string) => ( - token === 'AC' ? new Set([matching.name]) : undefined + token === 'AC' ? new Set([matching.uuid]) : undefined )); const getIndexedASSpecials = jasmine.createSpy('getIndexedASSpecials') - .and.callFake((unitName: string) => parsedByUnit.get(unitName)); + .and.callFake((unitUuid: string) => parsedByUnit.get(unitUuid)); const results = applyFilterStateToUnits({ units: [matching, unrelated], @@ -275,7 +275,7 @@ describe('unit-search-executor', () => { expect(results).toEqual([matching]); expect(getIndexedUnitIds).toHaveBeenCalledOnceWith('as.specials', 'AC'); - expect(getIndexedASSpecials).toHaveBeenCalledOnceWith(matching.name); + expect(getIndexedASSpecials).toHaveBeenCalledOnceWith(matching.uuid); }); it('evaluates selected weapon types independently for OR and AND queries', () => { diff --git a/src/app/utils/unit-search-executor.util.ts b/src/app/utils/unit-search-executor.util.ts index d90c1df64..2481deecb 100644 --- a/src/app/utils/unit-search-executor.util.ts +++ b/src/app/utils/unit-search-executor.util.ts @@ -30,6 +30,7 @@ export interface UnitSearchExecutionRequest { searchTokens: SearchTokensGroup[]; uiOnlyFilterState?: FilterState; uiOnlyFilterDependencies?: UnitFilterKernelDependencies; + initialAvailabilityScope?: AvailabilityFilterScope; gameSystem: GameSystem; sortKey: string; sortDirection: 'asc' | 'desc'; @@ -52,14 +53,14 @@ export interface UnitSearchExecutionRequest { getDisplayName?: (filterKey: string, value: string) => string | undefined; getIndexedUnitIds?: (filterKey: string, value: string, scope?: AvailabilityFilterScope) => ReadonlySet | undefined; getIndexedFilterValues?: (filterKey: string) => readonly string[]; - getIndexedASSpecials?: (unitId: string) => ParsedASSpecials | undefined; + getIndexedASSpecials?: (unitUuid: string) => ParsedASSpecials | undefined; availabilitySortScope?: AvailabilityFilterScope; getMegaMekRaritySortScore?: (unit: UnitSummary, scope?: AvailabilityFilterScope) => number; } export interface UnitSearchExecutionResult { results: UnitSummary[]; - normalizationMatchesByUnitName: ReadonlyMap; + normalizationMatchesByUnitUuid: ReadonlyMap; telemetryStages: SearchTelemetryStage[]; totalMs: number; unitCount: number; @@ -138,14 +139,14 @@ export function executeUnitSearch(request: UnitSearchExecutionRequest): UnitSear if (!normalizationEnabled) { return null; } - if (!normalizationMatchCache.has(unit.name)) { - normalizationMatchCache.set(unit.name, normalization?.kind === 'bv' + if (!normalizationMatchCache.has(unit.uuid)) { + normalizationMatchCache.set(unit.uuid, normalization?.kind === 'bv' ? findBvNormalizationMatch(unit, normalization.settings) : normalization?.kind === 'pv' ? findPvNormalizationMatch(unit, normalization.settings) : null); } - return normalizationMatchCache.get(unit.name) ?? null; + return normalizationMatchCache.get(unit.uuid) ?? null; }; const getContextualAdjustedBV = (unit: UnitSummary): number => { return resolveNormalizationMatch(unit)?.adjustedValue ?? request.getAdjustedBV(unit); @@ -156,7 +157,7 @@ export function executeUnitSearch(request: UnitSearchExecutionRequest): UnitSear const context: EvaluatorContext = { getProperty, - getUnitId: (unit: UnitSummary) => unit.name, + getUnitId: (unit: UnitSummary) => unit.uuid, getAdjustedBV: getContextualAdjustedBV, getAdjustedPV: getContextualAdjustedPV, gameSystem: request.gameSystem, @@ -222,7 +223,7 @@ export function executeUnitSearch(request: UnitSearchExecutionRequest): UnitSear telemetryStages, 'ast-filter', candidateUnits.length, - () => filterUnitsWithAST(candidateUnits, parsedQuery.ast, context), + () => filterUnitsWithAST(candidateUnits, parsedQuery.ast, context, request.initialAvailabilityScope), value => value.length, ); @@ -277,7 +278,12 @@ export function executeUnitSearch(request: UnitSearchExecutionRequest): UnitSear const model = (unit.model ?? '').toLowerCase(); if (isComplex) { - const matchingTexts = getMatchingTextForUnit(parsedQuery.ast, unit, context); + const matchingTexts = getMatchingTextForUnit( + parsedQuery.ast, + unit, + context, + request.initialAvailabilityScope, + ); if (matchingTexts.length > 0) { let bestScore = 0; for (const text of matchingTexts) { @@ -361,19 +367,19 @@ export function executeUnitSearch(request: UnitSearchExecutionRequest): UnitSear value => value.length, ); - const normalizationMatchesByUnitName = new Map(); + const normalizationMatchesByUnitUuid = new Map(); if (normalizationEnabled) { for (const unit of sorted) { const match = resolveNormalizationMatch(unit); if (match) { - normalizationMatchesByUnitName.set(unit.name, match); + normalizationMatchesByUnitUuid.set(unit.uuid, match); } } } return { results: sorted, - normalizationMatchesByUnitName, + normalizationMatchesByUnitUuid, telemetryStages, totalMs: getNowMs() - searchStartedAt, unitCount, diff --git a/src/app/utils/unit-search-worker-protocol.util.ts b/src/app/utils/unit-search-worker-protocol.util.ts index 05c9b2a86..7c2ffb617 100644 --- a/src/app/utils/unit-search-worker-protocol.util.ts +++ b/src/app/utils/unit-search-worker-protocol.util.ts @@ -10,12 +10,14 @@ export type UnitSearchWorkerCorpusVersion = string; export interface UnitSearchWorkerIndexSnapshot { [filterKey: string]: { + /** Unit UUIDs. */ [value: string]: string[]; }; } export interface UnitSearchWorkerFactionEraSnapshot { [eraName: string]: { + /** Unit UUIDs. */ [factionName: string]: string[]; }; } @@ -43,7 +45,7 @@ export interface UnitSearchWorkerQueryRequest { } export interface UnitSearchWorkerResultEntry { - unitName: string; + unitUuid: string; match?: UnitSearchNormalizationMatch; } @@ -88,4 +90,4 @@ export type UnitSearchWorkerRequestMessage = export type UnitSearchWorkerResponseMessage = | UnitSearchWorkerReadyMessage | UnitSearchWorkerResultMessage - | UnitSearchWorkerErrorMessage; \ No newline at end of file + | UnitSearchWorkerErrorMessage; diff --git a/src/app/utils/unit-search-worker-request.util.spec.ts b/src/app/utils/unit-search-worker-request.util.spec.ts index bc9d059e3..9fc0ab3b9 100644 --- a/src/app/utils/unit-search-worker-request.util.spec.ts +++ b/src/app/utils/unit-search-worker-request.util.spec.ts @@ -203,6 +203,23 @@ describe('buildWorkerExecutionQuery', () => { }), ]); }); + + it('groups a preserved complex query before applying UI filters', () => { + const executionQuery = buildWorkerExecutionQuery({ + effectiveFilterState: { + era: { value: ['Clan Invasion'], interactedWith: true }, + }, + effectiveTextSearch: 'Atlas', + preservedQuery: 'faction=="Clan Coyote" Atlas OR faction="Federated Suns"', + gameSystem: GameSystem.CLASSIC, + totalRangesCache: {}, + }); + + expect(executionQuery).toBe( + '(faction=="Clan Coyote" Atlas OR faction="Federated Suns") era="Clan Invasion"', + ); + expect(parseSemanticQueryAST(executionQuery, GameSystem.CLASSIC).errors).toEqual([]); + }); }); describe('getWorkerCorpusSnapshot', () => { diff --git a/src/app/utils/unit-search-worker-request.util.ts b/src/app/utils/unit-search-worker-request.util.ts index 60e321f70..0887789be 100644 --- a/src/app/utils/unit-search-worker-request.util.ts +++ b/src/app/utils/unit-search-worker-request.util.ts @@ -24,6 +24,8 @@ interface BuildWorkerExecutionQueryArgs { effectiveTextSearch: string; /** Original committed clauses; preserving these avoids flattening repeated constraints. */ semanticTokenTexts?: readonly string[]; + /** Raw grouped query to preserve before applying UI-only filters. */ + preservedQuery?: string; gameSystem: GameSystem; totalRangesCache: Record; } @@ -84,16 +86,22 @@ export function buildWorkerExecutionQuery({ effectiveFilterState, effectiveTextSearch, semanticTokenTexts = [], + preservedQuery, gameSystem, totalRangesCache, }: BuildWorkerExecutionQueryArgs): string { + const groupedQuery = preservedQuery?.trim(); const uiFilterText = filterStateToSemanticText( effectiveFilterState, - escapePlainTextForWorkerExecutionQuery(effectiveTextSearch), + groupedQuery ? '' : escapePlainTextForWorkerExecutionQuery(effectiveTextSearch), gameSystem, totalRangesCache, ).trim(); + if (groupedQuery) { + return uiFilterText ? `(${groupedQuery}) ${uiFilterText}` : groupedQuery; + } + return [uiFilterText, ...semanticTokenTexts] .map(part => part.trim()) .filter(Boolean) diff --git a/src/app/utils/unit-search-worker-result.util.spec.ts b/src/app/utils/unit-search-worker-result.util.spec.ts index 3d0cb2dbd..1339a07f9 100644 --- a/src/app/utils/unit-search-worker-result.util.spec.ts +++ b/src/app/utils/unit-search-worker-result.util.spec.ts @@ -21,47 +21,59 @@ function createResult(entries: UnitSearchWorkerResultMessage['entries']): UnitSe } describe('hydrateWorkerSearchResult', () => { - const alpha = { name: 'Alpha' } as UnitSummary; - const beta = { name: 'Beta' } as UnitSummary; - const units = new Map([[alpha.name, alpha], [beta.name, beta]]); + const alpha = { uuid: 'alpha-uuid', name: 'Alpha' } as UnitSummary; + const beta = { uuid: 'beta-uuid', name: 'Beta' } as UnitSummary; + const units = new Map([[alpha.uuid, alpha], [beta.uuid, beta]]); it('hydrates known units and their matching normalization metadata atomically', () => { const match = { kind: 'bv' as const, adjustedValue: 1995, gunnery: 3, piloting: 4 }; const hydrated = hydrateWorkerSearchResult( - createResult([{ unitName: 'Alpha', match }, { unitName: 'Beta' }]), - name => units.get(name), + createResult([{ unitUuid: alpha.uuid, match }, { unitUuid: beta.uuid }]), + uuid => units.get(uuid), ); expect(hydrated.units).toEqual([alpha, beta]); - expect(hydrated.normalizationMatchesByUnitName.get('Alpha')).toEqual(match); - expect(hydrated.normalizationMatchesByUnitName.has('Beta')).toBeFalse(); + expect(hydrated.normalizationMatchesByUnitUuid.get(alpha.uuid)).toEqual(match); + expect(hydrated.normalizationMatchesByUnitUuid.has(beta.uuid)).toBeFalse(); }); it('drops unknown units together with their metadata and preserves known ordering', () => { const hydrated = hydrateWorkerSearchResult( createResult([ - { unitName: 'Missing', match: { kind: 'bv', adjustedValue: 1, gunnery: 4, piloting: 5 } }, - { unitName: 'Beta' }, - { unitName: 'Alpha' }, + { unitUuid: 'missing-uuid', match: { kind: 'bv', adjustedValue: 1, gunnery: 4, piloting: 5 } }, + { unitUuid: beta.uuid }, + { unitUuid: alpha.uuid }, ]), - name => units.get(name), + uuid => units.get(uuid), ); expect(hydrated.units).toEqual([beta, alpha]); - expect(hydrated.normalizationMatchesByUnitName.size).toBe(0); + expect(hydrated.normalizationMatchesByUnitUuid.size).toBe(0); }); it('keeps only the first duplicate entry to avoid metadata drift', () => { const firstMatch = { kind: 'bv' as const, adjustedValue: 1900, gunnery: 4, piloting: 4 }; const hydrated = hydrateWorkerSearchResult( createResult([ - { unitName: 'Alpha', match: firstMatch }, - { unitName: 'Alpha', match: { kind: 'bv', adjustedValue: 2000, gunnery: 3, piloting: 4 } }, + { unitUuid: alpha.uuid, match: firstMatch }, + { unitUuid: alpha.uuid, match: { kind: 'bv', adjustedValue: 2000, gunnery: 3, piloting: 4 } }, ]), - name => units.get(name), + uuid => units.get(uuid), ); expect(hydrated.units).toEqual([alpha]); - expect(hydrated.normalizationMatchesByUnitName.get('Alpha')).toEqual(firstMatch); + expect(hydrated.normalizationMatchesByUnitUuid.get(alpha.uuid)).toEqual(firstMatch); + }); + + it('keeps distinct UUIDs even when display names collide', () => { + const duplicateName = { uuid: 'duplicate-uuid', name: alpha.name } as UnitSummary; + const unitsByUuid = new Map([[alpha.uuid, alpha], [duplicateName.uuid, duplicateName]]); + + const hydrated = hydrateWorkerSearchResult( + createResult([{ unitUuid: alpha.uuid }, { unitUuid: duplicateName.uuid }]), + uuid => unitsByUuid.get(uuid), + ); + + expect(hydrated.units).toEqual([alpha, duplicateName]); }); }); diff --git a/src/app/utils/unit-search-worker-result.util.ts b/src/app/utils/unit-search-worker-result.util.ts index 3571ecfca..7ab9bbb92 100644 --- a/src/app/utils/unit-search-worker-result.util.ts +++ b/src/app/utils/unit-search-worker-result.util.ts @@ -20,41 +20,41 @@ interface WorkerResultTelemetryContext { export interface HydratedWorkerSearchResult { units: UnitSummary[]; - normalizationMatchesByUnitName: ReadonlyMap; + normalizationMatchesByUnitUuid: ReadonlyMap; } export function hydrateWorkerSearchResult( result: UnitSearchWorkerResultMessage, - getUnitByName: (unitName: string) => UnitSummary | undefined, + getUnitByUuid: (unitUuid: string) => UnitSummary | undefined, ): HydratedWorkerSearchResult { const units: UnitSummary[] = []; - const normalizationMatchesByUnitName = new Map(); - const seenUnitNames = new Set(); + const normalizationMatchesByUnitUuid = new Map(); + const seenUnitUuids = new Set(); for (const entry of result.entries) { - if (seenUnitNames.has(entry.unitName)) { + if (seenUnitUuids.has(entry.unitUuid)) { continue; } - const unit = getUnitByName(entry.unitName); + const unit = getUnitByUuid(entry.unitUuid); if (!unit) { continue; } - seenUnitNames.add(entry.unitName); + seenUnitUuids.add(entry.unitUuid); units.push(unit); if (entry.match) { - normalizationMatchesByUnitName.set(entry.unitName, entry.match); + normalizationMatchesByUnitUuid.set(entry.unitUuid, entry.match); } } - return { units, normalizationMatchesByUnitName }; + return { units, normalizationMatchesByUnitUuid }; } export function hydrateWorkerResultUnits( result: UnitSearchWorkerResultMessage, - getUnitByName: (unitName: string) => UnitSummary | undefined, + getUnitByUuid: (unitUuid: string) => UnitSummary | undefined, ): UnitSummary[] { - return hydrateWorkerSearchResult(result, getUnitByName).units; + return hydrateWorkerSearchResult(result, getUnitByUuid).units; } export function buildWorkerSearchTelemetrySnapshot( @@ -73,4 +73,4 @@ export function buildWorkerSearchTelemetrySnapshot( stages: context.stages ?? result.stages, totalMs: context.totalMs ?? result.totalMs, }; -} \ No newline at end of file +} From e11f8a077a6e32d2948e17ebd730454f07b8bd7f Mon Sep 17 00:00:00 2001 From: exeea Date: Sun, 30 Aug 2026 23:00:56 +0200 Subject: [PATCH 85/87] test fix --- src/app/directives/tooltip.directive.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/directives/tooltip.directive.spec.ts b/src/app/directives/tooltip.directive.spec.ts index 0594fdef2..8a8f8ebc9 100644 --- a/src/app/directives/tooltip.directive.spec.ts +++ b/src/app/directives/tooltip.directive.spec.ts @@ -10,6 +10,8 @@ import { TooltipDirective } from './tooltip.directive'; @Component({ standalone: true, imports: [TooltipDirective], + // Keep a real mouse pointer in the Karma page from affecting synthetic pointer tests. + host: { style: 'pointer-events: none' }, template: `
    Parent From dd2ae156253f52775b34e956440a19c78e5cc180 Mon Sep 17 00:00:00 2001 From: exeea Date: Mon, 31 Aug 2026 20:06:19 +0200 Subject: [PATCH 86/87] phase and turn buttons --- .../page-interaction-overlay.component.html | 5 +- .../page-interaction-overlay.component.scss | 11 ++ .../page-interaction-overlay.component.ts | 16 ++- .../page-turn-summary-panel.component.html | 42 ++++++- .../page-turn-summary-panel.component.scss | 48 +++++++ .../page-turn-summary-panel.component.spec.ts | 117 ++++++++++++++++++ .../page-turn-summary-panel.component.ts | 46 ++++++- .../overlay/page-turn-summary.util.spec.ts | 25 +++- .../overlay/page-turn-summary.util.ts | 15 +++ 9 files changed, 312 insertions(+), 13 deletions(-) diff --git a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.html b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.html index 17cd9dd6f..635af8427 100644 --- a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.html +++ b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.html @@ -2,7 +2,10 @@
    } diff --git a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.scss b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.scss index 63de691f2..81db423a3 100644 --- a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.scss +++ b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.scss @@ -103,6 +103,11 @@ font-weight: bold; transition: background-color 0.2s; font-size: 0.9em; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 4px; &.end-phase-button { background-color: #a00; @@ -115,6 +120,12 @@ } } +.end-phase-icon { + width: 20px; + height: 20px; + flex: 0 0 auto; +} + .turn-tracker-button { --turn-movement-fill: var(--move-unassigned); --turn-movement-foreground: var(--move-on-dark); diff --git a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.ts b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.ts index 27c6c1186..43dfcac7c 100644 --- a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.ts +++ b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.ts @@ -39,6 +39,7 @@ import { } from '../../unit-notification-badges/unit-notification-badges.component'; import { WeaponTargetsOverlayController } from '../../equipment-dialog/weapon-targets-overlay.controller'; import { getTurnMovementIndicator } from '../../../utils/turn-movement-indicator.util'; +import { runWithTurnSummaryCloseBlocked } from './page-turn-summary.util'; const PAGE_TARGETS_OVERLAY_PREFIX = 'page-viewer-targets'; @@ -298,11 +299,16 @@ export class PageInteractionOverlayComponent { async endTurnForAll() { const force = this.force(); - if (!force) return; - const confirm = await this.dialogsService.requestConfirmation( - 'Are you sure you want to end the turn for all units?', - 'End Turn', - 'info' + const unitId = this.unit()?.id; + if (!force || !unitId) return; + const confirm = await runWithTurnSummaryCloseBlocked( + this.overlayManager, + unitId, + () => this.dialogsService.requestConfirmation( + 'Are you sure you want to end the turn for all units?', + 'End Turn', + 'info' + ) ); if (!confirm) return; const units = force.units(); diff --git a/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.html b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.html index 19d68e703..fb986c631 100644 --- a/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.html +++ b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.html @@ -278,11 +278,43 @@

    {{ controlRollShortLabel() }} Modifiers

    } } - @if (dirty()) { - - } - @if (endTurnForAllButtonVisible()) { - + @if (phaseDirty() || endPhaseForAllButtonVisible()) { +
    + @if (phaseDirty()) { + + } + @if (endPhaseForAllButtonVisible()) { + + } +
    } +
    + @if (dirty()) { + + } + @if (endTurnForAllButtonVisible()) { + + } +
    diff --git a/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.scss b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.scss index 0fc2c2eec..bb74c5853 100644 --- a/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.scss +++ b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.scss @@ -33,6 +33,13 @@ white-space: nowrap; } +.phase-actions, +.turn-actions { + display: flex; + flex-direction: row; + gap: 2px; +} + .section-title { margin: 0; font-size: 1em; @@ -74,6 +81,47 @@ transition: none; } +.phase-button, +.turn-action { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + flex: 1 1 0%; + min-width: 0; +} + +.phase-button { + cursor: pointer; + opacity: 1; + margin-inline: auto; + width: fit-content; + border: 0; + padding: 8px; + text-align: center; + font-weight: bold; + transition: background-color 0.2s; + font-size: 0.9em; + flex-direction: row; + background-color: #a00; + color: #fff; + + span { + line-height: 1.1; + white-space: normal; + } + + &:hover { + background-color: #f00; + } +} + +.turn-action-icon { + width: 20px; + height: 20px; + flex: 0 0 auto; +} + .turn-summary.render-ready .bt-button { transition: border 0.2s ease-in-out, background 0.2s ease-in-out, color 0.2s ease-in-out; } diff --git a/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.spec.ts b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.spec.ts index aec857f75..44e049597 100644 --- a/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.spec.ts +++ b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.spec.ts @@ -8,6 +8,8 @@ import { Overlay } from '@angular/cdk/overlay'; import { Subject } from 'rxjs'; import { CORE_2026_GAME_RULES, TW_GAME_RULES } from '../../../models/rules/game-rules'; import { DataService } from '../../../services/data.service'; +import { CBTEndTurnService } from '../../../services/cbt-end-turn.service'; +import { CBTPhaseResolutionService } from '../../../services/cbt-phase-resolution.service'; import { DialogsService } from '../../../services/dialogs.service'; import { EquipmentInteractionRegistryService } from '../../../services/equipment-interaction-registry.service'; import { OverlayManagerService } from '../../../services/overlay-manager.service'; @@ -71,6 +73,7 @@ describe('PageTurnSummaryPanelComponent', () => { const component = fixture.componentInstance; Object.assign(component, { dirty: () => false, + phaseDirty: () => false, damageReceived: () => 0, hasPSRChecks: () => false, falling: () => false, @@ -262,4 +265,118 @@ describe('PageTurnSummaryPanelComponent', () => { expect(overlayManager.unblockClose).toHaveBeenCalledOnceWith('turnSummary-unit-1'); expect(overlayManager.closeManagedOverlay).not.toHaveBeenCalledWith('turnSummary-unit-1'); }); + + it('shows phase actions for dirty phase state and resolves the selected scope', async () => { + const currentDirty = signal(false); + const otherDirty = signal(false); + const currentUnit = { + id: 'unit-a', + turnState: () => ({ + dirty: currentDirty, + dirtyPhase: currentDirty, + }), + }; + const otherUnit = { + id: 'unit-b', + turnState: () => ({ + dirty: otherDirty, + dirtyPhase: otherDirty, + }), + }; + const force = { units: () => [currentUnit, otherUnit] }; + const closeManagedOverlay = jasmine.createSpy('closeManagedOverlay'); + const resolvePhase = jasmine.createSpy('endPhase').and.resolveTo(true); + const requestConfirmation = jasmine.createSpy('requestConfirmation').and.resolveTo(true); + + TestBed.configureTestingModule({ + imports: [PageTurnSummaryPanelComponent], + providers: [ + { + provide: PageInteractionOverlayComponent, + useValue: { unit: signal(currentUnit), force: signal(force) }, + }, + { provide: OverlayManagerService, useValue: { closeManagedOverlay } }, + { provide: Overlay, useValue: {} }, + { provide: EquipmentInteractionRegistryService, useValue: { getRegistry: () => ({}) } }, + { provide: ToastService, useValue: {} }, + { provide: DialogsService, useValue: { requestConfirmation } }, + { provide: DataService, useValue: {} }, + { provide: CBTEndTurnService, useValue: {} }, + { provide: CBTPhaseResolutionService, useValue: { endPhase: resolvePhase } }, + ], + }); + const fixture = TestBed.createComponent(PageTurnSummaryPanelComponent); + const component = fixture.componentInstance; + Object.assign(component, { + dirty: () => false, + damageReceived: () => 0, + hasPSRChecks: () => false, + falling: () => false, + PSRChecksCount: () => 0, + controlRollShortLabel: () => 'PSR', + showImmobileStatus: () => false, + showMovementControls: () => true, + canSwitchAirborneMode: () => false, + airborne: () => false, + moveModes: () => [], + onlyStationaryMoveMode: () => false, + currentMoveMode: () => null, + prone: () => false, + canStandUp: () => false, + standAttempts: () => 0, + standUpRequiresPSR: () => false, + equipmentTrackControlRows: () => [], + spotting: () => false, + canSpot: () => false, + spottingModifierLabel: () => null, + defenseTargetModifierTooltip: () => null, + getTotalTargetModifierAsDefender: () => '+0', + cover: () => undefined, + waterDepth: () => '', + buildingLevel: () => '', + coverModifierLabel: () => null, + tracksHeat: () => false, + heatRows: () => [], + psrModifiers: () => [], + }); + + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.phase-actions')).toBeNull(); + + currentDirty.set(true); + fixture.detectChanges(); + const phaseButtons = fixture.nativeElement.querySelectorAll('.phase-actions button'); + expect(phaseButtons.length).toBe(2); + expect(phaseButtons[0].textContent.trim().toLowerCase()).toBe('end phase'); + expect(phaseButtons[1].textContent.trim().toLowerCase()).toBe('all units'); + + const currentEvent = jasmine.createSpyObj('event', ['stopPropagation']); + await component.endPhase(currentEvent); + + expect(currentEvent.stopPropagation).toHaveBeenCalledTimes(1); + expect(closeManagedOverlay).toHaveBeenCalledWith('turnSummary-unit-a'); + expect(resolvePhase).toHaveBeenCalledOnceWith(currentUnit); + + resolvePhase.calls.reset(); + closeManagedOverlay.calls.reset(); + currentDirty.set(false); + otherDirty.set(true); + fixture.detectChanges(); + + const remainingPhaseButtons = fixture.nativeElement.querySelectorAll('.phase-actions button'); + expect(remainingPhaseButtons.length).toBe(1); + expect(remainingPhaseButtons[0].textContent.trim().toLowerCase()).toBe('all units'); + + const allEvent = jasmine.createSpyObj('event', ['stopPropagation']); + await component.endPhaseForAll(allEvent); + + expect(allEvent.stopPropagation).toHaveBeenCalledTimes(1); + expect(requestConfirmation).toHaveBeenCalledOnceWith( + 'Are you sure you want to end the phase for all units?', + 'End Phase', + 'info' + ); + expect(closeManagedOverlay).toHaveBeenCalledWith('turnSummary-unit-a'); + expect(resolvePhase).toHaveBeenCalledOnceWith([currentUnit, otherUnit]); + }); }); diff --git a/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.ts b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.ts index a3e55396b..e0cd7daf1 100644 --- a/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.ts +++ b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.ts @@ -19,12 +19,13 @@ import { DataService } from '../../../services/data.service'; import type { MountedEquipment } from '../../../models/mounted-equipment.model'; import { EscalatingFailureHandler } from '../../../equipment-handlers/escalatingfailure.handler'; import { togglePsrWarningOverlay } from './page-psr-warning-panel.component'; -import { composeTurnSummaryHeatRows, displayPsrModifiers, isMoveModeDisabledWhileProne } from './page-turn-summary.util'; +import { composeTurnSummaryHeatRows, displayPsrModifiers, isMoveModeDisabledWhileProne, runWithTurnSummaryCloseBlocked } from './page-turn-summary.util'; import { orderedModifierTooltipLines } from '../../../utils/hit-target-tooltip.util'; import { toggleStandingUpOverlay } from './page-standing-up-panel.component'; import { isUnitBuildingLevel, isUnitWaterDepth, type UnitCover } from '../../../models/unit-cover.model'; import { CoverLevelPickerComponent } from '../../cover-level-picker/cover-level-picker.component'; import { CBTEndTurnService } from '../../../services/cbt-end-turn.service'; +import { CBTPhaseResolutionService } from '../../../services/cbt-phase-resolution.service'; interface EquipmentTrackControlRow { entry: MountedEquipment; @@ -74,6 +75,7 @@ export class PageTurnSummaryPanelComponent { private readonly dialogsService = inject(DialogsService); private readonly dataService = inject(DataService); private readonly cbtEndTurnService = inject(CBTEndTurnService); + private readonly phaseResolution = inject(CBTPhaseResolutionService); readonly unit = this.parent.unit; readonly force = this.parent.force; readonly endTurnForAllButtonVisible = input(false); @@ -107,6 +109,18 @@ export class PageTurnSummaryPanelComponent { return unit.turnState().dirty(); }); + readonly phaseDirty = computed(() => { + const unit = this.unit(); + if (!unit) return false; + return unit.turnState().dirtyPhase(); + }); + + readonly endPhaseForAllButtonVisible = computed(() => { + const force = this.force(); + if (!force) return false; + return force.units().some(unit => unit.turnState().dirtyPhase()); + }); + readonly damageReceived = computed(() => { const unit = this.unit(); if (!unit) return 0; @@ -310,6 +324,36 @@ export class PageTurnSummaryPanelComponent { if (unit) await this.cbtEndTurnService.endTurn([unit]); } + async endPhase(event: MouseEvent): Promise { + event.stopPropagation(); + const unit = this.unit(); + if (!unit) return; + + this.close(); + await this.phaseResolution.endPhase(unit); + } + + async endPhaseForAll(event: MouseEvent): Promise { + event.stopPropagation(); + const force = this.force(); + const unitId = this.unit()?.id; + if (!force || !unitId) return; + + const confirmed = await runWithTurnSummaryCloseBlocked( + this.overlayManager, + unitId, + () => this.dialogsService.requestConfirmation( + 'Are you sure you want to end the phase for all units?', + 'End Phase', + 'info' + ) + ); + if (!confirmed) return; + + this.close(); + await this.phaseResolution.endPhase(force.units()); + } + openPsrWarning(event: MouseEvent): void { event.stopPropagation(); togglePsrWarningOverlay(this.parent, this.overlayManager, this.injector, this.overlay); diff --git a/src/app/components/page-viewer/overlay/page-turn-summary.util.spec.ts b/src/app/components/page-viewer/overlay/page-turn-summary.util.spec.ts index 6339c3a66..33763fcba 100644 --- a/src/app/components/page-viewer/overlay/page-turn-summary.util.spec.ts +++ b/src/app/components/page-viewer/overlay/page-turn-summary.util.spec.ts @@ -4,7 +4,30 @@ import { Subject } from 'rxjs'; import type { ManagedOverlayRef, OverlayManagerService } from '../../../services/overlay-manager.service'; -import { composeTurnSummaryHeatRows, displayPsrModifiers, isMoveModeDisabledWhileProne, openTurnSummaryChildOverlay } from './page-turn-summary.util'; +import { composeTurnSummaryHeatRows, displayPsrModifiers, isMoveModeDisabledWhileProne, openTurnSummaryChildOverlay, runWithTurnSummaryCloseBlocked } from './page-turn-summary.util'; + +describe('runWithTurnSummaryCloseBlocked', () => { + it('keeps the summary blocked until a dismissed confirmation settles', async () => { + const overlayManager = jasmine.createSpyObj( + 'OverlayManagerService', + ['blockCloseUntil', 'unblockClose'], + ); + let dismiss!: (confirmed: boolean) => void; + const operation = jasmine.createSpy('operation').and.returnValue(new Promise(resolve => { + dismiss = resolve; + })); + + const result = runWithTurnSummaryCloseBlocked(overlayManager, 'unit-1', operation); + + expect(overlayManager.blockCloseUntil).toHaveBeenCalledOnceWith('turnSummary-unit-1'); + expect(overlayManager.unblockClose).not.toHaveBeenCalled(); + + dismiss(false); + + await expectAsync(result).toBeResolvedTo(false); + expect(overlayManager.unblockClose).toHaveBeenCalledOnceWith('turnSummary-unit-1'); + }); +}); describe('openTurnSummaryChildOverlay', () => { it('blocks the parent summary until the child overlay closes', () => { diff --git a/src/app/components/page-viewer/overlay/page-turn-summary.util.ts b/src/app/components/page-viewer/overlay/page-turn-summary.util.ts index 19fde2450..6f8eb347b 100644 --- a/src/app/components/page-viewer/overlay/page-turn-summary.util.ts +++ b/src/app/components/page-viewer/overlay/page-turn-summary.util.ts @@ -18,6 +18,21 @@ export interface TurnSummaryHeatRow { export const TURN_SUMMARY_UNDERWATER_HEAT_SOURCE_ID = 'underwater-dissipation'; +/** Prevents a modal interaction from being mistaken for a click outside the turn summary. */ +export async function runWithTurnSummaryCloseBlocked( + overlayManager: OverlayManagerService, + unitId: string, + operation: () => Promise, +): Promise { + const parentOverlayKey = `turnSummary-${unitId}`; + overlayManager.blockCloseUntil(parentOverlayKey); + try { + return await operation(); + } finally { + overlayManager.unblockClose(parentOverlayKey); + } +} + /** Keeps the summary's capture-phase outside-click handler dormant while a modal child is open. */ export function openTurnSummaryChildOverlay( overlayManager: OverlayManagerService, From b9bde71ab11c8b5d788e0cbf23fc73d412cd6801 Mon Sep 17 00:00:00 2001 From: exeea Date: Wed, 2 Sep 2026 01:09:14 +0200 Subject: [PATCH 87/87] BV Optimizer fix for fixed piloting units --- ...-budget-optimizer-dialog.component.spec.ts | 33 +++++++++++++++++-- ...force-budget-optimizer-dialog.component.ts | 5 +-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/app/components/force-budget-optimizer-dialog/force-budget-optimizer-dialog.component.spec.ts b/src/app/components/force-budget-optimizer-dialog/force-budget-optimizer-dialog.component.spec.ts index 31f9ac21b..aef81b2d8 100644 --- a/src/app/components/force-budget-optimizer-dialog/force-budget-optimizer-dialog.component.spec.ts +++ b/src/app/components/force-budget-optimizer-dialog/force-budget-optimizer-dialog.component.spec.ts @@ -20,6 +20,13 @@ interface ClassicSkillPrioritiesTestApi { interface ForceBudgetOptimizerDialogTestApi { targetBudget(): number; + createCBTOptions(forceUnit: { + getUnit(): UnitSummary; + getBaseBv(): number; + tagBV(): number; + c3Tax(): number; + externalStoresBv(): number; + }): Array<{ gunnery?: number; piloting?: number }>; getCBTSkillPriorities(unit: UnitSummary): ClassicSkillPrioritiesTestApi; getCBTSmartScore(priorities: ClassicSkillPrioritiesTestApi, gunnery: number, piloting: number): number; getPhysicalDamagePerTurn(unit: UnitSummary): number; @@ -34,7 +41,7 @@ interface OptimizationStateTestApi { } describe('ForceBudgetOptimizerDialogComponent', () => { - async function createComponent(forceTotal = 0, bvPvLimit = 0): Promise { + async function createComponent(forceTotal = 0, bvPvLimit = 0, maxDelta = 8): Promise { const force = { gameSystem: GameSystem.CLASSIC, totalBv: jasmine.createSpy('totalBv').and.returnValue(forceTotal), @@ -48,7 +55,7 @@ describe('ForceBudgetOptimizerDialogComponent', () => { gunnery: { min: 2, max: 6 }, piloting: { min: 2, max: 6 }, skill: { min: 2, max: 6 }, - maxDelta: 8, + maxDelta, }, }), setOption: jasmine.createSpy('setOption').and.resolveTo(undefined), @@ -144,6 +151,26 @@ describe('ForceBudgetOptimizerDialogComponent', () => { expect(gunneryFocusedScore).toBeGreaterThan(pilotingFocusedScore); }); + it('ignores max delta for fixed-Piloting units', async () => { + const component = await createComponent(0, 0, 0); + const infantry = createUnit({ + type: 'Infantry', + subtype: 'Conventional Infantry', + canAntiMech: false, + bv: 100, + }); + + const options = component.createCBTOptions({ + getUnit: () => infantry, + getBaseBv: () => 100, + tagBV: () => 0, + c3Tax: () => 0, + externalStoresBv: () => 0, + }); + + expect(options.some(option => option.gunnery === 2 && option.piloting === 8)).toBeTrue(); + }); + it('selects the nearest result without exceeding the target budget', async () => { const component = await createComponent(); const best = component.selectBestAffordableState([ @@ -173,4 +200,4 @@ describe('ForceBudgetOptimizerDialogComponent', () => { choice: null, }; } -}); \ No newline at end of file +}); diff --git a/src/app/components/force-budget-optimizer-dialog/force-budget-optimizer-dialog.component.ts b/src/app/components/force-budget-optimizer-dialog/force-budget-optimizer-dialog.component.ts index 2dc806106..26834437e 100644 --- a/src/app/components/force-budget-optimizer-dialog/force-budget-optimizer-dialog.component.ts +++ b/src/app/components/force-budget-optimizer-dialog/force-budget-optimizer-dialog.component.ts @@ -12,7 +12,7 @@ import type { Force } from '../../models/force.model'; import type { ForceUnit } from '../../models/force-unit.model'; import type { UnitSummary } from '../../models/unit-summary.model'; import { BVCalculatorUtil } from '../../utils/bv-calculator.util'; -import { getEffectivePilotingSkill } from '../../utils/cbt-common.util'; +import { getEffectivePilotingSkill, getFixedPilotingSkill } from '../../utils/cbt-common.util'; import { adjustPointValueForSkill } from '../../utils/pv-skill-adjustment.util'; import { OptionsService } from '../../services/options.service'; import { UnitSearchFiltersService } from '../../services/unit-search-filters.service'; @@ -276,11 +276,12 @@ export class ForceBudgetOptimizerDialogComponent { const [minGunnery, maxGunnery] = this.gunnerySkillRange(); const [minPiloting, maxPiloting] = this.pilotingSkillRange(); const maxDelta = this.maxPilotSkillDelta(); + const fixedPiloting = getFixedPilotingSkill(unit); for (let gunnery = minGunnery; gunnery <= maxGunnery; gunnery += 1) { for (let requestedPiloting = minPiloting; requestedPiloting <= maxPiloting; requestedPiloting += 1) { const piloting = getEffectivePilotingSkill(unit, requestedPiloting); - if (Math.abs(gunnery - piloting) > maxDelta) { + if (fixedPiloting === null && Math.abs(gunnery - piloting) > maxDelta) { continue; } const cost = Math.max(0, BVCalculatorUtil.calculateAdjustedBV(unit, preSkillBv, gunnery, piloting));