diff --git a/package.json b/package.json index db19a6367..b13394ed2 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,9 @@ "prestart": "npm run gen-force-name-words", "start": "ng serve --host 0.0.0.0", "build": "npm run prerun && ng build --configuration=production", + "postbuild": "node scripts/generate-seo-pages.js", "build:next": "npm run prerun && ng build --configuration=next", - "test:ratgenerator": "tsx scripts/ratgenerator_build_table.test.ts", + "postbuild:next": "npm run postbuild", "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", 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/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..5839ccb97 --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,12 @@ + + + + https://mekbay.com/ + + + https://mekbay.com/forcegenerator + + + https://mekbay.com/?expanded=true + + 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/scripts/generate-seo-pages.js b/scripts/generate-seo-pages.js new file mode 100644 index 000000000..bbbe28ccf --- /dev/null +++ b/scripts/generate-seo-pages.js @@ -0,0 +1,59 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +const fs = require('node:fs'); +const path = require('node:path'); + +const outputDirectory = path.resolve(__dirname, '..', 'dist', 'browser'); +const indexPath = path.join(outputDirectory, 'index.html'); + +function replaceRequired(html, pattern, replacement, label, pageName) { + if (!pattern.test(html)) { + throw new Error(`Could not generate ${pageName} SEO page: ${label} was not found in index.html.`); + } + return html.replace(pattern, replacement); +} + +const sourceHtml = fs.readFileSync(indexPath, 'utf8'); +const pages = [ + { + outputFile: 'forcegenerator.html', + pageName: 'Force Generator', + schemaName: 'MekBay BattleTech Force Generator', + title: 'BattleTech Force Generator | MekBay', + description: 'Generate balanced BattleTech forces for Classic and Alpha Strike by faction, era, unit type, Battle Value, or Point Value.', + canonicalUrl: 'https://mekbay.com/forcegenerator', + }, + { + outputFile: 'search.html', + pageName: 'Unit Search', + schemaName: 'MekBay BattleTech Unit Search', + title: 'BattleTech Unit Search | MekBay', + description: 'Search and compare BattleTech units for Classic and Alpha Strike by name, faction, era, role, Battle Value, Point Value, and more.', + canonicalUrl: 'https://mekbay.com/?expanded=true', + }, +]; + +function generateSeoPage(page) { + let html = sourceHtml; + html = replaceRequired(html, /[^<]*<\/title>/, `<title>${page.title}`, 'title', page.pageName); + html = replaceRequired(html, //, ``, 'canonical link', page.pageName); + html = replaceRequired(html, //, ``, 'description', page.pageName); + html = replaceRequired(html, //, ``, 'Open Graph title', page.pageName); + html = replaceRequired(html, //, ``, 'Open Graph description', page.pageName); + html = replaceRequired(html, //, ``, 'Open Graph URL', page.pageName); + html = replaceRequired(html, //, ``, 'Twitter title', page.pageName); + html = replaceRequired(html, //, ``, 'Twitter description', page.pageName); + html = replaceRequired(html, /"name": "MekBay",/, `"name": "${page.schemaName}",`, 'structured-data name', page.pageName); + html = replaceRequired(html, /"url": "https:\/\/mekbay\.com\/",/, `"url": "${page.canonicalUrl}",`, 'structured-data URL', page.pageName); + html = replaceRequired(html, /"description": "[^"]*",/, `"description": "${page.description}",`, 'structured-data description', page.pageName); + + const outputPath = path.join(outputDirectory, page.outputFile); + fs.writeFileSync(outputPath, html); + console.log(`Generated ${path.relative(process.cwd(), outputPath)}`); +} + +for (const page of pages) { + generateSeoPage(page); +} 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/app.html b/src/app/app.html index edd0ca26f..5aee90867 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -30,12 +30,12 @@
- +
- + + +
+ @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/c3-network-dialog/c3-network-dialog.component.spec.ts b/src/app/components/c3-network-dialog/c3-network-dialog.component.spec.ts index 8c18a1b34..0d0820527 100644 --- a/src/app/components/c3-network-dialog/c3-network-dialog.component.spec.ts +++ b/src/app/components/c3-network-dialog/c3-network-dialog.component.spec.ts @@ -480,6 +480,21 @@ describe('C3NetworkDialogComponent runtime visualization', () => { expect(component.nodeRuntimeStatuses().get('master')).toEqual(['OFFLINE']); }); + it('uses endpoint operation rather than configuration permission for OFFLINE', async () => { + const { component } = await createComponent(); + const master = c3Unit('master', [C3_FLAGS.NOVA]); + spyOn(master.unit, 'canPerformEquipmentAction').and.returnValue(true); + master.actionUnavailableComponents.set(new Set([0])); + component.nodes.set([node(master, 0, 0)]); + component.networks.set([{ + id: 'network', type: C3NetworkType.NOVA, color: '#7B1FA2', + peerIds: ['master'], + }]); + + expect(component.nodeRuntimeStatuses().get('master')).toEqual(['OFFLINE']); + expect(master.unit.canPerformEquipmentAction(master.unit.getInventory()[0], 'configure-network')).toBeTrue(); + }); + it('does not show OFFLINE for a local endpoint when only its remote endpoint is unavailable', async () => { const { component } = await createComponent(); const master = c3Unit('master', [C3_FLAGS.C3M]); diff --git a/src/app/components/c3-network-dialog/c3-network-dialog.component.ts b/src/app/components/c3-network-dialog/c3-network-dialog.component.ts index 3819c967f..939bbc104 100644 --- a/src/app/components/c3-network-dialog/c3-network-dialog.component.ts +++ b/src/app/components/c3-network-dialog/c3-network-dialog.component.ts @@ -941,9 +941,7 @@ export class C3NetworkDialogComponent implements AfterViewInit { return [...componentIndexes].every(index => { const component = runtime.capability(node.unit.id)?.component(index); if (!component) return false; - return component.mount - ? !component.mount.owner.canPerformEquipmentAction(component.mount, 'configure-network') - : !node.unit.isC3EndpointOperational(index, component); + return !node.unit.isC3EndpointOperational(index, component); }); } diff --git a/src/app/components/collection-dialog/collection-dialog.component.html b/src/app/components/collection-dialog/collection-dialog.component.html index 2e0ecea1b..010d363c8 100644 --- a/src/app/components/collection-dialog/collection-dialog.component.html +++ b/src/app/components/collection-dialog/collection-dialog.component.html @@ -24,7 +24,7 @@
- {{ 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); diff --git a/src/app/components/dice-roller/dice-roller.component.scss b/src/app/components/dice-roller/dice-roller.component.scss index fda6d1465..eaa9984be 100644 --- a/src/app/components/dice-roller/dice-roller.component.scss +++ b/src/app/components/dice-roller/dice-roller.component.scss @@ -1,18 +1,9 @@ -.dice-roller-root { - &.small .dice-row { - gap: 4px; - } - - &:not(.small) .dice-row { - gap: clamp(4px, 2vw, 12px); - } -} - .dice-row { display: flex; align-items: center; flex-wrap: nowrap; white-space: nowrap; + gap: 4px; } .dice-row > * { @@ -39,20 +30,31 @@ } } -.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.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(20px, 6vw, 24px); + font-size: clamp(24px, 7vw, 32px); font-weight: bold; color: #fff; } diff --git a/src/app/components/dice-roller/dice-roller.component.spec.ts b/src/app/components/dice-roller/dice-roller.component.spec.ts index b8b576168..662e8803f 100644 --- a/src/app/components/dice-roller/dice-roller.component.spec.ts +++ b/src/app/components/dice-roller/dice-roller.component.spec.ts @@ -24,6 +24,25 @@ describe('DiceRollerComponent', () => { expect(fixture.nativeElement.querySelectorAll('.die')).toHaveSize(3); }); + it('displays restored faces as a completed roll without emitting it again', () => { + const fixture = TestBed.createComponent(DiceRollerComponent); + const finished = jasmine.createSpy('finished'); + fixture.componentInstance.finished.subscribe(finished); + fixture.componentRef.setInput('initialResults', [5, 2]); + fixture.detectChanges(); + + expect(fixture.componentInstance.diceResults()).toEqual([5, 2]); + expect(fixture.componentInstance.diceSum()).toBe(7); + expect(fixture.componentInstance.rollFinished()).toBeTrue(); + expect(finished).not.toHaveBeenCalled(); + + fixture.componentRef.setInput('initialResults', null); + fixture.detectChanges(); + expect(fixture.componentInstance.diceResults()).toEqual([null, null]); + expect(fixture.componentInstance.rollFinished()).toBeFalse(); + fixture.destroy(); + }); + it('rolls deterministic dice, applies the modifier, and emits once', () => { spyOn(Math, 'random').and.returnValue(0.5); const fixture = TestBed.createComponent(DiceRollerComponent); diff --git a/src/app/components/dice-roller/dice-roller.component.ts b/src/app/components/dice-roller/dice-roller.component.ts index adb8697b7..a6c530cdb 100644 --- a/src/app/components/dice-roller/dice-roller.component.ts +++ b/src/app/components/dice-roller/dice-roller.component.ts @@ -19,6 +19,8 @@ export class DiceRollerComponent { private finalResults: number[] | null = null; diceCount = input(2); diceSides = input(6); + /** Completed faces to display when a parent workflow is resumed. */ + initialResults = input(null); modifier = input(0); showSum = input(true); /** Use small dice instead of large (default) */ @@ -56,14 +58,18 @@ export class DiceRollerComponent { private postEndTimer: ReturnType | null = null; constructor() { - let lastDiceCount = 0; effect((cleanup) => { - if (this.diceCount() === lastDiceCount) { - return; - } - lastDiceCount = this.diceCount(); - this.resetArrays(); + const diceCount = Math.max(0, Math.floor(this.diceCount())); + const diceSides = Math.max(1, Math.floor(this.diceSides())); + const restored = this.validInitialResults(this.initialResults(), diceCount, diceSides); this.clearTimers(); + this.finalResults = null; + this.activeDiceCount = diceCount; + this.diceResults.set(restored ?? Array(diceCount).fill(null)); + this.rolled.set(restored !== null); + this.isRolling.set(false); + this.overlayVisible.set(false); + this.canCloseOverlay.set(false); cleanup(() => { this.clearTimers(); }); @@ -145,8 +151,16 @@ export class DiceRollerComponent { this.finished.emit({ results, sum: this.diceSum() }); } - private resetArrays() { - this.diceResults.set(Array(Math.max(0, Math.floor(this.diceCount()))).fill(null)); + private validInitialResults( + results: readonly number[] | null, + diceCount: number, + diceSides: number, + ): number[] | null { + return results !== null + && results.length === diceCount + && results.every(value => Number.isInteger(value) && value >= 1 && value <= diceSides) + ? [...results] + : null; } private randomFace() { diff --git a/src/app/components/equipment-dialog/equipment-dialog.component.html b/src/app/components/equipment-dialog/equipment-dialog.component.html index ba76f5fe1..6f9538e14 100644 --- a/src/app/components/equipment-dialog/equipment-dialog.component.html +++ b/src/app/components/equipment-dialog/equipment-dialog.component.html @@ -7,23 +7,17 @@

{{ unitTitle() }}

@if (!readOnly()) { } - \ 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..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; @@ -98,58 +98,59 @@ } .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; + stroke-linejoin: miter; + stroke-miterlimit: 10; + vector-effect: non-scaling-stroke; + transform: scale(1.08); + transform-box: fill-box; + transform-origin: center; + } + + 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 +506,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..5e034a4ea 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,7 @@ function createUnit(id: string, entries: MountedEquipment[] = []): CBTForceUnit dirty: () => false, autoFall: () => false, PSRRollsCount: () => 0, - currentPhase: () => '' + getTotalTargetModifierAsDefender: () => ({ modifier: 0 }), }); spyOn(harness.unit, 'setHeat').and.callThrough(); spyOn(harness.unit, 'setInventoryEntry').and.callThrough(); @@ -106,7 +107,7 @@ function createContext(): EquipmentDialogContext { registry: { getChoices: () => [], handleSelection: () => false, - afterInventoryControlFire: () => undefined, + afterInventoryControlFire: async () => [], inventoryControlRules: () => ({}) }, queryContext: createHandlerQueryContext(equipmentCatalog), @@ -150,6 +151,26 @@ describe('EquipmentDialogComponent', () => { expect(registration.handle(new KeyboardEvent('keydown', { key: 'ArrowRight', ctrlKey: true }))).toBeFalse(); }); + 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, + getTotalTargetModifierAsDefender: () => ({ modifier: 4 }), + }); + 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('W4'); + 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..003d8ce97 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,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(() => { + const turnState = this.unit().turnState(); + return getTurnMovementIndicator( + turnState.moveMode(), + turnState.getTotalTargetModifierAsDefender().modifier, + ); + }); readonly targets = computed(() => { this.unit().getInventoryControlTargetsMap(); return this.unit().getInventoryControlTargets(); @@ -110,10 +118,6 @@ export class EquipmentDialogComponent { this.activeTab.set(tab); } - turnSummaryDirty(): boolean { - return this.unit().turnState().dirty(); - } - turnSummaryFalling(): boolean { return this.unit().turnState().autoFall(); } @@ -126,10 +130,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/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/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/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 7c6bb8b8f..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 @@ -39,6 +39,15 @@ import { UACFiringModeHandler } from '../../equipment-handlers/uac-firing-mode.h import { EquipmentFlag } from '../../models/equipment-flags.type'; 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'; +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' @@ -135,6 +144,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; } @@ -171,6 +181,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() }; @@ -201,6 +212,7 @@ function createComponent( hasDirectInventory: options.hasDirectInventory, applyInventoryControlDisplayEffects: options.applyUnitDisplayEffects, resolveEquipmentActionPermission: options.resolveEquipmentActionPermission, + resolveConfigureNetworkPermission: options.resolveConfigureNetworkPermission, hasIndependentInventoryControlAction: options.hasIndependentInventoryControlAction, }); const unit = unitHarness.unit; @@ -249,7 +261,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 }); @@ -455,6 +771,52 @@ 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', + equipment: misc('Nova CEWS', ['F_NOVA']), + locations: new Set(['CT']), + el: svgEntry(` + + Nova CEWS + + + `), + }); + const { component, fixture } = createComponent([nova], {}, [], new Map(), { + handlers: [new NovaCewsHandler()], + }); + + const row = component.groups().find(group => group.id === 'equipment')!.rows[0]; + expect(row.firingHeat).toBe(2); + expect(row.display.heat).toBe('2'); + expect((fixture.nativeElement.querySelector('.heat-cell') as HTMLElement).textContent?.trim()).toBe('2'); + + nova.setState(NOVA_CEWS_STATE_KEY, NOVA_CEWS_OFF_STATE); + nova.owner.inventoryControl.markInventoryViewChanged(); + fixture.detectChanges(); + + const offRow = component.groups().find(group => group.id === 'equipment')!.rows[0]; + expect(offRow.firingHeat).toBeNull(); + expect(offRow.display.heat).toBe('—'); + expect((fixture.nativeElement.querySelector('.heat-cell') as HTMLElement).textContent?.trim()).toBe('—'); + }); + it('excludes ammo in functionally destroyed locations from weapon ammo summaries', () => { const ac2 = weapon('AC/2', 'AC', 2); const ac2Ammo = ammo('AC/2 Ammo', 'AC', 2); @@ -525,23 +887,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', () => { @@ -2646,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 d5fad98cb..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'; @@ -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; @@ -128,6 +136,10 @@ interface AmmoConsumptionSummaryItem { count: number; } +interface InventoryControlFireSummaryItem extends InventoryControlFireResult { + label: string; +} + interface DragPreviewCellSizing { path: number[]; width: number; @@ -183,7 +195,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 +373,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 +398,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 +406,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 { @@ -843,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' ); } @@ -891,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 { @@ -908,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 { @@ -953,6 +1071,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 +1252,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 +1261,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/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..2f3627687 --- /dev/null +++ b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.html @@ -0,0 +1,121 @@ +
    +
    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 arc } + @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..d0a124b93 --- /dev/null +++ b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.scss @@ -0,0 +1,274 @@ +:host { + display: block; +} + +.panel { + width: min(660px, calc(100vw - 24px)); + max-height: min(780px, calc(100dvh - 24px)); +} + +.random-button { + 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; + 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..3585e9177 --- /dev/null +++ b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.spec.ts @@ -0,0 +1,118 @@ +// 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' }, + turnState: () => ({ cover: () => undefined }), + getPendingFall: () => undefined, + setPendingFallRolls: persistRolls, + getNotificationDisplayName: () => 'Atlas AS7-D', + hasArmorType: () => false, + 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); + }); + + 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 new file mode 100644 index 000000000..623525f49 --- /dev/null +++ b/src/app/components/falling-damage-dialog/falling-damage-dialog.component.ts @@ -0,0 +1,213 @@ +// 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 { unitCoverWaterDepth } from '../../models/unit-cover.model'; +import { + isResolvedMekFallHitLocation, + resolvedMekFallDamageGroups, + rollMekFallDice, + resolveMekFallDamage, + resolveMekFallHitLocation, + resolveMekFallOrientation, + twoD6ForTotal, + twoD6Total, + 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 hitLocationDice: readonly [number, number] | null; + readonly tripodLegRoll: number | null; +} + +interface FallingDamageGroupRow extends FallingDamageGroupRoll { + readonly index: number; + readonly damage: number; + readonly hitLocationRoll: number | null; + 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 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); + private readonly groupRolls = signal( + this.damageGroups.map((_damage, index) => { + const pendingRoll = this.pending?.damageRolls[index]; + return { + hitLocationDice: pendingRoll?.hitLocationDice ?? null, + tripodLegRoll: pendingRoll?.tripodLegRoll ?? 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) => { + 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; + 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 = 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.persistRolls(); + } + + setHitLocationRoll(index: number, roll: number | null): void { + this.updateGroupRoll(index, { + hitLocationDice: twoD6ForTotal(roll), + }); + } + + setTripodLegRoll(index: number, roll: number | null): void { + this.updateGroupRoll(index, { + tripodLegRoll: validRoll(roll, 1, 6), + }); + } + + 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); + this.groupRolls.set(rolled.damageRolls); + 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[], + ); + } +} + +function validRoll(value: number | null, min: number, max: number): number | null { + return value !== null && Number.isInteger(value) && value >= min && value <= max ? value : null; +} 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/floating-comp-info/floating-comp-info.component.ts b/src/app/components/floating-comp-info/floating-comp-info.component.ts index 8eed67872..5ec36bcbb 100644 --- a/src/app/components/floating-comp-info/floating-comp-info.component.ts +++ b/src/app/components/floating-comp-info/floating-comp-info.component.ts @@ -11,7 +11,6 @@ import { AmmoEquipment, type Equipment, formatEquipmentRulesRefs, WeaponEquipmen import { TechDate, TechAdvancementDates, techDateYear, formatTechDate } from '../../models/entity'; import { getWeaponTypeCSSClass } from '../../utils/equipment.util'; import { CBTGameRulesService } from '../../services/cbt-game-rules.service'; -import { resolveDefaultWeaponDamageText } from '../../utils/inventory-control-damage.util'; import { formatInventoryControlHeat } from '../../utils/inventory-control-heat.util'; 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)); diff --git a/src/app/components/force-builder-viewer/force-builder-viewer.component.html b/src/app/components/force-builder-viewer/force-builder-viewer.component.html index da9ee8c96..894f4b20a 100644 --- a/src/app/components/force-builder-viewer/force-builder-viewer.component.html +++ b/src/app/components/force-builder-viewer/force-builder-viewer.component.html @@ -108,7 +108,11 @@ }
    @if (!isReadOnly && groups.length > 1) {
    } @@ -258,13 +261,16 @@

    - {{ isAlphaStrike() ? 'PV' : 'BV' }}: {{ displayedBvPv(group.units()) }} + + {{ totalTons(group.units()) | formatTons }} tons · + {{ isAlphaStrike() ? 'PV' : 'BV' }}: {{ displayedBvPv(group.units()) }} + @let units = group.units();
    total + unit.getUnit().tons, 0); + } + displayedUnitBvPv(unit: ForceUnit): string { return formatBvPv( unit.getBv(), @@ -812,7 +818,7 @@ export class ForceOverviewDialogComponent { connectedDropLists = computed(() => { 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'); @@ -827,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); @@ -865,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; 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 +} 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/components/options-dialog/options-dialog.component.html b/src/app/components/options-dialog/options-dialog.component.html index 0eec9e469..b5824bb95 100644 --- a/src/app/components/options-dialog/options-dialog.component.html +++ b/src/app/components/options-dialog/options-dialog.component.html @@ -532,29 +532,53 @@
    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 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) { +
    +
    + + {{ automation.label }}: + +
    + @for (mode of cbtAutomationModes; track mode.value) { + + } +
    +
    +
    +

    {{ automation.description }}

    +
    + }
    Optional rules
    - - + + + +
    +
    + +
    @@ -566,6 +590,15 @@
    +
    + + +
    } @case ('Alpha Strike') { 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..8c370e1fe 100644 --- a/src/app/components/options-dialog/options-dialog.component.spec.ts +++ b/src/app/components/options-dialog/options-dialog.component.spec.ts @@ -73,24 +73,41 @@ describe('OptionsDialogComponent', () => { expect(setOption).toHaveBeenCalledOnceWith('forceViewerBVPVDisplay', 'both'); }); - it('persists the CBT automations selection as a boolean', () => { - const setOption = jasmine.createSpy('setOption'); - const component = configureComponent({ options: () => ({ unitServers: [] }), setOption }); - const select = document.createElement('select'); - select.innerHTML = ''; - select.value = 'false'; + it('forwards a CBT automation mode change to the options service', () => { + const setCbtAutomationMode = jasmine.createSpy('setCbtAutomationMode'); + const component = configureComponent({ + options: () => ({ + unitServers: [], + cbtAutomationOptions: { + pilotSkillCheck: 'ask', + heatAndDissipationResolution: 'yes', + heatEffectsCheck: 'ask', + pilotHitsAndConsciousnessCheck: 'ask', + internalExplosionsCheck: 'yes', + criticalHitChanceCheck: 'no', + breachAndFloodCheck: 'ask', + fallingCheck: 'yes', + }, + }), + setCbtAutomationMode, + }); - component.onCbtAutomationsChange({ target: select } as unknown as Event); + component.onCbtAutomationModeChange('heatAndDissipationResolution', 'ask'); - expect(setOption).toHaveBeenCalledOnceWith('cbtAutomations', false); + expect(setCbtAutomationMode).toHaveBeenCalledOnceWith('heatAndDissipationResolution', 'ask'); }); - it('updates one CBT optional rule without changing the other', () => { + it('updates one CBT optional rule without changing the others', () => { const setOption = jasmine.createSpy('setOption'); const component = configureComponent({ options: () => ({ unitServers: [], - CBTOptionalRules: { forcedWithdrawal: true, extremeRange: false }, + CBTOptionalRules: { + forcedWithdrawal: true, + extremeRange: false, + floatingCriticals: true, + sprinting: false, + }, }), setOption, }); @@ -103,6 +120,8 @@ describe('OptionsDialogComponent', () => { expect(setOption).toHaveBeenCalledOnceWith('CBTOptionalRules', { forcedWithdrawal: false, extremeRange: false, + floatingCriticals: true, + sprinting: false, }); }); diff --git a/src/app/components/options-dialog/options-dialog.component.ts b/src/app/components/options-dialog/options-dialog.component.ts index 4e336a3b2..fb517f48b 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, CBTOptionalRules, 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,53 @@ 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: '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: 'heatAndDissipationResolution', + label: 'Heat and dissipation', + description: 'Calculate and apply heat and cooling at end of turn.', + }, + { + key: 'heatEffectsCheck', + 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: 'pilotHitsAndConsciousnessCheck', + label: 'Pilot hits and consciousness', + description: 'Apply pilot injuries from head hits and heat effects, then resolve consciousness and recovery rolls.', + }, + { + key: 'internalExplosionsCheck', + label: 'Internal explosions', + description: 'Resolve effects caused by explosive equipment and ammunition.', + }, + { + key: 'criticalHitChanceCheck', + label: 'Critical hit chance', + description: 'Resolve checks that can cause critical hits.', + }, + { + key: 'breachAndFloodCheck', + label: 'Breach and flood', + description: 'Resolve armor breaches and flooding effects.', + }, + { + key: 'fallingCheck', + label: 'Falling', + description: 'Resolve fall orientation, hit locations, and damage before the seatbelt check.', + }, +]; @Component({ @@ -136,6 +189,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,12 +567,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.setCbtAutomationMode(key, value); } - onCBTOptionalRuleChange(key: 'forcedWithdrawal' | 'extremeRange', event: Event) { + onCBTOptionalRuleChange(key: keyof CBTOptionalRules, event: Event) { const value = (event.target as HTMLSelectElement).value === 'true'; this.optionsService.setOption('CBTOptionalRules', { ...this.optionsService.options().CBTOptionalRules, 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..5cc57ad1f 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,181 @@ 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(manualActions[0].classList).toContain('success'); + expect(manualActions[0].classList).not.toContain('primary'); + expect(Array.from(manualActions).slice(1).every(button => button.classList.contains('primary'))) + .toBeTrue(); + 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(); + const randomRow = fixture.nativeElement.querySelector('.critical-random-row') as HTMLElement; + expect(randomRow.classList).toContain('roll-disabled'); + expect(getComputedStyle(randomRow).cursor).toBe('not-allowed'); + 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('restores exact dice when a pending chance is reopened', () => { + const data = fixture.componentInstance.data as { + initialRoll?: readonly [number, number]; + initialResult?: { readonly kind: 'critical-hits'; readonly count: number }; + onRollChange?: (roll: readonly [number, number]) => void; + }; + const onRollChange = jasmine.createSpy('onRollChange'); + fixture.destroy(); + data.initialRoll = [5, 3]; + data.initialResult = { kind: 'critical-hits', count: 1 }; + data.onRollChange = onRollChange; + fixture = TestBed.createComponent(MekCriticalChanceDialogComponent); + fixture.detectChanges(); + + const roller = fixture.componentInstance.roller()!; + expect(roller.diceResults()).toEqual([5, 3]); + expect(roller.rollFinished()).toBeTrue(); + expect(fixture.componentInstance.result()).toEqual({ kind: 'critical-hits', count: 1 }); + expect(onRollChange).not.toHaveBeenCalled(); + + roller.roll([6, 4]); + jasmine.clock().tick(500); + expect(onRollChange).toHaveBeenCalledOnceWith([6, 4]); + }); + + 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 +272,7 @@ describe('MekCriticalChanceDialogComponent optional modifiers', () => { useValue: { locationLabel: 'Left Arm', canBlowOff: true, + industrialMek: false, modifiers: [{ label: 'Hardened armor in damaged facing', value: -2, @@ -191,4 +324,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..6071336f2 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,13 @@ import { export interface MekCriticalChanceDialogData { readonly locationLabel: string; readonly canBlowOff: boolean; + readonly industrialMek: boolean; readonly modifiers?: readonly MekCriticalChanceModifier[]; + readonly initialResult?: MekCriticalChanceResult; + readonly initialRoll?: readonly [number, number]; + readonly onResultChange?: (result: MekCriticalChanceResult | null) => void; + readonly onRollChange?: (roll: readonly [number, number]) => void; + readonly manual?: boolean; } @Component({ @@ -24,7 +30,7 @@ export interface MekCriticalChanceDialogData { changeDetection: ChangeDetectionStrategy.OnPush, template: `
    -
    Critical Chance · {{ data.locationLabel }}
    +
    Critical Chance: {{ data.locationLabel }}
    @@ -106,45 +112,76 @@ 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,36 +194,44 @@ 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(); } 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)); } 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 +262,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 +272,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 fadd55702..1934322c9 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,8 @@ } .panel { - width: min(460px, 100vw); + width: min(460px, calc(100vw - 24px)); + cursor: auto; } .body { @@ -19,12 +20,34 @@ text-align: center; } -.critical-dialog-body p { - margin: 0; +.critical-random-row { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + cursor: pointer; + + &:not(.roll-disabled):hover .random-button, + &:not(.roll-disabled):focus-within .random-button { + opacity: 1; + } + + &.roll-disabled { + cursor: not-allowed; + } +} + +.critical-dice-trigger { + cursor: inherit; + + &:focus-visible { + outline: 2px solid var(--bt-yellow); + outline-offset: 4px; + } } .critical-table-hint { - color: var(--text-color-tertiary); + color: var(--text-color-secondary); font-size: 0.85em; } @@ -40,10 +63,6 @@ min-width: 0; } -.result-slot-hidden { - visibility: hidden; -} - .critical-roll-details { align-self: stretch; margin: 0; @@ -119,20 +138,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 { @@ -151,17 +177,47 @@ line-height: 1.35; } +.case-ii-check { + border: 1px solid #e8a64a; + background: rgba(120, 80, 0, 0.2); + color: var(--text-color-secondary); + font-size: 0.88em; + line-height: 1.35; +} + +.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); -} -.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 { @@ -170,33 +226,201 @@ 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: 8px; + gap: 4px; + flex-wrap: wrap; + + .bt-button { + flex: 1 1 0; + width: auto; + min-width: 0; + } +} + +.critical-manual-results { + flex: 1 0 100%; + display: grid; + gap: 6px; } -.actions .bt-button { - flex: 1 1 0; - width: auto; +.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 { - flex-wrap: wrap; +.critical-chance-options.rolled-result-action { + grid-template-columns: minmax(0, 1fr); +} + +.critical-result-action, +.critical-dismiss-actions .bt-button { + width: 100%; +} + +.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 { + 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-chance-actions .critical-action { - order: -1; - flex: 0 0 100%; +.critical-slot-name { + 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; + 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-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; +} + +.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-chance-actions .critical-action.action-unavailable { - visibility: hidden; +.critical-slot-unavailable.critical-slot-dimmed { + opacity: 0.3; } diff --git a/src/app/components/page-viewer/mek-critical-hit-dialog.component.spec.ts b/src/app/components/page-viewer/mek-critical-hit-dialog.component.spec.ts new file mode 100644 index 000000000..9d1e2798a --- /dev/null +++ b/src/app/components/page-viewer/mek-critical-hit-dialog.component.spec.ts @@ -0,0 +1,891 @@ +// 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, 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, 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 { MekCriticalHitDialogComponent } from './mek-critical-hit-dialog.component'; + +describe('MekCriticalHitDialogComponent', () => { + 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; + location: string; + requiredHits: number; + consolidateImmediately: boolean; + locationDestroyed?: boolean; + pendingCriticalId?: string; + manual?: boolean; + caseIICheckRequired?: boolean; + caseIICheckPassed?: boolean; + caseIICheckResult?: 'resolve' | 'discard'; + caseIICheckRoll?: readonly [number, number]; + 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 () => { + const caseII = new MiscEquipment({ + id: 'ISCASEII', + name: 'CASE II', + type: 'misc', + flags: ['F_CASE_II'], + }); + const weapon = new WeaponEquipment({ + id: 'MediumLaser', + 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 }, + ]; + 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: { + applied: false, + slotNumber: 2, + equipment: 'CASE II', + armoredAbsorption: false, + reason: 'empty', + }, + }); + 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; + }, + getCritSlot: (location: string, slot: number) => { + slotsVersion(); + return criticalSlots.find(candidate => candidate.loc === location && candidate.slot === slot) ?? null; + }, + getInventory: () => inventoryEntries, + getUnit: () => ({ comp: [] }), + } as unknown as CBTForceUnit; + dialogData = { + unit, + location: 'LT', + requiredHits: 1, + consolidateImmediately: true, + pendingCriticalId: '0198b234-7abc-7def-8123-456789abcdef', + }; + + await TestBed.configureTestingModule({ + imports: [MekCriticalHitDialogComponent], + providers: [ + provideZonelessChangeDetection(), + { provide: DialogRef, useValue: dialogRef }, + { provide: MekCriticalHitAutomationService, useValue: { previewRoll, previewSlot, applyRoll } }, + { provide: DIALOG_DATA, useValue: dialogData }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(MekCriticalHitDialogComponent); + fixture.detectChanges(); + }); + + it('keeps the initial location protection visible after it is destroyed', () => { + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('.protection-badge')?.textContent).toContain('CASE II'); + expect(element.querySelector('.protection-note')?.textContent).toContain('Caps internal damage at 1'); + + caseIISlot.destroyed = 1; + slotsVersion.update(version => version + 1); + fixture.detectChanges(); + + expect(element.querySelector('.protection-badge')?.textContent).toContain('CASE II'); + expect(element.querySelector('.protection-note')?.textContent).toContain('Caps internal damage at 1'); + }); + + 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.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', + ) as NodeListOf; + + expect(Array.from(actions, action => action.textContent?.trim())) + .toEqual(['APPLY', 'CLOSE']); + expect(actions[0].disabled).toBeTrue(); + expect(actions[1].disabled).toBeFalse(); + 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(MekCriticalHitDialogComponent); + 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(MekCriticalHitDialogComponent); + 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; + dialogData.pendingCriticalId = undefined; + fixture = TestBed.createComponent(MekCriticalHitDialogComponent); + 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(MekCriticalHitDialogComponent); + 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', () => { + 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('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(MekCriticalHitDialogComponent); + 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(MekCriticalHitDialogComponent); + 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(MekCriticalHitDialogComponent); + 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(MekCriticalHitDialogComponent); + 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(); + + 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(); + expect(fixture.componentInstance.appliedHits()).toBe(0); + expect(roll).toHaveBeenCalledTimes(1); + }); + + 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'); + + fixture.componentInstance.onFinished({ results: [1, 2] }); + fixture.componentInstance.primaryAction(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.componentInstance.appliedHits()).toBe(0); + expect(fixture.componentInstance.discardedHits()).toBe(1); + expect(fixture.componentInstance.complete()).toBeTrue(); + 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('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(); + + 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(MekCriticalHitDialogComponent); + 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(MekCriticalHitDialogComponent); + fixture.detectChanges(); + + fixture.componentInstance.onCaseIIFinished({ results: [4, 4] }); + fixture.detectChanges(); + + expect(setPendingCriticalCaseIICheckResult).toHaveBeenCalledOnceWith( + dialogData.pendingCriticalId, + 'discard', + [4, 4], + ); + 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(); + + fixture.destroy(); + dialogData.caseIICheckResult = 'discard'; + dialogData.caseIICheckRoll = [4, 4]; + fixture = TestBed.createComponent(MekCriticalHitDialogComponent); + 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', () => { + fixture.destroy(); + dialogData.caseIICheckRequired = true; + dialogData.requiredHits = 2; + fixture = TestBed.createComponent(MekCriticalHitDialogComponent); + 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')).toBeNull(); + expect(fixture.nativeElement.querySelector('.case-ii-check')).not.toBeNull(); + }); + + 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 }); + }); + + 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(MekCriticalHitDialogComponent); + fixture.detectChanges(); + + expect(fixture.componentInstance.primaryLabel()).toBe('APPLY'); + expect(fixture.nativeElement.querySelector('.critical-result')?.textContent) + .toContain('rolled hit has not been applied'); + }); + + 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(MekCriticalHitDialogComponent); + 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')).toBeNull(); + }); + + 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(); + 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(); + 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-hit-dialog.component.ts b/src/app/components/page-viewer/mek-critical-hit-dialog.component.ts new file mode 100644 index 000000000..bac3a675e --- /dev/null +++ b/src/app/components/page-viewer/mek-critical-hit-dialog.component.ts @@ -0,0 +1,815 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { ChangeDetectionStrategy, Component, computed, inject, signal, viewChild } from '@angular/core'; +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 { + getMekExplosionProtection, + getRollableMekCriticalSlots, + mekCriticalRollDiceCount, + mekCriticalRollForSlot, + mekCriticalRollLocation, + mekCriticalSlotDisplayName, + mekCriticalSlotRollability, + mekCriticalSlotIndexForRoll, + randomValidMekCriticalRoll, + type MekCriticalHitPreview, + type MekExplosionLocationDamage, + type MekCriticalHitOptions, + type MekCriticalRollOutcome, +} from '../../utils/mek-critical-hit.util'; +import { MekCriticalHitAutomationService } from '../../services/mek-critical-hit-automation.service'; +import { isConsciousnessCheck } from '../../utils/unit-check.util'; +import { DiceRollerComponent } from '../dice-roller/dice-roller.component'; + +export interface MekCriticalHitDialogData { + readonly unit: CBTForceUnit; + readonly location: string; + readonly targetLocation?: string; + readonly requiredHits: number; + readonly locationDestroyed?: boolean; + readonly consolidateImmediately: boolean; + readonly pendingCriticalId?: string; + readonly manual?: boolean; + readonly caseIICheckRequired?: boolean; + readonly caseIICheckPassed?: boolean; + readonly caseIICheckResult?: 'resolve' | 'discard'; + readonly caseIICheckRoll?: readonly [number, number]; + readonly pilotDamageGroup?: string; + readonly canUndoToChance?: boolean; +} + +export interface MekCriticalHitDialogResult { + readonly completed: boolean; + readonly interruptedForConsciousness?: boolean; + readonly remainingHits?: number; + readonly undoToChance?: true; +} + +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 { + readonly equipment: string; + readonly rawDamage: number; + readonly pilotHits: number; + readonly locations: readonly MekExplosionLocationDamage[]; + readonly phaseEnd: boolean; + readonly automaticCriticalMessage?: string; +} + +@Component({ + selector: 'mek-critical-hit-dialog', + standalone: true, + imports: [DiceRollerComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
    +
    Critical Hit: {{ locationLabel }}
    +
    +
    +
    + {{ 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') { +
    + {{ explosionProtectionLabel }} + {{ 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 && hasRollableSlot()) { +
    + +
    + +
    +
    + } + + @if (outcome(); as currentOutcome) { +
    + {{ outcomeLabel(currentOutcome) }} +
    + @if (currentOutcome.pendingExplosion; as pendingExplosion) { +
    + + {{ pendingExplosion.equipment }} explosion pending + ({{ pendingExplosion.rawDamage }} damage). + +
    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 (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 { + + } + } +
    +
    + } +
    + @if (canDiscardNonExplosiveResult()) { + + } + } +
    +
    +
    + + @if (canUndoToChance()) { + + } + +
    +
    + `, + styleUrls: [ + './overlay/page-psr-warning-panel.component.scss', + './mek-critical-dialog.component.scss', + ], +}) +export class MekCriticalHitDialogComponent { + private readonly dialogRef = inject(DialogRef); + private readonly criticalHitAutomation = inject(MekCriticalHitAutomationService); + readonly data = inject(DIALOG_DATA); + readonly roller = viewChild('roller'); + readonly caseIIRoller = viewChild('caseIIRoller'); + readonly criticalRollOptions: MekCriticalHitOptions = { + 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 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); + 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); + 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, + this.criticalRollOptions, + )); + private readonly lockedSlotRows = signal( + this.restoredRoll ? this.createSlotRows(this.availableSlots()) : null, + ); + 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); + 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.aggregatedEndPhaseConsciousRolls + && this.data.unit.turnState().actionablePendingUnitChecks() + .some(isConsciousnessCheck)); + // 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); + + constructor() { + const slots = this.availableSlots(); + if (this.data.canUndoToChance && slots.length === 1) this.selectSlot(slots[0]); + } + + roll(): void { + if (!this.canStartRoll()) return; + this.lockManualSlots(); + this.outcome.set(null); + this.automationCancelled.set(false); + const results = randomValidMekCriticalRoll( + this.data.unit, + this.targetLocation, + Math.random, + 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); + const pendingId = this.data.pendingCriticalId; + if (pendingId) { + this.data.unit.turnState().setPendingCriticalCaseIICheckResult( + pendingId, + result, + event.results, + ); + } + } + + applyCaseIICheck(result: 'resolve' | 'discard'): void { + if (this.isAnyRolling() || this.resolving() || !this.needsCaseIICheck()) return; + if (result === 'discard') { + this.discardCurrentCritical('case-ii'); + return; + } + const pendingId = this.data.pendingCriticalId; + if (pendingId && !this.data.unit.turnState().passPendingCriticalCaseIICheck(pendingId)) return; + this.caseIICheckPassed.set(true); + this.caseIICheckResult.set(null); + } + + onFinished(event: { readonly results: number[] }): void { + 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.persistRoll(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.clearPersistedRoll()) 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, + results, + this.data.consolidateImmediately, + 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(); + } else { + this.advanceToNextCritical(); + } + 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()); + } else if (!this.hasInterruptingConsciousness()) { + this.advanceToNextCritical(); + } + } + + 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.pendingResults()) { + return this.resolvedHits() + 1 < this.data.requiredHits ? 'NEXT' : 'APPLY'; + } + 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.discardPersistedHits(); + 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' + : 'empty'; + return `Slot ${outcome.slotNumber} is ${reason}: rerolling.`; + } + if (outcome.armoredAbsorption) { + return `Slot ${outcome.slotNumber}: ${outcome.equipment} is armored and absorbs the hit.`; + } + return `Slot ${outcome.slotNumber}: ${outcome.equipment} critical slot destroyed.`; + } + + locationName(location: string): string { + return getMekLocationLabel(location) ?? location; + } + + slotNumber(slot: CriticalSlot): number { + return (slot.slot ?? 0) + 1; + } + + tableSlotNumber(slotIndex: number): number { + return slotIndex % 6 + 1; + } + + slotLabel(slot: CriticalSlot): string { + return mekCriticalSlotDisplayName(this.data.unit, slot); + } + + 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 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 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.resolvePersistedHit()) 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(); + } 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[] { + 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, + 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( + 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; + const remainingHits = Math.max(0, this.data.requiredHits - this.resolvedHits()); + this.dialogRef.close({ + completed: this.complete(), + ...(interruptedForConsciousness ? { interruptedForConsciousness: true } : {}), + ...(interruptedForConsciousness && remainingHits > 0 ? { remainingHits } : {}), + }); + } + + private completeDialog(interruptedForConsciousness = false): void { + this.dialogRef.close({ + completed: true, + ...(interruptedForConsciousness ? { interruptedForConsciousness: true } : {}), + }); + } +} 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 deleted file mode 100644 index 9c51c6f91..000000000 --- a/src/app/components/page-viewer/mek-critical-roll-dialog.component.spec.ts +++ /dev/null @@ -1,147 +0,0 @@ -// 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 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 { MekCriticalRollDialogComponent } from './mek-critical-roll-dialog.component'; - -describe('MekCriticalRollDialogComponent', () => { - let fixture: ComponentFixture; - let caseIISlot: CriticalSlot; - let criticalSlots: CriticalSlot[]; - let dialogRef: { close: jasmine.Spy }; - const slotsVersion = signal(0); - - beforeEach(async () => { - const caseII = new MiscEquipment({ - id: 'ISCASEII', - name: 'CASE II', - type: 'misc', - flags: ['F_CASE_II'], - }); - const weapon = new WeaponEquipment({ - id: 'MediumLaser', - name: 'Medium 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, - ]; - dialogRef = { close: jasmine.createSpy('close') }; - const unit = { - gameRules: CORE_2026_GAME_RULES, - rules: { mountedCriticalDamageDestructionThreshold: () => 1 }, - getCritSlots: () => { - slotsVersion(); - return criticalSlots; - }, - getCritSlot: (location: string, slot: number) => { - slotsVersion(); - return criticalSlots.find(candidate => candidate.loc === location && candidate.slot === slot) ?? null; - }, - getUnit: () => ({ comp: [] }), - } as unknown as CBTForceUnit; - - await TestBed.configureTestingModule({ - imports: [MekCriticalRollDialogComponent], - providers: [ - provideZonelessChangeDetection(), - { provide: DialogRef, useValue: dialogRef }, - { - provide: DIALOG_DATA, - useValue: { unit, location: 'LT', requiredHits: 1, consolidateImmediately: true }, - }, - ], - }).compileComponents(); - fixture = TestBed.createComponent(MekCriticalRollDialogComponent); - fixture.detectChanges(); - }); - - it('keeps the initial location protection visible after it is destroyed', () => { - const element = fixture.nativeElement as HTMLElement; - - expect(element.querySelector('.protection-badge')?.textContent).toContain('CASE II'); - expect(element.querySelector('.protection-note')?.textContent).toContain('Caps internal damage at 1'); - - caseIISlot.destroyed = 1; - slotsVersion.update(version => version + 1); - fixture.detectChanges(); - - expect(element.querySelector('.protection-badge')?.textContent).toContain('CASE II'); - expect(element.querySelector('.protection-note')?.textContent).toContain('Caps internal damage at 1'); - }); - - it('animates to dice faces for a valid critical slot', () => { - spyOn(Math, 'random').and.returnValue(0); - const roller = fixture.componentInstance.roller()!; - const roll = spyOn(roller, 'roll'); - - fixture.componentInstance.roll(); - - expect(roll).toHaveBeenCalledOnceWith([1, 1]); - }); - - it('automatically retries if a selected slot becomes invalid before the roll finishes', () => { - const roll = spyOn(fixture.componentInstance, 'roll'); - - fixture.componentInstance.onFinished({ results: [1, 2] }); - fixture.detectChanges(); - - expect(fixture.nativeElement.querySelector('.critical-result')).toBeNull(); - expect(fixture.componentInstance.appliedHits()).toBe(0); - 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(); - - const primaryButton = fixture.nativeElement.querySelector('.bt-button.primary') as HTMLButtonElement; - expect(primaryButton.disabled).toBeFalse(); - primaryButton.click(); - fixture.detectChanges(); - - expect(fixture.componentInstance.discarded()).toBeTrue(); - expect(fixture.componentInstance.complete()).toBeTrue(); - expect(fixture.componentInstance.rollButtonLabel()).toBe('CRITICALS DISCARDED'); - expect(dialogRef.close).toHaveBeenCalledOnceWith({ completed: true }); - }); - - it('uses completed primary-button states as dismiss actions', () => { - const primaryButton = fixture.nativeElement.querySelector('.bt-button.primary') as HTMLButtonElement; - - fixture.componentInstance.appliedHits.set(1); - fixture.detectChanges(); - - expect(primaryButton.textContent).toContain('CRITICALS APPLIED'); - expect(primaryButton.disabled).toBeFalse(); - primaryButton.click(); - - expect(dialogRef.close).toHaveBeenCalledOnceWith({ completed: true }); - - dialogRef.close.calls.reset(); - fixture.componentInstance.appliedHits.set(0); - fixture.componentInstance.discarded.set(true); - fixture.detectChanges(); - - expect(primaryButton.textContent).toContain('CRITICALS DISCARDED'); - expect(primaryButton.disabled).toBeFalse(); - primaryButton.click(); - - expect(dialogRef.close).toHaveBeenCalledOnceWith({ completed: true }); - }); -}); 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 deleted file mode 100644 index 0b88c4d86..000000000 --- a/src/app/components/page-viewer/mek-critical-roll-dialog.component.ts +++ /dev/null @@ -1,206 +0,0 @@ -// Copyright (C) 2026 The MegaMek Team -// SPDX-License-Identifier: GPL-3.0-or-later -// Author: Drake - -import { ChangeDetectionStrategy, Component, computed, inject, signal, viewChild } from '@angular/core'; -import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; -import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; -import { getMekLocationLabel } from '../../models/entity/types'; -import { - applyMekCriticalRoll, - getMekExplosionProtection, - hasRollableMekCriticalSlot, - mekCriticalRollDiceCount, - mekCriticalRollLocation, - randomValidMekCriticalRoll, - type MekCriticalRollOutcome, -} from '../../utils/mek-critical-hit.util'; -import { DiceRollerComponent } from '../dice-roller/dice-roller.component'; - -export interface MekCriticalRollDialogData { - readonly unit: CBTForceUnit; - readonly location: string; - readonly requiredHits?: number; - readonly consolidateImmediately: boolean; -} - -export interface MekCriticalRollDialogResult { - readonly completed: boolean; -} - -@Component({ - selector: 'mek-critical-roll-dialog', - standalone: true, - imports: [DiceRollerComponent], - changeDetection: ChangeDetectionStrategy.OnPush, - template: ` -
    -
    Critical Roll · {{ locationLabel }}
    -
    -
    - @if (data.requiredHits !== undefined) { -
    - {{ appliedHits() }} / {{ data.requiredHits }} critical hits applied -
    - } - @if (explosionProtection !== 'none') { -
    - {{ explosionProtectionLabel }} - {{ explosionProtectionNote }} -
    - } - - - @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) { -
    - - {{ pendingExplosion.equipment }} explosion pending - ({{ pendingExplosion.rawDamage }} damage). - -
    It resolves at phase end. Firing the weapon this phase prevents it.
    -
    - } - } @else if (!hasRollableSlot()) { -
    No valid critical slots remain; excess critical hits are discarded.
    - } -
    -
    -
    - - -
    -
    - `, - styleUrls: [ - './overlay/page-psr-warning-panel.component.scss', - './mek-critical-dialog.component.scss', - ], -}) -export class MekCriticalRollDialogComponent { - private readonly dialogRef = inject(DialogRef); - readonly data = inject(DIALOG_DATA); - readonly roller = viewChild('roller'); - readonly targetLocation = 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 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( - this.data.unit, - this.targetLocation, - { transfer: false }, - )); - // 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; - } - this.outcome.set(null); - const results = randomValidMekCriticalRoll( - this.data.unit, - this.targetLocation, - Math.random, - { transfer: false }, - ); - if (!results) return; - this.roller()?.roll(results); - } - - onFinished(event: { readonly results: number[] }): void { - const outcome = applyMekCriticalRoll( - this.data.unit, - this.targetLocation, - event.results, - this.data.consolidateImmediately, - { transfer: false }, - ); - if (!outcome?.applied) { - this.outcome.set(null); - this.roll(); - return; - } - this.outcome.set(outcome); - this.appliedHits.update(value => value + 1); - } - - 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})`; - } - - outcomeLabel(outcome: MekCriticalRollOutcome): string { - if (!outcome.applied) { - const reason = outcome.reason === 'already-damaged' - ? 'already damaged' - : outcome.reason === 'unhittable' ? 'unhittable' : 'empty'; - return `Slot ${outcome.slotNumber} is ${reason} — rerolling.`; - } - if (outcome.armoredAbsorption) { - return `Slot ${outcome.slotNumber}: ${outcome.equipment} — armored slot absorbs the hit.`; - } - return `Slot ${outcome.slotNumber}: ${outcome.equipment} critical slot destroyed.`; - } - - locationName(location: string): string { - return getMekLocationLabel(location) ?? location; - } - - close(): void { - this.dialogRef.close({ - completed: this.data.requiredHits === undefined || this.complete(), - }); - } -} diff --git a/src/app/components/page-viewer/mek-floating-critical-dialog.component.scss b/src/app/components/page-viewer/mek-floating-critical-dialog.component.scss new file mode 100644 index 000000000..8722f1048 --- /dev/null +++ b/src/app/components/page-viewer/mek-floating-critical-dialog.component.scss @@ -0,0 +1,84 @@ +.floating-location-options { + align-self: stretch; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 2px; +} + +.floating-location-option { + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + min-height: 34px; + min-width: 0; + padding: 0; + border: 1px solid var(--border-color); + background: var(--background-input); + color: var(--text-color); + font: inherit; + text-align: left; + cursor: pointer; +} + +.floating-location-option:not(:disabled):hover, +.floating-location-option:not(:disabled):focus-visible, +.floating-location-option.selected { + border-color: var(--bt-yellow); + background: color-mix(in srgb, var(--bt-yellow) 14%, var(--background-input)); + outline: none; +} + +.floating-location-option:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.floating-location-number { + align-self: stretch; + display: grid; + place-items: center; + background: var(--border-color); + font-size: 0.82em; + font-weight: 800; + font-variant-numeric: tabular-nums; +} + +.floating-location-option.selected .floating-location-number { + background: var(--bt-yellow); + color: #000; +} + +.floating-location-name { + display: flex; + align-items: center; + min-width: 0; + padding: 5px 10px; +} + +.floating-tripod-leg-picker { + align-self: stretch; + display: grid; + gap: 6px; + padding: 8px; + border: 1px solid var(--bt-yellow); + background: color-mix(in srgb, var(--bt-yellow) 10%, transparent); +} + +.floating-tripod-leg-picker > span { + color: var(--text-color-secondary); + font-size: 0.8em; +} + +.floating-tripod-leg-options { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 4px; +} + +.floating-tripod-leg-options .bt-button { + min-width: 0; +} + +.floating-tripod-leg-options .bt-button.selected { + border-color: var(--bt-yellow); + color: var(--bt-yellow); +} 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 new file mode 100644 index 000000000..ac0ec6acd --- /dev/null +++ b/src/app/components/page-viewer/mek-floating-critical-dialog.component.spec.ts @@ -0,0 +1,126 @@ +// 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 { ComponentFixture, TestBed } from '@angular/core/testing'; +import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; +import { + MekFloatingCriticalDialogComponent, + type MekFloatingCriticalDialogData, +} from './mek-floating-critical-dialog.component'; + +describe('MekFloatingCriticalDialogComponent', () => { + let fixture: ComponentFixture; + let close: jasmine.Spy; + let onDraftChange: jasmine.Spy; + let data: MekFloatingCriticalDialogData; + + beforeEach(async () => { + close = jasmine.createSpy('close'); + onDraftChange = jasmine.createSpy('onDraftChange'); + data = { + unit: mockUnit('Biped'), + hitArc: 'left', + onDraftChange, + }; + await TestBed.configureTestingModule({ + imports: [MekFloatingCriticalDialogComponent], + providers: [ + { provide: DialogRef, useValue: { close } }, + { provide: DIALOG_DATA, useValue: data }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(MekFloatingCriticalDialogComponent); + fixture.detectChanges(); + }); + + it('applies a directly selected facing-aware location', () => { + const row = fixture.componentInstance.locationRows.find(candidate => candidate.roll === 7)!; + + fixture.componentInstance.selectLocation(row); + + expect(onDraftChange).toHaveBeenCalledOnceWith([3, 4], null); + expect(fixture.componentInstance.selectedLocation()).toBe('LT'); + expect(close).not.toHaveBeenCalled(); + + fixture.componentInstance.apply(); + + expect(close).toHaveBeenCalledOnceWith({ action: 'apply', location: 'LT' }); + }); + + it('persists exact rolled dice and applies their facing-aware location', () => { + fixture.componentInstance.onFinished({ results: [5, 5], sum: 2 }); + + expect(onDraftChange).toHaveBeenCalledOnceWith([5, 5], null); + expect(fixture.componentInstance.selectedLocation()).toBe('RA'); + expect(close).not.toHaveBeenCalled(); + + fixture.componentInstance.apply(); + + expect(close).toHaveBeenCalledOnceWith({ action: 'apply', location: 'RA' }); + }); + + it('requires a resolved location only for APPLY', () => { + fixture.componentInstance.apply(); + expect(close).not.toHaveBeenCalled(); + + fixture.componentInstance.skip(); + fixture.componentInstance.close(); + + expect(close.calls.allArgs()).toEqual([ + [{ action: 'skip' }], + [undefined], + ]); + }); + + it('restores a pending draft without persisting it again', () => { + fixture.destroy(); + Object.assign(data, { + initialDice: [2, 6] as const, + }); + fixture = TestBed.createComponent(MekFloatingCriticalDialogComponent); + fixture.detectChanges(); + + expect(fixture.componentInstance.initialDice).toEqual([2, 6]); + expect(fixture.componentInstance.locationRoll()).toBe(8); + expect(fixture.componentInstance.selectedLocation()).toBe('CT'); + expect(onDraftChange).not.toHaveBeenCalled(); + }); + + it('requires and applies the additional tripod leg result', () => { + fixture.destroy(); + Object.assign(data, { unit: mockUnit('Tripod'), hitArc: 'front' }); + fixture = TestBed.createComponent(MekFloatingCriticalDialogComponent); + fixture.detectChanges(); + const row = fixture.componentInstance.locationRows.find(candidate => candidate.roll === 5)!; + + fixture.componentInstance.selectLocation(row); + + expect(fixture.componentInstance.needsTripodLegRoll()).toBeTrue(); + fixture.componentInstance.apply(); + expect(close).not.toHaveBeenCalled(); + + fixture.componentInstance.selectTripodLeg(3); + + expect(fixture.componentInstance.selectedLocation()).toBe('CL'); + expect(onDraftChange.calls.allArgs()).toEqual([ + [[2, 3], null], + [[2, 3], 3], + ]); + + fixture.componentInstance.apply(); + + expect(close).toHaveBeenCalledOnceWith({ action: 'apply', location: 'CL' }); + }); +}); + +function mockUnit(subtype: 'Biped' | 'Tripod'): CBTForceUnit { + return { + getUnit: () => ({ + type: 'Mek', + subtype, + comp: [], + }), + } as unknown as CBTForceUnit; +} 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 new file mode 100644 index 000000000..b95f0d7a2 --- /dev/null +++ b/src/app/components/page-viewer/mek-floating-critical-dialog.component.ts @@ -0,0 +1,255 @@ +// 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 { 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, + twoD6ForTotal, + twoD6Total, +} from '../../utils/mek-falling.util'; +import { + clusterTableForUnit, + hitLocationRows, + type MekHitLocationTable, +} from '../../utils/record-sheet-reference-table'; +import { DiceRollerComponent } from '../dice-roller/dice-roller.component'; + +export interface MekFloatingCriticalDialogData { + readonly unit: CBTForceUnit; + readonly hitArc: MekHitArc; + readonly initialDice?: readonly [number, number]; + readonly initialTripodLegRoll?: number; + readonly onDraftChange?: ( + dice: readonly [number, number] | null, + tripodLegRoll: number | null, + ) => void; +} + +export type MekFloatingCriticalDialogResult = + | { readonly action: 'apply'; readonly location: string } + | { readonly action: 'skip' }; + +interface FloatingCriticalLocationRow { + readonly roll: number; + readonly label: string; + readonly requiresTripodLegRoll: boolean; +} + +@Component({ + selector: 'mek-floating-critical-dialog', + standalone: true, + imports: [DiceRollerComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
    +
    Floating Critical: {{ facingLabel }}
    +
    +
    +
    + +
    + +
    +
    + +
    + @for (row of locationRows; track row.roll) { + + } +
    + + @if (needsTripodLegRoll()) { +
    + Tripod leg + Choose the additional 1D6 result. +
    + @for (roll of d6Rolls; track roll) { + + } +
    +
    + } +
    +
    +
    + + + +
    +
    + `, + styleUrls: [ + './overlay/page-psr-warning-panel.component.scss', + './mek-critical-dialog.component.scss', + './mek-floating-critical-dialog.component.scss', + ], +}) +export class MekFloatingCriticalDialogComponent { + private readonly dialogRef = inject(DialogRef); + readonly data = inject(DIALOG_DATA); + readonly roller = viewChild('roller'); + readonly hitLocationTable: MekHitLocationTable = clusterTableForUnit(this.data.unit.getUnit()).hitLocationTable + ?? 'biped'; + readonly facingLabel = floatingCriticalFacingLabel(this.data.hitArc); + 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)); + 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(() => { + const roll = this.locationRoll(); + return roll === null + ? null + : resolveMekFallHitLocation( + this.hitLocationTable, + this.data.hitArc, + roll, + this.tripodLegRoll() ?? undefined, + ); + }); + readonly selectedLocation = computed(() => this.selectedResult()?.location ?? null); + readonly needsTripodLegRoll = computed(() => { + const selected = this.locationRows.find(row => row.roll === this.locationRoll()); + return selected?.requiresTripodLegRoll === true && this.selectedLocation() === null; + }); + + roll(): void { + if (this.isRolling()) return; + this.roller()?.roll(); + } + + 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 === twoD6Total(dice)); + const tripodRoll = row?.requiresTripodLegRoll + ? Math.floor(Math.random() * 6) + 1 + : null; + this.setDraft(dice, tripodRoll); + } + + selectLocation(row: FloatingCriticalLocationRow): void { + if (this.isRolling()) return; + 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 dice = this.hitLocationDice(); + if (!dice) return; + this.setDraft(dice, roll); + } + + apply(): void { + const location = this.selectedLocation(); + if (this.isRolling() || !location) return; + this.dialogRef.close({ action: 'apply', location }); + } + + skip(): void { + if (!this.isRolling()) this.dialogRef.close({ action: 'skip' }); + } + + close(): void { + if (!this.isRolling()) this.dialogRef.close(undefined); + } + + private setDraft( + dice: readonly [number, number], + tripodLegRoll: number | null, + ): void { + this.hitLocationDice.set(dice); + this.tripodLegRoll.set(tripodLegRoll); + this.data.onDraftChange?.(dice, tripodLegRoll); + } +} + +function floatingLocationRow( + table: MekHitLocationTable, + roll: number, + hitArc: MekHitArc, +): FloatingCriticalLocationRow { + const result = resolveMekFallHitLocation(table, hitArc, roll); + return { + roll, + label: result.locationLabel ?? result.tableLabel, + requiresTripodLegRoll: result.location === null, + }; +} + +function floatingCriticalFacingLabel(hitArc: MekHitArc): string { + if (hitArc === 'left') return 'Left Side'; + if (hitArc === 'right') return 'Right Side'; + if (hitArc === 'rear') return 'Rear'; + return 'Front'; +} + +function validTripodLegRoll(value: number | undefined): number | null { + return value !== undefined && Number.isInteger(value) && value >= 1 && value <= 6 ? value : null; +} + +function validDice(value: readonly number[] | undefined, count: number): readonly number[] | null { + return value !== undefined + && value.length === count + && value.every(die => Number.isInteger(die) && die >= 1 && die <= 6) + ? value + : null; +} 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 34949e5af..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 @@
    } @@ -10,51 +13,30 @@ @if (turnTrackerVisible()) {
    - @if (falling()) { - - } - @if (hasActionablePSRChecks()) { - - } + -
    -} \ No newline at end of file +} 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 afc96d92e..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 @@ -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; @@ -44,7 +48,9 @@ position: absolute; top: 8px; right: 8px; + left: 8px; display: flex; + justify-content: flex-end; align-items: center; gap: 8px; pointer-events: auto; @@ -97,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; @@ -109,72 +120,65 @@ } } +.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); 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; + stroke-linejoin: miter; + stroke-miterlimit: 10; + vector-effect: non-scaling-stroke; + transform: scale(1.13); + transform-box: fill-box; + transform-origin: center; } text { pointer-events: none; font-weight: bold; - fill: #fff; + fill: var(--turn-movement-foreground); } - &.move .octagon { - fill: var(--phase-move); + &.stationary { + --turn-movement-fill: var(--move-stationary); + --turn-movement-foreground: var(--move-on-dark); } - &.ranged .octagon { - fill: var(--phase-ranged); + &.walk { + --turn-movement-fill: var(--move-walk); + --turn-movement-foreground: var(--move-on-light); } - &.physical .octagon { - fill: var(--phase-physical); + &.run { + --turn-movement-fill: var(--move-run); + --turn-movement-foreground: var(--move-on-dark); } - &.heat .octagon { - fill: var(--phase-heat); + &.jump { + --turn-movement-fill: var(--move-jump); + --turn-movement-foreground: var(--move-on-dark); } - } - - :host-context(.night-mode) & { - color: #fff; - - svg text { - fill: #000; - } - - svg .octagon { - stroke-width: 0; - } - } -} - -.check-warning-button { - width: 40px; - height: 40px; - - svg { - width: 100%; - height: 100%; - - &.warning { - color: var(--damage-color); - - text { - pointer-events: none; - font-weight: bold; - fill: #000; - } + &.sprint { + --turn-movement-fill: var(--move-sprint); + --turn-movement-foreground: var(--move-on-light); } } } @@ -184,8 +188,7 @@ gap: 10px; .overlay-round-button, - .turn-tracker-button, - .check-warning-button { + .turn-tracker-button { width: 46px; height: 46px; } @@ -201,4 +204,4 @@ :host { display: none !important; } -} \ No newline at end of file +} 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 new file mode 100644 index 000000000..f4ac37167 --- /dev/null +++ b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.spec.ts @@ -0,0 +1,122 @@ +// 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 { CBTEndTurnService } from '../../../services/cbt-end-turn.service'; +import { CBTPhaseResolutionService } from '../../../services/cbt-phase-resolution.service'; +import { DataService } from '../../../services/data.service'; +import { DialogsService } from '../../../services/dialogs.service'; +import { EquipmentInteractionRegistryService } from '../../../services/equipment-interaction-registry.service'; +import { ForceBuilderService } from '../../../services/force-builder.service'; +import { LoggerService } from '../../../services/logger.service'; +import { OptionsService } from '../../../services/options.service'; +import { OverlayManagerService } from '../../../services/overlay-manager.service'; +import { ToastService } from '../../../services/toast.service'; +import { PageViewerStateService } from '../internal/page-viewer-state.service'; +import { PageInteractionOverlayComponent } from './page-interaction-overlay.component'; + +describe('PageInteractionOverlayComponent pending work', () => { + let fixture: ComponentFixture; + let resumePendingChain: jasmine.Spy; + 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, + getTotalTargetModifierAsDefender: () => ({ modifier: defenderModifier() }), + autoFall: () => false, + actionablePSRRollsCount: () => 0, + PSRRollsCount: () => 0, + getPSRChecks: () => [], + getPSROutcome: () => undefined, + pendingCriticalChanceCount: () => 0, + pendingCriticalHitCount: () => 0, + getPendingCriticalChances: () => [], + getPendingCriticalHits: () => [], + getPendingEvents: () => [], + pendingUnitCheckCount: () => 0, + actionablePendingUnitChecks: () => [], + }; + unit = { + id: 'unit-a', + gameRules: { aggregatedEndPhaseConsciousRolls: true }, + rules: { controlRollFullLabel: 'Piloting Skill Rolls' }, + turnState: () => turnState, + pendingFallCount: () => 0, + PSRTargetRoll: () => 7, + } as unknown as CBTForceUnit; + + await TestBed.configureTestingModule({ + imports: [PageInteractionOverlayComponent], + providers: [ + provideZonelessChangeDetection(), + { provide: LoggerService, useValue: {} }, + { provide: DialogsService, useValue: {} }, + { provide: OverlayManagerService, useValue: { closeAllManagedOverlays } }, + { provide: OptionsService, useValue: { options: () => ({ trackPhaseAndTurn: true }) } }, + { provide: Overlay, useValue: {} }, + { + provide: PageViewerStateService, + useValue: { inventoryDialogOpen: signal(false), forceUnits: signal([]) }, + }, + { provide: DataService, useValue: {} }, + { provide: EquipmentInteractionRegistryService, useValue: {} }, + { provide: ForceBuilderService, useValue: {} }, + { provide: ToastService, useValue: {} }, + { provide: CBTEndTurnService, useValue: {} }, + { + provide: CBTPhaseResolutionService, + useValue: { endPhase: resolvePhase, resumePendingChain }, + }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(PageInteractionOverlayComponent); + fixture.componentRef.setInput('unit', unit); + fixture.detectChanges(); + }); + + it('routes a pending-work activation through the shared resolver', async () => { + const event = jasmine.createSpyObj('event', ['stopPropagation']); + + fixture.componentInstance.openNotification({ kind: 'unit-check', event }); + await fixture.whenStable(); + + expect(event.stopPropagation).toHaveBeenCalledTimes(1); + expect(closeAllManagedOverlays).toHaveBeenCalledTimes(1); + expect(resumePendingChain).toHaveBeenCalledOnceWith(unit); + }); + + it('delegates phase completion to the shared phase resolver', async () => { + const event = jasmine.createSpyObj('event', ['stopPropagation']); + + await fixture.componentInstance.endPhase(event); + + 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 6ea9c3a26..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 @@ -17,7 +17,6 @@ import { CommonModule } from '@angular/common'; import { Overlay } from '@angular/cdk/overlay'; import { ComponentPortal } from '@angular/cdk/portal'; import { outputToObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { OptionsService } from '../../../services/options.service'; import { DialogsService } from '../../../services/dialogs.service'; import { LoggerService } from '../../../services/logger.service'; import { OverlayManagerService } from '../../../services/overlay-manager.service'; @@ -25,15 +24,22 @@ import { DataService } from '../../../services/data.service'; import { createHandlerCommandContext, createHandlerQueryContext, EquipmentInteractionRegistryService } from '../../../services/equipment-interaction-registry.service'; import { ForceBuilderService } from '../../../services/force-builder.service'; import { ToastService } from '../../../services/toast.service'; +import { CBTEndTurnService } from '../../../services/cbt-end-turn.service'; +import { CBTPhaseResolutionService } from '../../../services/cbt-phase-resolution.service'; import type { CBTForceUnit } from '../../../models/cbt-force-unit.model'; import type { CBTForce } from '../../../models/cbt-force.model'; import { togglePsrWarningOverlay } from './page-psr-warning-panel.component'; import { PageTurnSummaryPanelComponent } from './page-turn-summary-panel.component'; -import { countActionablePsrChecks } from './page-turn-summary.util'; import { PageViewerStateService } from '../internal/page-viewer-state.service'; import { EquipmentDialogComponent } from '../../equipment-dialog/equipment-dialog.component'; import type { EquipmentDialogContext, EquipmentDialogData } from '../../equipment-dialog/equipment-dialog.model'; +import { + UnitNotificationBadgesComponent, + 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'; +import { runWithTurnSummaryCloseBlocked } from './page-turn-summary.util'; const PAGE_TARGETS_OVERLAY_PREFIX = 'page-viewer-targets'; @@ -47,7 +53,7 @@ const PAGE_TARGETS_OVERLAY_PREFIX = 'page-viewer-targets'; @Component({ selector: 'page-interaction-overlay', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule], + imports: [CommonModule, UnitNotificationBadgesComponent], templateUrl: './page-interaction-overlay.component.html', host: { '[class.fixed-mode]': 'mode() === "fixed"' @@ -60,7 +66,6 @@ export class PageInteractionOverlayComponent { private destroyRef = inject(DestroyRef); private dialogsService = inject(DialogsService); private overlayManager = inject(OverlayManagerService); - private optionsService = inject(OptionsService); private overlay = inject(Overlay); private host = inject(ElementRef); private pageViewerState = inject(PageViewerStateService); @@ -68,6 +73,8 @@ export class PageInteractionOverlayComponent { private equipmentRegistryService = inject(EquipmentInteractionRegistryService); private forceBuilderService = inject(ForceBuilderService); private toastService = inject(ToastService); + private cbtEndTurnService = inject(CBTEndTurnService); + private phaseResolution = inject(CBTPhaseResolutionService); private targetsOverlay = new WeaponTargetsOverlayController({ overlay: this.overlay, overlayManager: this.overlayManager, @@ -90,47 +97,22 @@ 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(); }); - falling = computed(() => { + movementIndicator = computed(() => { const unit = this.unit(); - if (!unit) return false; - return unit.turnState().autoFall(); - }); - - private pendingPSRChecks = computed(() => { - const unit = this.unit(); - if (!unit) return []; + if (!unit) return null; const turnState = unit.turnState(); - return turnState.getPSRChecks().filter(check => - check.fallCheck !== undefined - && check.id !== undefined - && turnState.getPSROutcome(check.id) === undefined + return getTurnMovementIndicator( + turnState.moveMode(), + turnState.getTotalTargetModifierAsDefender().modifier, ); }); - actionablePSRCount = computed(() => { - return countActionablePsrChecks(this.pendingPSRChecks(), this.falling()); - }); - - hasActionablePSRChecks = computed(() => this.actionablePSRCount() > 0); - - currentPhase = computed(() => { - const unit = this.unit(); - if (!unit) return ''; - return unit.turnState().currentPhase(); - }); - endTurnButtonVisible = computed(() => { const force = this.force(); if (!force) return false; @@ -192,12 +174,72 @@ export class PageInteractionOverlayComponent { } } - openPsrWarning(event: MouseEvent): void { + openNotification({ kind, event }: UnitNotificationActivation): void { + switch (kind) { + case 'fall': + if ((this.unit()?.pendingFallCount?.() ?? 0) > 0) { + void this.openPendingFalls(event); + } else { + this.openPsrWarning(event); + } + break; + case 'psr': + void this.openPendingUnitChecks(event); + break; + case 'critical-chance': + void this.openPendingCriticalChances(event); + break; + case 'critical-hit': + void this.openPendingCriticalHits(event); + break; + case 'unit-check': + void this.openPendingUnitChecks(event); + break; + } + } + + openPsrWarning(event: Event): void { event.stopPropagation(); if (!this.turnTrackerVisible()) return; togglePsrWarningOverlay(this, this.overlayManager, this.injector, this.overlay, () => this.closeAllOverlays()); } + async openPendingCriticalHits(event: Event): Promise { + event.stopPropagation(); + if (!this.turnTrackerVisible()) return; + const unit = this.unit(); + if (!unit) return; + this.closeAllOverlays(); + await this.phaseResolution.resumePendingChain(unit); + } + + async openPendingCriticalChances(event: Event): Promise { + event.stopPropagation(); + if (!this.turnTrackerVisible()) return; + const unit = this.unit(); + if (!unit) return; + this.closeAllOverlays(); + await this.phaseResolution.resumePendingChain(unit); + } + + async openPendingUnitChecks(event: Event): Promise { + event.stopPropagation(); + if (!this.turnTrackerVisible()) return; + const unit = this.unit(); + if (!unit) return; + this.closeAllOverlays(); + await this.phaseResolution.resumePendingChain(unit); + } + + async openPendingFalls(event: Event): Promise { + event.stopPropagation(); + if (!this.turnTrackerVisible()) return; + const unit = this.unit(); + if (!unit) return; + this.closeAllOverlays(); + await this.phaseResolution.resumePendingChain(unit); + } + openTargets(event: MouseEvent): void { event.stopPropagation(); if (!this.turnTrackerVisible()) return; @@ -257,25 +299,35 @@ 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(); - units.forEach(unit => unit.endTurn()); + await this.cbtEndTurnService.endTurn(units); } - endPhase(event: MouseEvent): void { + async endPhase(event: MouseEvent): Promise { event.stopPropagation(); - this.unit()?.endPhase(); + const unit = this.unit(); + if (!unit) return; + + this.closeAllOverlays(); + await this.phaseResolution.endPhase(unit); } - endTurn(event: MouseEvent): void { + async endTurn(event: MouseEvent): Promise { event.stopPropagation(); - this.unit()?.endTurn(); + const unit = this.unit(); + if (unit) await this.cbtEndTurnService.endTurn([unit]); } /** 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 325d8ce94..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 @@ -1,6 +1,6 @@
    {{ controlRollFullLabel() }}
    - @if (!allChecksAutomaticFailure()) { + @if (showRollDetails()) {
    Target roll {{ unit()?.PSRTargetRoll() }} @@ -12,7 +12,8 @@ @if (check.fallCheck !== undefined) {
    + [class.automatic-failure]="isAutomaticFailure(check)" + [class.cascade-failure]="isCascadedFailure(check)">
    {{ i + 1 }}
    @@ -22,29 +23,42 @@ {{ location }} } - Failure: {{ check.failureOutcome }} + Failure: {{ failureLabel(check) }}
    @if (!isAutomaticFailure(check) && outcome(check); as result) { + @if (diceFor(check); as dice) { + + }
    {{ result }}
    }
    @if (isAutomaticFailure(check)) {
    AUTOMATIC FAILURE
    - } @else if (!outcome(check)) { -
    + } @else if (isCascadedFailure(check)) { +
    FAILED — PREVIOUS FALL CHECK FAILED
    + } @else if (canEditOutcome(check)) { +
    - - + +
    }
    } }
    - @if (!allChecksAutomaticFailure()) { + @if (showRollDetails()) {
    @if (modifiersList().length > 0) {
    Modifiers
    @@ -66,7 +80,9 @@ }
    - + +
    diff --git a/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.scss b/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.scss index d80fabd94..bcfd3a75e 100644 --- a/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.scss +++ b/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.scss @@ -10,7 +10,7 @@ box-sizing: border-box; display: flex; flex-direction: column; - padding: 8px; + padding: 0; gap: 8px; transition: opacity 0.2s; max-height: 80dvh; @@ -20,6 +20,7 @@ .header { flex: 0 0 auto; + padding: 8px 8px 0; font-weight: bold; text-align: center; } @@ -27,9 +28,9 @@ .body { flex: 1 1 auto; min-height: 0; + padding: 0 8px; overflow-x: hidden; overflow-y: auto; - color: var(--text-color-secondary); } .psr-list { @@ -57,6 +58,10 @@ border-left-color: var(--danger); } +.psr-item.cascade-failure { + opacity: 0.65; +} + .psr-check-header { display: flex; align-items: center; @@ -127,6 +132,11 @@ color: var(--danger); } +.psr-saved-dice { + flex-shrink: 0; + pointer-events: none; +} + .psr-resolution-actions { display: grid; grid-template-columns: 24px 1fr 1fr; @@ -134,29 +144,8 @@ margin-top: 8px; } -.random-button { - width: 24px; - min-width: 24px; - height: 32px; - padding: 0; - border: none; - background: transparent url('/images/random.svg') center / 20px 20px no-repeat; - cursor: pointer; - opacity: 0.8; - transition: opacity 0.2s ease-in-out; -} - -.random-button:hover, -.random-button:focus-visible { - opacity: 1; -} - -.random-button:disabled { - cursor: not-allowed; - opacity: 0.35; -} - -.psr-automatic-failure { +.psr-automatic-failure, +.psr-cascade-failure { margin-top: 8px; padding-left: 32px; color: var(--danger); @@ -227,6 +216,7 @@ flex: 0 0 auto; display: grid; grid-template-columns: minmax(0, 1fr) var(--roll-value-width); + margin: 0 8px; background: var(--background-input); min-height: 42px; color: var(--text-color); @@ -254,8 +244,11 @@ flex: 0 0 auto; display: flex; justify-content: center; + gap: 8px; + padding: 0 8px 8px; .bt-button { - width: 100%; + flex: 1 1 0; + min-width: 0; } } 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 68a6cff27..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,9 +8,14 @@ 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, psrRollOutcome, togglePsrWarningOverlay } from './page-psr-warning-panel.component'; +import { PagePsrWarningPanelComponent, PSR_WARNING_UNIT, psrRollOutcome, togglePsrWarningOverlay } from './page-psr-warning-panel.component'; describe('togglePsrWarningOverlay', () => { it('keeps Turn Summary open until PSR Warning closes', () => { @@ -51,18 +56,43 @@ describe('psrRollOutcome', () => { }); describe('PagePsrWarningPanelComponent', () => { - it('rolls 2d6 from the action column and resolves against the target roll', () => { - const check = { id: 'fall-check', fallCheck: 0, loc: 'RL', reason: 'Hip hit', failureOutcome: 'Fall' }; - const damageCheck = { id: 'damage-check', fallCheck: 1, reason: 'Received 20 damage', failureOutcome: 'Fall' }; - const resolvePSRCheck = jasmine.createSpy('resolvePSRCheck'); + it('stages virtual and physical-dice outcomes until the results are accepted', () => { + const check: PSRCheck = { + 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', + 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( + (checkId: string, outcome: 'success' | 'failed') => { + outcomes.set(checkId, outcome); + return true; + }, + ); + const closeManagedOverlay = jasmine.createSpy('closeManagedOverlay'); const turnState = { getPSRChecks: () => [check, damageCheck], - getPSROutcome: () => undefined, + getPSROutcome: (checkId: string) => outcomes.get(checkId), resolvePSRCheck, autoFall: () => false, + isPSRCheckAutomaticFailure: () => false, + dmgReceived: () => 0, }; const unit = { id: 'unit-1', + getUnit: () => ({ type: 'Mek' }), + getCondition: () => false, + automationMode: () => 'ask', rules: { controlRollFullLabel: 'Piloting Skill Rolls' }, turnState: () => turnState, PSRTargetRoll: () => 8, @@ -82,8 +112,15 @@ describe('PagePsrWarningPanelComponent', () => { TestBed.configureTestingModule({ imports: [PagePsrWarningPanelComponent], providers: [ - { provide: PageInteractionOverlayComponent, useValue: { unit: signal(unit) } }, - { provide: OverlayManagerService, useValue: { closeManagedOverlay: jasmine.createSpy('closeManagedOverlay') } }, + { + provide: PSR_WARNING_UNIT, + useValue: signal({ + ...unit, + psrOutcomeSelections: signal>>({}), + psrDiceSelections: signal>>({}), + }), + }, + { provide: OverlayManagerService, useValue: { closeManagedOverlay } }, ], }); const fixture = TestBed.createComponent(PagePsrWarningPanelComponent); @@ -93,48 +130,41 @@ describe('PagePsrWarningPanelComponent', () => { .componentInstance as DiceRollerComponent; spyOn(roller, 'roll'); - const panel = fixture.nativeElement.querySelector('.panel') as HTMLElement; - const body = panel.querySelector('.body') as HTMLElement; - expect(Array.from(panel.children).map(child => child.className)).toEqual([ - 'header', 'psr-target', 'body', 'actions' - ]); - expect(getComputedStyle(panel).overflowY).toBe('hidden'); - expect(getComputedStyle(body).overflowY).toBe('auto'); - - const actions = fixture.nativeElement.querySelector('.psr-resolution-actions') as HTMLElement; - const subtitles = fixture.nativeElement.querySelectorAll('.psr-subtitle') as NodeListOf; - const modifierLocations = fixture.nativeElement.querySelectorAll('.modifier-location') as NodeListOf; - const modifierReasons = fixture.nativeElement.querySelectorAll('.modifier-reason') as NodeListOf; - const rollButton = actions.firstElementChild as HTMLButtonElement; - rollButton.click(); + fixture.componentInstance.roll(check); fixture.componentInstance.onRollFinished({ results: [4, 4], sum: 8 }); - expect(rollButton.classList).toContain('random-button'); - expect(roller.diceCount()).toBe(2); - expect(roller.diceSides()).toBe(6); expect(roller.roll).toHaveBeenCalledTimes(1); - expect(resolvePSRCheck).toHaveBeenCalledOnceWith('fall-check', 'success'); + expect(resolvePSRCheck).not.toHaveBeenCalled(); + expect(fixture.componentInstance.outcome(check)).toBe('success'); expect(fixture.componentInstance.rolledResult()).toBe('SUCCESS'); - expect(subtitles[0].textContent?.replace(/\s+/g, ' ').trim()).toBe('Right Leg — Failure: Fall'); - expect(subtitles[1].textContent?.replace(/\s+/g, ' ').trim()).toBe('Failure: Fall'); - expect(Array.from(modifierLocations, location => location.textContent?.trim())).toEqual(['—', 'RL', 'LL']); - expect(Array.from(modifierReasons, reason => reason.textContent?.trim())).toEqual([ - 'Gyro hit', - 'Hip hit', - 'Hip hit, Leg Actuators hit (2)', + expect(fixture.componentInstance.diceFor(check)).toEqual([4, 4]); + expect(fixture.componentInstance.canAccept()).toBeFalse(); + + fixture.componentInstance.selectOutcome(damageCheck, 'success'); + expect(fixture.componentInstance.canAccept()).toBeTrue(); + expect(resolvePSRCheck).not.toHaveBeenCalled(); + + fixture.componentInstance.accept(); + + expect(resolvePSRCheck.calls.allArgs()).toEqual([ + ['fall-check', 'success'], + ['damage-check', 'success'], ]); - expect(new Set(Array.from(modifierLocations, location => location.getBoundingClientRect().width)).size).toBe(1); + expect(closeManagedOverlay).toHaveBeenCalledOnceWith('psrWarning-unit-1'); }); - it('retains a resolved rule check long enough to display its outcome', () => { + 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]; let status: 'pending' | 'success' | 'failed' = 'pending'; + const closeManagedOverlay = jasmine.createSpy('closeManagedOverlay'); const resolveRuleCheck = jasmine.createSpy('resolveRuleCheck').and.callFake( (_key: string, _token: string, result: 'success' | 'failed') => { status = result; @@ -147,9 +177,14 @@ describe('PagePsrWarningPanelComponent', () => { getPSROutcome: () => undefined, resolvePSRCheck: jasmine.createSpy('resolvePSRCheck'), autoFall: () => false, + isPSRCheckAutomaticFailure: () => false, + dmgReceived: () => 0, }; const unit = { id: 'unit-1', + getUnit: () => ({ type: 'Mek' }), + getCondition: () => false, + automationMode: () => 'ask', rules: { controlRollFullLabel: 'Piloting Skill Rolls' }, turnState: () => turnState, PSRTargetRoll: () => 8, @@ -161,16 +196,231 @@ describe('PagePsrWarningPanelComponent', () => { TestBed.configureTestingModule({ imports: [PagePsrWarningPanelComponent], providers: [ - { provide: PageInteractionOverlayComponent, useValue: { unit: signal(unit) } }, - { provide: OverlayManagerService, useValue: { closeManagedOverlay: jasmine.createSpy('closeManagedOverlay') } }, + { + provide: PSR_WARNING_UNIT, + useValue: signal({ + ...unit, + psrOutcomeSelections: signal>>({}), + }), + }, + { provide: OverlayManagerService, useValue: { closeManagedOverlay } }, ], }); const fixture = TestBed.createComponent(PagePsrWarningPanelComponent); - fixture.componentInstance.resolve(check, 'success'); + fixture.componentInstance.selectOutcome(check, 'success'); - expect(resolveRuleCheck).toHaveBeenCalledOnceWith('risc-shutdown', 'token-1', 'success'); + expect(resolveRuleCheck).not.toHaveBeenCalled(); expect(fixture.componentInstance.psrChecks()).toEqual([check]); expect(fixture.componentInstance.outcome(check)).toBe('success'); + + fixture.componentInstance.accept(); + + expect(resolveRuleCheck).toHaveBeenCalledOnceWith('risc-shutdown', 'token-1', 'success'); + expect(closeManagedOverlay).toHaveBeenCalledOnceWith('psrWarning-unit-1'); + }); + + it('keeps provisional choices when closed and restores them when reopened', () => { + const check: PSRCheck = { + 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'); + const turnState = { + getPSRChecks: () => [check], + getPSROutcome: () => undefined, + resolvePSRCheck, + autoFall: () => false, + isPSRCheckAutomaticFailure: () => false, + dmgReceived: () => 0, + }; + const unit = { + id: 'unit-1', + getUnit: () => ({ type: 'Mek' }), + getCondition: () => false, + automationMode: () => 'ask', + rules: { controlRollFullLabel: 'Piloting Skill Rolls' }, + turnState: () => turnState, + PSRTargetRoll: () => 8, + PSRModifiers: () => ({ modifiers: [] }), + resolveRuleCheck: jasmine.createSpy('resolveRuleCheck'), + }; + + const psrOutcomeSelections = signal>>({}); + const psrDiceSelections = signal>>({}); + const parent = { unit: signal({ ...unit, psrOutcomeSelections, psrDiceSelections }) }; + TestBed.configureTestingModule({ + imports: [PagePsrWarningPanelComponent], + providers: [ + { provide: PSR_WARNING_UNIT, useValue: parent.unit }, + { provide: OverlayManagerService, useValue: { closeManagedOverlay } }, + ], + }); + const fixture = TestBed.createComponent(PagePsrWarningPanelComponent); + + fixture.componentInstance.selectOutcome(check, 'failed', [5, 2]); + fixture.componentInstance.close(); + + expect(resolvePSRCheck).not.toHaveBeenCalled(); + expect(unit.resolveRuleCheck).not.toHaveBeenCalled(); + expect(closeManagedOverlay).toHaveBeenCalledOnceWith('psrWarning-unit-1'); + expect(psrOutcomeSelections()).toEqual({ 'fall-check': 'failed' }); + expect(psrDiceSelections()).toEqual({ 'fall-check': [5, 2] }); + + fixture.destroy(); + closeManagedOverlay.calls.reset(); + const reopenedFixture = TestBed.createComponent(PagePsrWarningPanelComponent); + reopenedFixture.detectChanges(); + + expect(reopenedFixture.componentInstance.outcome(check)).toBe('failed'); + expect(reopenedFixture.componentInstance.canAccept()).toBeTrue(); + expect(reopenedFixture.componentInstance.diceFor(check)).toEqual([5, 2]); + + reopenedFixture.componentInstance.accept(); + + expect(resolvePSRCheck).toHaveBeenCalledOnceWith('fall-check', 'failed'); + expect(psrOutcomeSelections()).toEqual({}); + expect(psrDiceSelections()).toEqual({}); + expect(closeManagedOverlay).toHaveBeenCalledOnceWith('psrWarning-unit-1'); + }); + + it('locks later Fall checks as failed while preserving independent checks', () => { + const checks: PSRCheck[] = [ + { + 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, + getPSROutcome: () => undefined, + resolvePSRCheck, + autoFall: () => false, + isPSRCheckAutomaticFailure: () => false, + dmgReceived: () => 0, + }; + const unit = { + id: 'unit-1', + getUnit: () => ({ type: 'Mek' }), + getCondition: () => false, + automationMode: () => 'ask', + rules: { controlRollFullLabel: 'Piloting Skill Rolls' }, + turnState: () => turnState, + PSRTargetRoll: () => 8, + PSRModifiers: () => ({ modifiers: [] }), + getRuleCheck: () => undefined, + resolveRuleCheck, + }; + + TestBed.configureTestingModule({ + imports: [PagePsrWarningPanelComponent], + providers: [ + { + provide: PSR_WARNING_UNIT, + useValue: signal({ + ...unit, + psrOutcomeSelections: signal>>({}), + }), + }, + { provide: OverlayManagerService, useValue: { closeManagedOverlay } }, + ], + }); + const fixture = TestBed.createComponent(PagePsrWarningPanelComponent); + fixture.detectChanges(); + const component = fixture.componentInstance; + + component.selectOutcome(checks[0], 'success'); + component.selectOutcome(checks[1], 'failed'); + fixture.detectChanges(); + + expect(component.outcome(checks[0])).toBe('success'); + expect(component.outcome(checks[1])).toBe('failed'); + expect(component.outcome(checks[2])).toBeUndefined(); + expect(component.outcome(checks[3])).toBe('failed'); + expect(component.isCascadedFailure(checks[3])).toBeTrue(); + expect(resolvePSRCheck).not.toHaveBeenCalled(); + + expect(component.canAccept()).toBeFalse(); + + component.selectOutcome(checks[2], 'success'); + fixture.detectChanges(); + expect(component.canAccept()).toBeTrue(); + + component.accept(); + + expect(resolvePSRCheck.calls.allArgs()).toEqual([ + ['first-fall', 'success'], + ['second-fall', 'failed'], + ['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', + }; + const turnState = { + getPSRChecks: () => [check], + getPSROutcome: () => undefined, + resolvePSRCheck: jasmine.createSpy('resolvePSRCheck'), + autoFall: () => false, + isPSRCheckAutomaticFailure: () => true, + dmgReceived: () => 0, + }; + const unit = { + id: 'unit-1', + rules: { controlRollFullLabel: 'Piloting Skill Rolls' }, + turnState: () => turnState, + PSRTargetRoll: () => 11, + PSRModifiers: () => ({ modifiers: [] }), + psrOutcomeSelections: signal>>({}), + psrDiceSelections: signal>>({}), + resolveRuleCheck: jasmine.createSpy('resolveRuleCheck'), + }; + + TestBed.configureTestingModule({ + imports: [PagePsrWarningPanelComponent], + providers: [ + { provide: PSR_WARNING_UNIT, useValue: signal(unit) }, + { provide: OverlayManagerService, useValue: { closeManagedOverlay: jasmine.createSpy('closeManagedOverlay') } }, + ], + }); + const fixture = TestBed.createComponent(PagePsrWarningPanelComponent); + fixture.detectChanges(); + + expect(fixture.componentInstance.isAutomaticFailure(check)).toBeTrue(); + expect(fixture.componentInstance.outcome(check)).toBe('failed'); + expect(fixture.componentInstance.allChecksAutomaticFailure()).toBeTrue(); + expect(fixture.componentInstance.showRollDetails()).toBeFalse(); + expect(fixture.nativeElement.querySelector('.psr-automatic-failure')?.textContent.trim()) + .toBe('AUTOMATIC FAILURE'); }); + }); 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 3c57890a8..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 @@ -2,16 +2,33 @@ // 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, type Signal, viewChild } from '@angular/core'; +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 { PageInteractionOverlayComponent } from './page-interaction-overlay.component'; +import type { CBTForceUnit } from '../../../models/cbt-force-unit.model'; +import type { PageInteractionOverlayComponent } from './page-interaction-overlay.component'; import { displayPsrModifiers, openTurnSummaryChildOverlay } from './page-turn-summary.util'; import { getMekLocationLabel } from '../../../models/entity/types'; +type PsrOutcome = 'success' | 'failed'; +type PsrOutcomeSource = 'committed' | 'selected' | 'cascade' | 'automatic'; + +interface PsrOutcomeState { + readonly outcome: PsrOutcome; + readonly source: PsrOutcomeSource; +} + +export interface PsrWarningDialogData { + readonly unit: CBTForceUnit; +} + +export const PSR_WARNING_UNIT = new InjectionToken>('PSR_WARNING_UNIT'); +const PSR_WARNING_CLOSE = new InjectionToken<() => void>('PSR_WARNING_CLOSE'); + export function psrRollOutcome(sum: number, target: number): 'success' | 'failed' { return sum >= target ? 'success' : 'failed'; } @@ -35,7 +52,11 @@ export function togglePsrWarningOverlay( beforeOpen?.(); const customInjector = Injector.create({ providers: [ - { provide: PageInteractionOverlayComponent, useValue: parent } + { provide: PSR_WARNING_UNIT, useValue: parent.unit }, + { + provide: PSR_WARNING_CLOSE, + useValue: () => overlayManager.closeManagedOverlay(overlayKey), + }, ], parent: injector }); @@ -60,28 +81,41 @@ export function togglePsrWarningOverlay( styleUrl: './page-psr-warning-panel.component.scss', }) export class PagePsrWarningPanelComponent { - private readonly parent = inject(PageInteractionOverlayComponent); + private readonly overlayUnit = inject(PSR_WARNING_UNIT, { optional: true }); + private readonly overlayClose = inject(PSR_WARNING_CLOSE, { optional: true }); private readonly overlayManager = inject(OverlayManagerService); + private readonly dialogData = inject(DIALOG_DATA, { optional: true }); + private readonly dialogRef = inject | null>(DialogRef, { optional: true }); readonly diceRoller = viewChild('roller'); - readonly unit = this.parent.unit; + readonly unit = this.overlayUnit ?? computed(() => this.dialogData?.unit ?? null); readonly rolledResult = signal(null); readonly rolledResultTone = computed<'default' | 'success' | 'failed'>(() => { if (this.rolledResult() === 'SUCCESS') return 'success'; if (this.rolledResult() === 'FAILED') return 'failed'; return 'default'; }); - private readonly retainedResolvedRuleChecks = signal([]); + private readonly selectedOutcomes = computed>>(() => + this.unit()?.psrOutcomeSelections() ?? {}); + private readonly selectedDice = computed>>(() => + this.unit()?.psrDiceSelections?.() ?? {}); private rollingCheck: PSRCheck | null = null; readonly locationLabel = getMekLocationLabel; - close(): void { - const unitId = this.unit()?.id; - this.overlayManager.closeManagedOverlay(`psrWarning-${unitId}`); + close(accepted = false): void { + if (this.dialogRef) { + this.dialogRef.close(accepted); + return; + } + if (this.overlayClose) { + this.overlayClose(); + return; + } + this.overlayManager.closeManagedOverlay(`psrWarning-${this.unit()?.id}`); } roll(check: PSRCheck): void { const roller = this.diceRoller(); - if (!roller || roller.isRolling() || this.outcome(check) || this.isAutomaticFailure(check)) return; + if (!roller || roller.isRolling() || !this.canEditOutcome(check)) return; this.rollingCheck = check; this.rolledResult.set(null); roller.roll(); @@ -95,22 +129,95 @@ export class PagePsrWarningPanelComponent { const result = psrRollOutcome(event.sum, unit.PSRTargetRoll()); this.rolledResult.set(result.toUpperCase()); - this.resolve(check, result); + this.selectOutcome(check, result, validPsrDice(event.results)); + } + + selectOutcome( + check: PSRCheck, + result: PsrOutcome, + dice: readonly [number, number] | null = null, + ): void { + const checkId = check.id; + const unit = this.unit(); + if (!unit || !checkId || !this.canEditOutcome(check)) return; + unit.psrOutcomeSelections.update(current => ({ ...current, [checkId]: result })); + unit.psrDiceSelections?.update(current => { + if (dice) return { ...current, [checkId]: [...dice] as readonly [number, number] }; + const { [checkId]: _removed, ...remaining } = current; + return remaining; + }); + } + + diceFor(check: PSRCheck): readonly [number, number] | null { + return check.id && this.resolutionStates().get(check.id)?.source === 'selected' + ? this.selectedDice()[check.id] ?? null + : null; + } + + outcome(check: PSRCheck): PsrOutcome | undefined { + return check.id ? this.resolutionStates().get(check.id)?.outcome : undefined; + } + + canEditOutcome(check: PSRCheck): boolean { + const source = check.id ? this.resolutionStates().get(check.id)?.source : undefined; + return source === undefined || source === 'selected'; + } + + isCascadedFailure(check: PSRCheck): boolean { + return check.id ? this.resolutionStates().get(check.id)?.source === 'cascade' : false; + } + + isAutomaticFailure(check: PSRCheck): boolean { + const turnState = this.unit()?.turnState(); + return this.committedOutcome(check) === undefined + && turnState !== undefined + && (turnState.isPSRCheckAutomaticFailure(check) + || (turnState.autoFall() && isFallPSRCheck(check))); + } + + failureLabel(check: PSRCheck): string { + return psrFailureLabel(check); } - resolve(check: PSRCheck, result: 'success' | 'failed'): void { + readonly canAccept = computed(() => { + const checks = this.psrChecks(); + const states = this.resolutionStates(); + return checks.length > 0 + && checks.every(check => check.id !== undefined && states.has(check.id)) + && Array.from(states.values()).some(state => state.source !== 'committed'); + }); + + accept(): void { + const unit = this.unit(); + if (!unit || !this.canAccept()) return; + const resolutions = this.psrChecks().flatMap(check => { + if (!check.id) return []; + const state = this.resolutionStates().get(check.id); + return state && state.source !== 'committed' + ? [{ check, outcome: state.outcome }] + : []; + }); + if (resolutions.length === 0) return; + + for (const resolution of resolutions) { + this.applyOutcome(resolution.check, resolution.outcome); + } + unit.psrOutcomeSelections.set({}); + unit.psrDiceSelections?.set({}); + this.close(true); + } + + private applyOutcome(check: PSRCheck, result: PsrOutcome): void { const unit = this.unit(); if (!unit) return; if (check.resolution) { - if (unit.resolveRuleCheck(check.resolution.key, check.resolution.token, result)) { - this.retainResolvedRuleCheck(check); - } + unit.resolveRuleCheck(check.resolution.key, check.resolution.token, result); } else if (check.id) { unit.turnState().resolvePSRCheck(check.id, result); } } - outcome(check: PSRCheck) { + private committedOutcome(check: PSRCheck): PsrOutcome | undefined { const unit = this.unit(); if (!unit) return undefined; if (check.resolution) { @@ -120,12 +227,7 @@ export class PagePsrWarningPanelComponent { } return ruleCheck.status; } - if (!check.id) return undefined; - return unit.turnState().getPSROutcome(check.id); - } - - isAutomaticFailure(check: PSRCheck): boolean { - return this.unit()?.turnState().autoFall() === true && check.failureOutcome === 'Fall'; + return check.id ? unit.turnState().getPSROutcome(check.id) : undefined; } readonly modifiersList = computed(() => { @@ -143,20 +245,38 @@ export class PagePsrWarningPanelComponent { readonly psrChecks = computed(() => { const unit = this.unit(); if (!unit) return []; - const checks = unit.turnState().getPSRChecks() - .filter(check => check.fallCheck !== undefined) - for (const retainedCheck of this.retainedResolvedRuleChecks()) { - if (!retainedCheck.resolution) continue; - const ruleCheck = unit.getRuleCheck(retainedCheck.resolution.key); - if (!ruleCheck - || ruleCheck.token !== retainedCheck.resolution.token - || ruleCheck.status === 'pending' - || checks.some(check => this.sameRuleCheck(check, retainedCheck))) { + return unit.turnState().getPSRChecks() + .filter(check => check.fallCheck !== undefined); + }); + + private readonly resolutionStates = computed>(() => { + const states = new Map(); + const selected = this.selectedOutcomes(); + let priorFallFailed = false; + + for (const check of this.psrChecks()) { + if (!check.id) continue; + const committed = this.committedOutcome(check); + if (committed) { + states.set(check.id, { outcome: committed, source: 'committed' }); + if (committed === 'failed' && isFallPSRCheck(check)) priorFallFailed = true; + continue; + } + if (this.isAutomaticFailure(check)) { + states.set(check.id, { outcome: 'failed', source: 'automatic' }); + if (isFallPSRCheck(check)) priorFallFailed = true; + continue; + } + if (priorFallFailed && isFallPSRCheck(check)) { + states.set(check.id, { outcome: 'failed', source: 'cascade' }); continue; } - checks.push(retainedCheck); + const outcome = selected[check.id]; + if (!outcome) continue; + states.set(check.id, { outcome, source: 'selected' }); + if (outcome === 'failed' && isFallPSRCheck(check)) priorFallFailed = true; } - return checks.sort((left, right) => this.checkDisplayOrder(left) - this.checkDisplayOrder(right)); + return states; }); readonly allChecksAutomaticFailure = computed(() => { @@ -164,23 +284,13 @@ export class PagePsrWarningPanelComponent { return checks.length > 0 && checks.every(check => this.isAutomaticFailure(check)); }); - private checkDisplayOrder(check: PSRCheck): number { - if (this.isAutomaticFailure(check)) return 2; - if (this.outcome(check)) return 1; - return 0; - } - - private retainResolvedRuleCheck(check: PSRCheck): void { - if (!check.resolution) return; - this.retainedResolvedRuleChecks.update(current => - current.some(existing => this.sameRuleCheck(existing, check)) - ? current - : [...current, check] - ); - } + readonly showRollDetails = computed(() => this.psrChecks().length > 0 + && !this.allChecksAutomaticFailure()); +} - private sameRuleCheck(left: PSRCheck, right: PSRCheck): boolean { - return left.resolution?.key === right.resolution?.key - && left.resolution?.token === right.resolution?.token; - } +function validPsrDice(results: readonly number[]): readonly [number, number] | null { + return results.length === 2 + && results.every(value => Number.isInteger(value) && value >= 1 && value <= 6) + ? [results[0], results[1]] + : null; } diff --git a/src/app/components/page-viewer/overlay/page-standing-up-panel.component.html b/src/app/components/page-viewer/overlay/page-standing-up-panel.component.html index dd599555c..fa991e05a 100644 --- a/src/app/components/page-viewer/overlay/page-standing-up-panel.component.html +++ b/src/app/components/page-viewer/overlay/page-standing-up-panel.component.html @@ -89,7 +89,8 @@ [compactOverlayResult]="true" [overlayResult]="rolledResult()" [overlayResultTone]="rolledResultTone()" - overlayCloseHint="Click to return to standing up" + [overlayCloseHint]="rollOverlayCloseHint()" (finished)="onRollFinished($event)" + (overlayClosed)="onRollOverlayClosed()" /> } 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 16f5fa0b0..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 @@ -69,21 +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((fixture.nativeElement.querySelector('.stand-attempt-limit') as HTMLElement).textContent?.trim()) - .toBe('(1 attempt per turn)'); - const carefulStandCheckbox = fixture.nativeElement.querySelector('.careful-stand input') as HTMLInputElement; - expect(carefulStandCheckbox.disabled).toBeFalse(); + expect(component.canCarefulStand()).toBeTrue(); canCarefulStand.set(false); - fixture.detectChanges(); - expect(carefulStandCheckbox.disabled).toBeTrue(); + expect(component.canCarefulStand()).toBeFalse(); canCarefulStand.set(true); - fixture.detectChanges(); 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' }), @@ -95,24 +88,24 @@ describe('PageStandingUpPanelComponent', () => { component.onRollFinished({ results: [3, 3], sum: 6 }); expect(roller.roll).toHaveBeenCalledTimes(1); + expect(resolveStandAttempt).not.toHaveBeenCalled(); + expect(component.lastOutcome()).toBeNull(); + expect(component.rolledResult()).toBe('SUCCESS'); + + component.onRollOverlayClosed(); + expect(resolveStandAttempt).toHaveBeenCalledOnceWith('success', { carefulStand: true }); expect(component.lastOutcome()).toBe('success'); - expect(component.rolledResult()).toBe('SUCCESS'); + expect(component.rolledResult()).toBeNull(); 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(['-', '+']); - - adjustmentButtons[0].click(); - fixture.detectChanges(); + 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); @@ -150,20 +143,24 @@ describe('PageStandingUpPanelComponent', () => { expect(component.modifiersList()).toEqual([]); }); - it('does not offer careful stand under Core rules', () => { + 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: -1, + standingUpPSRModifier: 0, getStandAttemptLimit: () => null, supportsCarefulStand: false, canCarefulStand: () => false, }, turnState: () => ({ - standAttempts: signal(undefined), + standAttempts: signal(0), carefulStand: signal(false), canStandUp: signal(true), canStandWithoutPSR: signal(false), + resolveStandAttempt, + adjustStandAttempts: jasmine.createSpy('adjustStandAttempts'), }), PSRTargetRoll: () => 8, PSRModifiers: () => ({ modifiers: [] }), @@ -173,39 +170,41 @@ describe('PageStandingUpPanelComponent', () => { imports: [PageStandingUpPanelComponent], providers: [ { provide: PageInteractionOverlayComponent, useValue: { unit: signal(unit) } }, - { provide: OverlayManagerService, useValue: { closeManagedOverlay: jasmine.createSpy('closeManagedOverlay') } }, + { provide: OverlayManagerService, useValue: { closeManagedOverlay } }, ], }); - const fixture = TestBed.createComponent(PageStandingUpPanelComponent); - fixture.detectChanges(); + 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(fixture.nativeElement.querySelector('.careful-stand')).toBeNull(); - expect(fixture.componentInstance.carefulStand()).toBeFalse(); + expect(resolveStandAttempt).toHaveBeenCalledOnceWith('failed', { carefulStand: false }); + expect(closeManagedOverlay).toHaveBeenCalledOnceWith('standingUp-unit-1'); }); - it('renders a standing-attempt review without mutable attempt controls (except attempts stepper)', () => { - const attempts = signal(2); - const carefulStand = signal(false); - const resolveStandAttempt = jasmine.createSpy('resolveStandAttempt'); - const adjustStandAttempts = jasmine.createSpy('adjustStandAttempts'); + it('does not allow careful stand under Core rules', () => { const unit = { id: 'unit-1', rules: { standingUpPSRModifier: -1, - getStandAttemptLimit: () => 1, - supportsCarefulStand: true, + getStandAttemptLimit: () => null, + supportsCarefulStand: false, canCarefulStand: () => false, }, turnState: () => ({ - standAttempts: attempts, - carefulStand, + standAttempts: signal(undefined), + carefulStand: signal(false), canStandUp: signal(true), canStandWithoutPSR: signal(false), - resolveStandAttempt, - adjustStandAttempts, }), PSRTargetRoll: () => 8, - PSRModifiers: () => ({ modifiers: [{ pilotCheck: 1, reason: 'Gyro damaged' }] }), + PSRModifiers: () => ({ modifiers: [] }), }; TestBed.configureTestingModule({ @@ -213,46 +212,36 @@ describe('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(); - expect((fixture.nativeElement.querySelector('.header') as HTMLElement).textContent?.trim()) - .toBe('Standing Up Review'); - expect((fixture.nativeElement.querySelector('.attempts strong') as HTMLElement).textContent?.trim()).toBe('2'); - expect(fixture.nativeElement.querySelector('.psr-resolution-actions')).toBeNull(); - expect(fixture.nativeElement.querySelector('.careful-stand')).toBeNull(); - expect(fixture.nativeElement.querySelector('dice-roller')).toBeNull(); + const component = TestBed.createComponent(PageStandingUpPanelComponent).componentInstance; - 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.supportsCarefulStand()).toBeFalse(); expect(component.carefulStand()).toBeFalse(); }); - it('shows only stand attempts when reviewing a unit that can stand without a PSR', () => { + 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: 'quad-1', + id: 'unit-1', rules: { standingUpPSRModifier: -1, - getStandAttemptLimit: () => null, + getStandAttemptLimit: () => 1, supportsCarefulStand: true, - canCarefulStand: () => true, + canCarefulStand: () => false, }, turnState: () => ({ - standAttempts: signal(1), - carefulStand: signal(false), + standAttempts: attempts, + carefulStand, canStandUp: signal(true), - canStandWithoutPSR: signal(true), - adjustStandAttempts: jasmine.createSpy('adjustStandAttempts'), + canStandWithoutPSR: signal(false), + resolveStandAttempt, + adjustStandAttempts, }), PSRTargetRoll: () => 8, PSRModifiers: () => ({ modifiers: [{ pilotCheck: 1, reason: 'Gyro damaged' }] }), @@ -268,12 +257,16 @@ describe('PageStandingUpPanelComponent', () => { }); const fixture = TestBed.createComponent(PageStandingUpPanelComponent); fixture.detectChanges(); + const component = fixture.componentInstance; - expect((fixture.nativeElement.querySelector('.attempts strong') as HTMLElement).textContent?.trim()).toBe('1'); - expect(fixture.nativeElement.querySelector('.psr-target')).toBeNull(); - expect(fixture.nativeElement.querySelector('.psr-list')).toBeNull(); - expect(fixture.nativeElement.querySelector('.careful-stand')).toBeNull(); - expect(fixture.nativeElement.querySelector('.roll-details')).toBeNull(); - expect(fixture.nativeElement.querySelector('dice-roller')).toBeNull(); + 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 55f4ab569..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 @@ -76,12 +76,16 @@ export class PageStandingUpPanelComponent { ); 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; @@ -130,14 +134,23 @@ export class PageStandingUpPanelComponent { 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 { @@ -147,6 +160,7 @@ export class PageStandingUpPanelComponent { this.rolledResult.set(null); if (unit.turnState().resolveStandAttempt(outcome, { carefulStand: this.carefulStand() })) { this.lastOutcome.set(outcome); + if (outcome === 'failed') this.close(); } } @@ -158,5 +172,6 @@ export class PageStandingUpPanelComponent { 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.html b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.html index cfff493bf..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 @@ -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() }}
    @@ -275,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 89ad91072..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; } @@ -121,6 +169,16 @@ } } +.move-mode-row.crowded { + flex-wrap: wrap; + align-items: stretch; + + .move-button { + flex: 1 0 calc(33.333% - 3px); + max-width: none; + } +} + .immobile-status { min-height: 40px; justify-content: center; @@ -283,6 +341,10 @@ line-height: 1; } +.bt-button[disabled] .modifier-badge { + opacity: 0.4; +} + .modifier { min-width: 40px; min-height: 18px; 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 5b10ed615..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'; @@ -15,23 +17,31 @@ import { ToastService } from '../../../services/toast.service'; import { PageInteractionOverlayComponent } from './page-interaction-overlay.component'; import { STANDING_UP_REVIEW_ONLY } from './page-standing-up-panel.component'; import { PageTurnSummaryPanelComponent } from './page-turn-summary-panel.component'; +import type { MotiveModeOption, MotiveModes } from '../../../models/motiveModes.model'; describe('PageTurnSummaryPanelComponent', () => { it('distinguishes an Immobile unit from one with only Stationary available', () => { const immobile = signal(false); const rulesId = signal<'core2026' | 'tw'>('core2026'); - const moveMode = signal<'stationary' | 'walk' | 'run' | 'jump' | 'UMU' | 'VTOL' | null>(null); + const moveMode = signal(null); + const availableMotiveModes = signal([ + { mode: 'stationary', label: 'Stationary', psr: false }, + ]); + 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() { return rulesId() === 'tw' ? TW_GAME_RULES : CORE_2026_GAME_RULES; }, getCondition: (condition: string) => condition === 'immobile' && immobile(), + canTakeActiveActions: () => true, getUnit: () => ({ type: 'Mek', subtype: 'BattleMek', @@ -43,7 +53,7 @@ describe('PageTurnSummaryPanelComponent', () => { getAttackMovementModifier: () => 0, getCommittedDamageMovementModePSRCheck: () => null, }, - getAvailableMotiveModes: () => [{ mode: 'stationary', label: 'Stationary', psr: false }], + getAvailableMotiveModes: () => availableMotiveModes(), turnState: () => turnState, }; @@ -63,6 +73,7 @@ describe('PageTurnSummaryPanelComponent', () => { const component = fixture.componentInstance; Object.assign(component, { dirty: () => false, + phaseDirty: () => false, damageReceived: () => 0, hasPSRChecks: () => false, falling: () => false, @@ -97,10 +108,18 @@ 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); expect(fixture.nativeElement.querySelector('.move-button.stationary-only')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.move-mode-row.crowded')).toBeNull(); expect(fixture.nativeElement.querySelector('.immobile-status')).toBeNull(); immobile.set(true); @@ -131,6 +150,65 @@ describe('PageTurnSummaryPanelComponent', () => { expect(fixture.nativeElement.querySelector('.move-button.selected')?.textContent.trim()).toBe('Run'); expect(fixture.nativeElement.querySelector('hex-slider')).not.toBeNull(); expect(fixture.nativeElement.querySelector('.hex.danger')?.textContent.trim()).toBe('5'); + + availableMotiveModes.set([ + { mode: 'stationary', label: 'Stationary' }, + { mode: 'walk', label: 'Walk' }, + { mode: 'run', label: 'Run' }, + { mode: 'sprint', label: 'Sprint' }, + { mode: 'jump', label: 'Jump' }, + ]); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.move-mode-row.crowded')).not.toBeNull(); + expect(fixture.nativeElement.querySelectorAll('.move-mode-row .move-button').length).toBe(5); + }); + + it('clears attacks and spotting when Sprint is selected', () => { + const moveMode = signal(null); + const spotting = signal(true); + const clearInventoryControlSelection = jasmine.createSpy('clearInventoryControlSelection'); + const markPhaseStateChanged = jasmine.createSpy('markPhaseStateChanged'); + const turnState = { + moveMode, + effectiveMoveMode: () => moveMode(), + moveDistance: signal(null), + minDistanceCurrentMoveMode: () => 0, + carefulStand: signal(false), + spotting, + applyMovePSR: signal(true), + markPhaseStateChanged, + }; + const unit = { + canTakeActiveActions: () => true, + clearInventoryControlSelection, + turnState: () => turnState, + }; + + TestBed.configureTestingModule({ + imports: [PageTurnSummaryPanelComponent], + providers: [ + { provide: PageInteractionOverlayComponent, useValue: { unit: signal(unit), force: signal(null) } }, + { provide: OverlayManagerService, useValue: { closeManagedOverlay: () => undefined } }, + { provide: Overlay, useValue: {} }, + { provide: EquipmentInteractionRegistryService, useValue: { getRegistry: () => ({}) } }, + { provide: ToastService, useValue: {} }, + { provide: DialogsService, useValue: {} }, + { provide: DataService, useValue: {} }, + ], + }); + const component = TestBed.createComponent(PageTurnSummaryPanelComponent).componentInstance; + Object.assign(component, { prone: () => false }); + + expect(component.canSpot()).toBeTrue(); + + component.selectMove('sprint'); + + expect(moveMode()).toBe('sprint'); + expect(spotting()).toBeFalse(); + expect(component.canSpot()).toBeFalse(); + expect(clearInventoryControlSelection).toHaveBeenCalledTimes(1); + expect(markPhaseStateChanged).toHaveBeenCalledTimes(1); }); it('reports spent stand-attempt MP and reopens the standing dialog without changing the attempt', () => { @@ -187,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 7872ee25a..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,11 +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; @@ -72,6 +74,8 @@ export class PageTurnSummaryPanelComponent { private readonly toastService = inject(ToastService); 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); @@ -105,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; @@ -224,6 +240,12 @@ export class PageTurnSummaryPanelComponent { return unit.turnState().spotting(); }); + readonly canSpot = computed(() => { + const unit = this.unit(); + return unit?.canTakeActiveActions() === true + && unit.turnState().moveMode() !== 'sprint'; + }); + readonly cover = computed(() => this.unit()?.turnState().cover()); readonly waterDepth = computed(() => { const cover = this.cover(); @@ -297,8 +319,39 @@ export class PageTurnSummaryPanelComponent { this.overlayManager.closeManagedOverlay(`turnSummary-${unitId}`); } - endTurn(): void { - this.unit()?.endTurn(); + async endTurn(): Promise { + const unit = this.unit(); + 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 { @@ -327,6 +380,7 @@ export class PageTurnSummaryPanelComponent { turnState.moveMode.set(null); turnState.moveDistance.set(null); turnState.applyMovePSR.set(true); + turnState.markPhaseStateChanged(); } readonly moveModes = computed(() => { @@ -374,13 +428,18 @@ export class PageTurnSummaryPanelComponent { } else { turnState.moveMode.set(mode); turnState.moveDistance.set(mode === 'stationary' ? null : turnState.minDistanceCurrentMoveMode()); + if (mode === 'sprint') { + turnState.spotting.set(false); + unit.clearInventoryControlSelection(); + } } turnState.applyMovePSR.set(true); + turnState.markPhaseStateChanged(); } toggleSpotting(): void { const unit = this.unit(); - if (!unit) return; + if (!unit || !this.canSpot()) return; const turnState = unit.turnState(); turnState.spotting.set(!turnState.spotting()); } @@ -477,6 +536,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/overlay/page-turn-summary.util.spec.ts b/src/app/components/page-viewer/overlay/page-turn-summary.util.spec.ts index badec98a7..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, countActionablePsrChecks, 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', () => { @@ -38,32 +61,16 @@ describe('openTurnSummaryChildOverlay', () => { expect(overlayManager.unblockClose).toHaveBeenCalledOnceWith('turnSummary-unit-1'); }); }); - describe('isMoveModeDisabledWhileProne', () => { - it('disables only jump while prone without changing its selected state', () => { + it('disables jump and sprint while prone', () => { expect(isMoveModeDisabledWhileProne('jump', true)).toBeTrue(); expect(isMoveModeDisabledWhileProne('jump', false)).toBeFalse(); + expect(isMoveModeDisabledWhileProne('sprint', true)).toBeTrue(); + expect(isMoveModeDisabledWhileProne('sprint', false)).toBeFalse(); expect(isMoveModeDisabledWhileProne('run', true)).toBeFalse(); }); }); -describe('countActionablePsrChecks', () => { - const fallCheck = { failureOutcome: 'Fall' }; - const crippleCheck = { failureOutcome: 'Crippled' }; - - it('shows all checks when the unit is not automatically falling', () => { - expect(countActionablePsrChecks([fallCheck, crippleCheck], false)).toBe(2); - }); - - it('hides the warning when autofall already represents every check', () => { - expect(countActionablePsrChecks([fallCheck, fallCheck], true)).toBe(0); - }); - - it('keeps non-fall checks actionable during autofall', () => { - expect(countActionablePsrChecks([fallCheck, crippleCheck], true)).toBe(1); - }); -}); - describe('displayPsrModifiers', () => { it('filters and preserves the rules-level modifier order', () => { expect(displayPsrModifiers([ @@ -116,17 +123,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 3ec8d27a7..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 @@ -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'; @@ -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, @@ -43,6 +58,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]; @@ -78,9 +94,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 => ({ @@ -89,16 +105,9 @@ export function displayPsrModifiers(modifiers: readonly PSRCheck[]): Array[], - autoFall: boolean -): number { - return autoFall ? checks.filter(check => check.failureOutcome !== 'Fall').length : checks.length; -} - export function isMoveModeDisabledWhileProne( mode: MotiveModes, prone: boolean, ): boolean { - return mode === 'jump' && prone; + return (mode === 'jump' || mode === 'sprint') && prone; } 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 9acfe0167..19abeb257 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'; @@ -14,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'; @@ -24,9 +26,13 @@ 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'; +import { CBTPhaseResolutionService } from '../../services/cbt-phase-resolution.service'; type SvgInteractionServicePrivate = { addSvgTapHandler( @@ -38,13 +44,15 @@ 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; 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 +77,17 @@ function createSvgInteractionUnit(overrides: T): T & { getInve armorType: 'Standard', }), getInventory: () => [], + getCritSlot: () => null, + getModularArmorState: () => ({ hits: 0, points: 0, remaining: 0 }), + addModularArmorHits: () => 0, getEquipmentStatus: () => 'available', isEquipmentOperational: () => true, canPerformEquipmentAction: () => true, + getNotificationDisplayName: () => 'Test Unit', + automationMode: () => 'ask', + applyUnderwaterBreachAndFlooding: () => undefined, + resolveUnderwaterHullBreachCheck: () => null, + automationTriggers: new Subject(), rules: NO_CONDITION_RULES, ...overrides, } as T & { @@ -96,6 +112,15 @@ 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; + let phaseIsResolving: jasmine.Spy; + let showToast: jasmine.Spy; beforeEach(() => { zoomPanService = { @@ -126,6 +151,33 @@ 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(); + phaseIsResolving = jasmine.createSpy('isResolving').and.returnValue(false); + showToast = jasmine.createSpy('showToast'); options = { pickerStyle: 'default', colorScheme: 'default', @@ -161,7 +213,26 @@ describe('SvgInteractionService', () => { provide: PickerFactoryService, useValue: pickerFactory }, - { provide: ToastService, useValue: { showToast: jasmine.createSpy('showToast') } } + { + 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: CBTPhaseResolutionService, useValue: { isResolving: phaseIsResolving } }, + { provide: ToastService, useValue: { showToast } } ] }); @@ -302,57 +373,363 @@ 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); }); - 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); + }); + + it('opens a manual critical hit without queueing it', () => { + const unit = createSvgInteractionUnit({}); + + service.openMekCriticalRollDialog(unit, 'LT'); + expect(criticalOpenManual).toHaveBeenCalledOnceWith(unit, 'LT', false); + }); + + 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(); - dialogClosedCallbacks[0]({ kind: 'critical-hits', count: 2 }); + expect(criticalChanceResume.calls.allArgs().map(args => args[1])).toEqual(['critical:1']); - 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, + 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 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(); + 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', () => { + 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(); + 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(); + + expect(criticalChanceResume.calls.allArgs().map(args => args[1])).toEqual(['critical:parent']); + + finishFirst(); + await service.automationQueue; + expect(criticalChanceResume.calls.allArgs().map(args => args[1])).toEqual([ + 'critical:parent', + 'critical:nested', + ]); + }); + + it('applies only accepted locations from a breach and flood review', async () => { + const automationTriggers = new Subject(); const setLocationCondition = jasmine.createSpy('setLocationCondition'); - const unit = createSvgInteractionUnit({ setLocationCondition }); + const unit = createSvgInteractionUnit({ + id: 'unit-a', + automationTriggers, + getNotificationDisplayName: () => 'Archer ARC-2D', + setLocationCondition, + }); + automationResolve.and.resolveTo(new Set(['flood:1:LL'])); + service.updateUnit(unit); - service.openMekCriticalChanceDialog(unit, 'HD'); - 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( + 'breachAndFloodCheck', + [ + 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(setLocationCondition).toHaveBeenCalledOnceWith('HD', 'blown-off', true, false); - expect(dialogsService.createDialog).toHaveBeenCalledTimes(1); + 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('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('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'); const unit = createSvgInteractionUnit({ - getCritSlots: () => [shoulder], - applyHitToCritSlot, + id: 'unit-a', + automationTriggers, + getNotificationDisplayName: () => 'Archer ARC-2D', setLocationCondition, }); + phaseIsResolving.and.returnValue(true); + service.updateUnit(unit); - service.openMekCriticalChanceDialog(unit, 'LA'); - dialogClosedCallbacks[0]({ kind: 'blown-off' }); + automationTriggers.next({ + kind: 'breach-and-flood', + id: 'flood:phase', + locations: ['LL'], + commit: true, + }); + await service.automationQueue; - expect(applyHitToCritSlot).toHaveBeenCalledOnceWith(shoulder, 1, false); - expect(setLocationCondition).not.toHaveBeenCalled(); - expect(dialogsService.createDialog).toHaveBeenCalledTimes(1); + 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( + '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 +962,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); @@ -820,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'; @@ -1128,6 +1580,7 @@ describe('SvgInteractionService', () => { internalPoints: 12, internalHits: 0, }); + unit.automationMode = () => 'yes'; service.updateUnit(unit); service.setupInteractions(svg); @@ -1144,7 +1597,109 @@ 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, + armorDamagedBySameHit: 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, + }); + unit.automationMode = () => 'yes'; + unit.applyHeadHitCrewHits.and.returnValue(3); + service.updateUnit(unit); + service.setupInteractions(svg); + + tap(location, 711); + pickerFactory.createNumericPicker.calls.mostRecent().args[0].onPick({ value: 4 }); + await service.automationQueue; + + expect(automationResolve).toHaveBeenCalledOnceWith( + 'pilotHitsAndConsciousnessCheck', + [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.', + }, + ); + expect(unit.applyHeadHitCrewHits).toHaveBeenCalledTimes(1); + 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', + 'error', + ); + }); + + 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.applyHeadHitCrewHits).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.applyHeadHitCrewHits).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.applyHeadHitCrewHits).not.toHaveBeenCalled(); }); it('does not pass armor repairs backward into structure', () => { @@ -1181,7 +1736,28 @@ 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, + armorDamagedBySameHit: 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 +1832,10 @@ 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, + armorDamagedBySameHit: true, + }); }); it('does not color direct structure choices as armor overflow', () => { @@ -1807,13 +2386,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 +2404,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 +2456,7 @@ function tap(el: SVGElement, pointerId: number): void { } function createArmorInteractionUnit(config: { + location?: string; armorPoints: number; armorHits: number; internalPoints: number; @@ -1885,7 +2469,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 +2487,7 @@ function createArmorInteractionUnit(config: { addInternalHits: jasmine.createSpy('addInternalHits').and.callFake((_loc: string, hits: number) => { internalHits += hits; }), + 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 e5f6e2a3c..9e3b496d1 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'; @@ -19,12 +19,12 @@ 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'; 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,11 +43,19 @@ 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 { 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'; +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'; +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'; type SheetInventoryRangeKey = InventoryRangeKey | 'extreme'; type HeatMarkerData = { el: SVGElement | null, heat: number; baselineHeat: number }; @@ -79,7 +87,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', @@ -113,6 +121,13 @@ export class SvgInteractionService { private equipmentRegistryService = inject(EquipmentInteractionRegistryService); 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); + private fallingResolution = inject(FallingResolutionService); + private phaseResolution = inject(CBTPhaseResolutionService); // Zoom-pan service passed via initialize() private zoomPanService!: ZoomPanServiceInterface; @@ -134,6 +149,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; @@ -216,7 +233,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) { @@ -632,6 +657,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; @@ -675,14 +705,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]; @@ -735,43 +758,32 @@ export class SvgInteractionService { 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) { + 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 { 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) { + valueToApply -= unit.addModularArmorHits(loc, valueToApply); + } else if (consumedModularArmorPoints > 0 && valueToApply < 0) { + 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) { const ordinaryArmorRemaining = Math.max(0, pipsCount - unit.getArmorHits(loc, rear)); @@ -781,12 +793,16 @@ 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, + ...(armorDamage > 0 ? { armorDamagedBySameHit: true } : {}), + }); } } else { 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); @@ -794,6 +810,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') { @@ -1161,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; @@ -1170,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; @@ -1185,8 +1219,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'); @@ -1213,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; @@ -1643,7 +1711,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); @@ -1699,42 +1767,125 @@ 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 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, + 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. Breach reviews are + // transient, so they must still be delivered rather than silently lost. + const phaseOwned = trigger.kind !== 'breach-and-flood' + && trigger.kind !== 'hull-breach-check'; + if (phaseOwned && 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') { + 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); + } + + this.queueAutomation(async () => { + // END PHASE may have started after the trigger was scheduled. + if (phaseOwned && this.phaseResolution.isResolving(unit)) return; + await task(); }); - 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 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('pilotHitsAndConsciousnessCheck', [event], { + title: 'Review Pilot Hit', + message: 'Choose whether to apply the pilot hit caused by this head hit.', }); + 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('breachAndFloodCheck', events, { + title: 'Review Breach and Flooding', + }); + } 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 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, + 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 { @@ -1985,15 +2136,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); }); } @@ -2104,7 +2257,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); @@ -2402,6 +2555,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/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..cf4ef4263 --- /dev/null +++ b/src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.spec.ts @@ -0,0 +1,334 @@ +// 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 { FALL_PSR_FAILURE, PSR_CHECK_KIND, 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', + kind: PSR_CHECK_KIND.DAMAGE_THRESHOLD, + failure: FALL_PSR_FAILURE, + fallCheck: 0, + reason: '20 or more damage', + }]); + 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..a08f5b0fd --- /dev/null +++ b/src/app/components/pending-unit-check-dialog/pending-unit-check-dialog.component.ts @@ -0,0 +1,166 @@ +// 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 { isFallPSRCheck } from '../../models/rules/unit-type-rules'; +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() && 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' && isFallPSRCheck(entry.check)) { + 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..ddbf5ae14 --- /dev/null +++ b/src/app/components/pending-unit-check-dialog/pending-unit-check-row.component.ts @@ -0,0 +1,289 @@ +// 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 { psrFailureLabel } from '../../models/rules/unit-type-rules'; +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) : ''; + } + const check = this.currentPsrCheck(); + return check ? psrFailureLabel(check) : ''; + }); + 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/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/components/tooltip/tooltip.component.ts b/src/app/components/tooltip/tooltip.component.ts index 4ff7b3238..a9918bf47 100644 --- a/src/app/components/tooltip/tooltip.component.ts +++ b/src/app/components/tooltip/tooltip.component.ts @@ -62,7 +62,7 @@ export type TooltipContent = string | TooltipLine[]; position: relative; pointer-events: auto; background-color: var(--background-color-menu); - max-width: min(400px, calc(100vw - 24px)); + max-width: min(500px, calc(100vw - 24px)); max-height: calc(100dvh - 24px); } .tooltip-content { diff --git a/src/app/components/unit-block/unit-block.component.html b/src/app/components/unit-block/unit-block.component.html index bb982459d..4d35791f4 100644 --- a/src/app/components/unit-block/unit-block.component.html +++ b/src/app/components/unit-block/unit-block.component.html @@ -1,16 +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 (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 +251,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..b9c73954b 100644 --- a/src/app/components/unit-block/unit-block.component.scss +++ b/src/app/components/unit-block/unit-block.component.scss @@ -25,13 +25,15 @@ pointer-events: none; width: 80px; max-height: 105px; + min-height: 64px; min-width: 80px; background: #0003; display: flex; flex-direction: column; align-items: center; + justify-content: center; overflow: hidden; - padding: 2px; + padding: 1px; box-sizing: border-box; border: 1px solid var(--border-color); flex-grow: 1; @@ -43,6 +45,20 @@ align-items: center; position: relative; z-index: 2; + + .compact-notification-row { + width: 100%; + flex-wrap: wrap; + justify-content: center; + } + + .unit-chassis { + text-shadow: + -0.7px 0 0 black, + 0.7px 0 0 black, + 0 -0.7px 0 black, + 0 0.7px 0 black; + } } &.destroyed { @@ -117,8 +133,8 @@ .pilot-info-list { position: absolute; - top: 0px; - right: -1px; + top: 2px; + right: 0px; display: flex; flex-direction: column; align-items: flex-end; @@ -148,6 +164,17 @@ width: 16px; } } + + .dirty-state { + border-width: 0 0 32px 32px; + } + + .move-badge { + width: 26px; + height: 26px; + padding: 0 2px 1px 0; + font-size: 10px; + } } :host(.miniMode) .unit-square.compact-mode { @@ -200,6 +227,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; @@ -442,26 +484,61 @@ right: 0; z-index: 1; border-style: solid; - border-width: 0 0 30px 30px; - border-color: transparent transparent #666 transparent; + border-width: 0 0 40px 40px; + border-color: transparent transparent #a00 transparent; +} + +.move-badge { + position: absolute; + display: flex; + align-items: flex-end; + justify-content: flex-end; + box-sizing: border-box; + width: 34px; + height: 34px; + bottom: 0; + right: 0; + z-index: 3; + padding: 0 3px 2px 0; + clip-path: polygon(100% 0, 100% 100%, 0 100%); + color: var(--move-on-dark); + font-size: 12px; + font-weight: 800; + line-height: 1; + pointer-events: none; + + &::before { + position: absolute; + inset: 0; + content: ''; + background: linear-gradient( + 135deg, + transparent 50%, + var(--border-color) 50%, + var(--border-color) calc(50% + 1px), + transparent calc(50% + 1px) + ); + } - &.move { - border-color: transparent transparent var(--phase-move) transparent; + &.stationary { + background-color: var(--move-stationary); } - &.ranged { - border-color: transparent transparent var(--phase-ranged) transparent; + &.walk { + background-color: var(--move-walk); + color: var(--move-on-light); } - &.physical { - border-color: transparent transparent var(--phase-physical) transparent; + &.run { + background-color: var(--move-run); } - &.heat { - border-color: transparent transparent var(--phase-heat) transparent; + &.jump { + background-color: var(--move-jump); } - &.pending-effects { - border-color: transparent transparent var(--phase-pending-effects) transparent; + &.sprint { + background-color: var(--move-sprint); + color: var(--move-on-light); } } 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..cf326dc67 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,13 +19,51 @@ 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: '' }, }); }); + 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, + getTotalTargetModifierAsDefender: () => ({ modifier: defenderModifier() }), + }), + }); + + const fixture = TestBed.createComponent(UnitBlockComponent); + fixture.componentRef.setInput('forceUnit', forceUnit); + + expect(fixture.componentInstance.dirty()).toBeFalse(); + expect(fixture.componentInstance.movementIndicator()).toBeNull(); + + moveMode.set('walk'); + expect(fixture.componentInstance.dirty()).toBeFalse(); + 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); + expect(fixture.componentInstance.dirty()).toBeTrue(); + expect(fixture.componentInstance.movementIndicator()).toBeNull(); + }); + it('includes crew state and one aggregated NARC badge alongside unit conditions', () => { const forceUnit = Object.create(CBTForceUnit.prototype) as CBTForceUnit; const crewStates: CrewMemberState[] = ['stunned', 'stunned']; @@ -53,4 +93,5 @@ describe('UnitBlockComponent', () => { { key: 'location-narc', label: 'NARC', color: '#f00' }, ]); }); + }); diff --git a/src/app/components/unit-block/unit-block.component.ts b/src/app/components/unit-block/unit-block.component.ts index a81b7ddb4..80941f8c9 100644 --- a/src/app/components/unit-block/unit-block.component.ts +++ b/src/app/components/unit-block/unit-block.component.ts @@ -13,13 +13,15 @@ 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'; 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'; +import { getTurnMovementIndicator } from '../../utils/turn-movement-indicator.util'; +import { getEcmDisplay, getTagDisplay } from '../../utils/force-viewer-electronics-display.util'; interface UnitConditionDisplay { key: string; @@ -27,11 +29,6 @@ interface UnitConditionDisplay { color: string; } -interface ECMDisplay { - mode: ECMMode | string; - unavailable: boolean; -} - export interface UnitBlockPilotEditEvent { event: MouseEvent; } @@ -39,7 +36,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'], @@ -97,37 +101,29 @@ export class UnitBlockComponent { return false; } else if (unit instanceof CBTForceUnit) { - return unit.turnState().dirty(); + return unit.turnState().dirtyPhase(); } return false; }); - unitPhase = computed(() => { - const unit = this.forceUnit(); - if (!unit) return ''; - if (unit instanceof ASForceUnit) { - return ''; - } else - if (unit instanceof CBTForceUnit) { - const phase = unit.turnState().currentPhase(); - return phase || ''; + movementIndicator = computed(() => { + if (!this.optionsService.options().trackPhaseAndTurn) { + return null; } - return ''; + const unit = this.forceUnit(); + if (!(unit instanceof CBTForceUnit)) return null; + + const turnState = unit.turnState(); + return getTurnMovementIndicator( + turnState.moveMode(), + turnState.getTotalTargetModifierAsDefender().modifier, + ); }); - hasPendingEffects = computed(() => { - if (!this.optionsService.options().trackPhaseAndTurn) { - return false; - } + notificationUnit = computed(() => { + if (!this.optionsService.options().trackPhaseAndTurn) return null; const unit = this.forceUnit(); - if (!unit) return false; - if (unit instanceof ASForceUnit) { - return false; - } else - if (unit instanceof CBTForceUnit) { - return unit.turnState().dirtyPhase(); - } - return false; + return unit instanceof CBTForceUnit ? unit : null; }); activeConditions = computed(() => { @@ -172,51 +168,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/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-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..72e1d6a2c --- /dev/null +++ b/src/app/components/unit-notification-badges/unit-notification-badges.component.html @@ -0,0 +1,68 @@ +@if (hasAutoFall()) { + + + +} + +@let notification = pendingNotification(); +@if (notification) { + + @switch (notification.kind) { + @case ('fall') { + + @if (notification.count > 1) { + + } + } + @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..8e964edb3 --- /dev/null +++ b/src/app/components/unit-notification-badges/unit-notification-badges.component.spec.ts @@ -0,0 +1,229 @@ +// 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 { FALL_PSR_FAILURE, PSR_CHECK_KIND } from '../../models/rules/unit-type-rules'; +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}`, + kind: PSR_CHECK_KIND.DAMAGE_THRESHOLD, + failure: FALL_PSR_FAILURE, + fallCheck: 1, + 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..ac666c540 --- /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 { isFallPSRCheck, psrFailureLabel, 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 isFallPSRCheck(check) + && (turnState.autoFall() || turnState.isPSRCheckAutomaticFailure(check)) + && (outcome === undefined || (outcome === 'failed' && !unit.getCondition('prone'))); + } + return outcome === undefined + && !turnState.isPSRCheckAutomaticFailure(check) + && (!turnState.autoFall() || !isFallPSRCheck(check)); + }); + if (pending.length === 0) return null; + + return pending.map(check => ({ + label: psrCheckLabel(check), + value: mode === 'automatic-fall' + ? psrFailureLabel(check) + : `Target ${unit.PSRTargetRoll()}+ · ${psrFailureLabel(check)}`, + })); +} + +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/components/unit-search/unit-search.component.html b/src/app/components/unit-search/unit-search.component.html index 28c1e06cd..9dcd83dbc 100644 --- a/src/app/components/unit-search/unit-search.component.html +++ b/src/app/components/unit-search/unit-search.component.html @@ -16,33 +16,35 @@
    @if (buttonOnly() && !this.expandedView()) { - + } @else { - } @else { + + } - @switch (mode) { @case ('chassis') { @@ -258,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 d4d49e71d..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); }); @@ -905,6 +910,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.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.uuid, nova.uuid]); + + component.clearVariantGroupFilter(); + fixture.detectChanges(); + + expect([...component.selectedUnits()]).toEqual([atlas.uuid, nova.uuid]); + }); + 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 ea4822a2e..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,14 +779,15 @@ export class UnitSearchComponent { }); }); effect(() => { - 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 => displayedNames.has(name))) { - this.selectedUnits.set(new Set([...selected].filter(name => displayedNames.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); } }); @@ -1080,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() { @@ -1572,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: { @@ -1828,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]; } @@ -2023,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); } @@ -2046,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); } @@ -2087,7 +2089,7 @@ export class UnitSearchComponent { } isUnitSelected(unit: UnitSummary): boolean { - return this.selectedUnits().has(unit.name); + return this.selectedUnits().has(unit.uuid); } clearSelection() { @@ -2099,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, @@ -2211,7 +2213,18 @@ export class UnitSearchComponent { this.closeViewModeMenu(); } - toggleExpandedView() { + openExpandedSearch(event: MouseEvent): void { + if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { + return; + } + + event.preventDefault(); + if (!this.expandedView()) { + this.toggleExpandedView(); + } + } + + toggleExpandedView(): void { const isExpanded = this.expandedView(); if (isExpanded) { 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 diff --git a/src/app/equipment-handlers/bap.handler.ts b/src/app/equipment-handlers/bap.handler.ts index e6443286a..4dedd3eb2 100644 --- a/src/app/equipment-handlers/bap.handler.ts +++ b/src/app/equipment-handlers/bap.handler.ts @@ -3,13 +3,77 @@ // 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 { readonly id = 'bap-handler'; override readonly flags: EquipmentFlag[] = ['F_BAP']; override readonly priority = 10; - + + override applicableTo(equipment: MountedEquipment): boolean { + // 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'; -} \ No newline at end of file + 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/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-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/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..e958c10ec 100644 --- a/src/app/equipment-handlers/ecm.handler.ts +++ b/src/app/equipment-handlers/ecm.handler.ts @@ -4,21 +4,31 @@ 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 { + cancelConflictingElectronicSuiteActivations, + deactivateConflictingElectronicSuites, + ECM_MODE_STATE_KEY, + ECM_PENDING_MODE_STATE_KEY, + getEffectiveEcmMode, + getNextEffectiveEcmMode, + 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; - private getDefaultMode(): string { - return ECMMode.ECM; + 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 getModes(equipment: MountedEquipment) { @@ -48,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 [ @@ -63,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' @@ -75,7 +97,16 @@ 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); + } + + 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/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..ce73a1cca 100644 --- a/src/app/equipment-handlers/index.ts +++ b/src/app/equipment-handlers/index.ts @@ -3,33 +3,43 @@ // 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 { 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 { 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 { 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 { 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 { 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 { 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 +47,44 @@ 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 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 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 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 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 SearchlightHandler()); + registry.register(new ShieldModeHandler()); + registry.register(new StealthHandler()); + registry.register(new SpotWelderHandler()); 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/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 new file mode 100644 index 000000000..7e2ea1bdd --- /dev/null +++ b/src/app/equipment-handlers/nova-cews.handler.spec.ts @@ -0,0 +1,314 @@ +// 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 { 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'; +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 { + 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({ + 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 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(); + + 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('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'); + 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..2470019e1 --- /dev/null +++ b/src/app/equipment-handlers/nova-cews.handler.ts @@ -0,0 +1,103 @@ +// 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 { 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'; + +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 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; + + // Commit the selected suite's ECM/probe handoff after outgoing-turn + // effects and heat have already resolved. + deactivateConflictingElectronicSuites(equipment); + } + + override getInventoryHeatSources( + equipment: MountedEquipment, + _turnState: TurnState, + context: HandlerQueryContext, + ): UnitHeatSource[] { + // 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}`, + 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/prototype-laser.handler.spec.ts b/src/app/equipment-handlers/prototype-laser.handler.spec.ts new file mode 100644 index 000000000..1f94d2b34 --- /dev/null +++ b/src/app/equipment-handlers/prototype-laser.handler.spec.ts @@ -0,0 +1,99 @@ +// 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 returns the rolled 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: '*' }); + + expect(handler.afterInventoryControlFire(medium.mounted)).toEqual({ + additionalHeat: 6, + detail: '1D6 roll: 6', + }); + expect(medium.addFiredHeat).not.toHaveBeenCalled(); + expect(medium.setHeat).not.toHaveBeenCalled(); + }); + + 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); + + const result = handler.afterInventoryControlFire(medium.mounted); + + 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); + + 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', () => { + const aero = fixture('ISERLargeLaserPrototype', 'Aero'); + + expect(handler.applyInventoryControlHeatEffects( + aero.mounted, + { value: 12, weakened: false }, + context, + )).toEqual({ value: 18, weakened: false }); + expect(handler.afterInventoryControlFire(aero.mounted)).toBeUndefined(); + 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..e9ab03df1 --- /dev/null +++ b/src/app/equipment-handlers/prototype-laser.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 { WeaponEquipment } from '../models/equipment.model'; +import type { InventoryControlHeatEffect } from '../utils/inventory-control-heat.util'; +import { + EquipmentInteractionHandler, + type HandlerCommandContext, + type HandlerQueryContext, + type InventoryControlFireResult, +} 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): 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; + return { + additionalHeat: extraHeat, + detail: maximum === 3 + ? `1D3 (1D6 roll: ${roll})` + : `1D6 roll: ${roll}`, + }; + } + + 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/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/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 b8cf02764..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], }); } @@ -160,7 +169,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 +181,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(); @@ -221,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')); @@ -229,9 +273,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,13 +289,69 @@ 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(); }); + 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 55ba8ae9a..7d4c1fe16 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, @@ -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,16 +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: `equipment:${equipment.id}`, - label: 'Equipment', + id: `stealth:${equipment.id}`, + 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 { @@ -98,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/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/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-c3.spec.ts b/src/app/models/cbt-force-unit-c3.spec.ts index c10b78c0d..52f836e06 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 { } @@ -62,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', @@ -171,12 +175,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 +286,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 +301,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-state.model.ts b/src/app/models/cbt-force-unit-state.model.ts index 203ca3b2d..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(turnCounter = 0) { + 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(); } @@ -152,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() { @@ -177,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) { diff --git a/src/app/models/cbt-force-unit.model.spec.ts b/src/app/models/cbt-force-unit.model.spec.ts index 45af55ef8..74a49ce0a 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'; @@ -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, @@ -48,6 +48,10 @@ import { BombastLaserHandler, } 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'; +import { HPG_CHARGING_STATE, HPG_STATE_KEY, HPG_TRANSMITTING_STATE } from '../utils/hpg-state.util'; function createEquipment(): EquipmentMap { const ultraAc20 = new WeaponEquipment({ @@ -729,6 +733,10 @@ class ExposedUnitSvgVehicleService extends UnitSvgVehicleService { } class ExposedUnitSvgMekService extends UnitSvgMekService { + refreshHeat(): void { + this.updateHeatDisplay(this.unit.getHeat()); + } + refreshInventory(): void { this.updateInventory(); } @@ -736,6 +744,10 @@ class ExposedUnitSvgMekService extends UnitSvgMekService { refreshHeatSinks(): void { this.updateHeatSinkPips(); } + + refreshCriticalSlots(criticalSlots = this.unit.getCritSlots()): void { + this.updateCritSlotDisplay(criticalSlots); + } } class ExposedUnitSvgAeroService extends UnitSvgAeroService { @@ -868,30 +880,42 @@ 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(), + sprinting: false, + }, + }), + } }, ], }); @@ -1140,6 +1164,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 +1267,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 +1298,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 +1308,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 +1325,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 +1364,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 +1385,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 +1401,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 +1410,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 +1495,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 +1582,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 +1599,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 +1624,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 +1732,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 +1755,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 +1800,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 +1833,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 +1850,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 +1872,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(` @@ -1844,7 +1991,72 @@ 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: 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('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', () => { + // 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,201 +2066,512 @@ 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('floods a structurally destroyed location with depleted armor when it becomes submerged', () => { + automationModes.pilotSkillCheck = 'no'; const { forceUnit } = createCriticalHeatSinkForceUnit(); - forceUnit.turnState().setCover('underwater-depth-1'); - - forceUnit.addArmorHits('LL', 5); + forceUnit.setArmorHits('LL', forceUnit.getArmorPoints('LL')); + forceUnit.setInternalHits('LL', forceUnit.getInternalPoints('LL')); - expect(forceUnit.getCommittedArmorHits('LL')).toBe(0); + expect(forceUnit.isInternalLocStructurallyDestroyed('LL')).toBeTrue(); expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeFalse(); - forceUnit.endPhase(); + forceUnit.turnState().setCover('underwater-depth-1'); - expect(forceUnit.getCommittedArmorHits('LL')).toBe(5); expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeTrue(); }); - it('floods all submerged locations at the unit-specific full-submersion depth', () => { - const normal = createCriticalHeatSinkForceUnit().forceUnit; - normal.setArmorHits('LT', 5); - normal.turnState().setCover('underwater-depth-2'); - expect(normal.getLocationCondition('LT', 'flooded')).toBeTrue(); - - const superheavy = createCriticalHeatSinkForceUnit().forceUnit; - superheavy.getUnit().tons = 150; - superheavy.setArmorHits('LT', 5); - superheavy.turnState().setCover('underwater-depth-1'); - expect(superheavy.getLocationCondition('LT', 'flooded')).toBeFalse(); - superheavy.turnState().setCover('underwater-depth-2'); - expect(superheavy.getLocationCondition('LT', 'flooded')).toBeFalse(); - superheavy.turnState().setCover('underwater-depth-3'); - expect(superheavy.getLocationCondition('LT', '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')); - it('floods a location when either front or rear armor facing is breached', () => { - const rearBreached = createCriticalHeatSinkForceUnit().forceUnit; - rearBreached.locations!.armor.set('LT-rear', { loc: 'LT', rear: true, points: 5 }); - rearBreached.setLocations({ - LT: { armor: 5 }, - 'LT-rear': { armor: 0 }, - }, true); - rearBreached.turnState().setCover('underwater-depth-1'); - rearBreached.setCondition('prone', true); - expect(rearBreached.getLocationCondition('LT', 'flooded')).toBeTrue(); + forceUnit.turnState().setCover('underwater-depth-1'); - const frontBreached = createCriticalHeatSinkForceUnit().forceUnit; - frontBreached.locations!.armor.set('LT-rear', { loc: 'LT', rear: true, points: 5 }); - frontBreached.setLocations({ - LT: { armor: 0 }, - 'LT-rear': { armor: 5 }, - }, true); - frontBreached.turnState().setCover('underwater-depth-1'); - frontBreached.setCondition('prone', true); - expect(frontBreached.getLocationCondition('LT', 'flooded')).toBeTrue(); + expect(forceUnit.isInternalLocStructurallyDestroyed('LL')).toBeTrue(); + expect(forceUnit.getArmorHits('LL')).toBe(0); + expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeFalse(); }); - it('disables heat sinks in flooded and blown-off locations', () => { - const { forceUnit, legSlot } = createCriticalHeatSinkForceUnit(); + it('automatically resolves a successful Core hull-breach check after underwater damage', () => { + automationModes.pilotSkillCheck = 'no'; + const { forceUnit } = createCriticalHeatSinkForceUnit(); forceUnit.turnState().setCover('underwater-depth-1'); - forceUnit.addArmorHits('LL', 5); - forceUnit.endPhase(); - - expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeTrue(); - expect(forceUnit.getEquipmentStatus(legSlot)).toBe('disabled'); - expect(forceUnit.rules.heatDissipation()?.totalDissipation).toBe(4); + spyOn(Math, 'random').and.returnValues(0, 0); - forceUnit.setLocationCondition('LL', 'blown-off', true); - forceUnit.endPhase(); + forceUnit.addArmorHits('LL', 1); - expect(forceUnit.getEquipmentStatus(legSlot)).toBe('destroyed'); - expect(forceUnit.rules.heatDissipation()?.totalDissipation).toBe(4); + 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('shows a single dissipation value when all remaining cooling is used', () => { - const forceUnit = createForceUnit(createMekUnitWithDissipation(5)); - const svg = new DOMParser().parseFromString(` - - - - `, 'image/svg+xml').documentElement as unknown as SVGSVGElement; - forceUnit.svg.set(svg); - forceUnit.setHeatData({ current: 2, previous: 2 }); - forceUnit.turnState().addFiredHeat(6); - const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgService(forceUnit, unitInitializer)); + 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); - svgService.refreshTurnState(); + forceUnit.addArmorHits('LL', 1); - const lines = Array.from(svg.querySelectorAll('#damagedEngineHeatText > tspan')); - expect(forceUnit.turnState().heatProjection().projected).toBe(3); - expect(lines.map(line => line.textContent)).toEqual([ - 'Weapons: +6', - 'Sink: -5', - ]); - expect(lines[1].getAttribute('fill')).toBe('#2070d1'); + 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('keeps heatsink changes in the projection until the turn ends', () => { - const forceUnit = createForceUnit(createMekUnitWithDissipation(20)); - forceUnit.setHeatData({ current: 15, previous: 15 }); - forceUnit.turnState().moveMode.set('walk'); - - expect(forceUnit.turnState().heatProjection().projected).toBe(0); - forceUnit.applyHeat(); + 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); - expect(forceUnit.getHeat().current).toBe(15); - expect(forceUnit.turnState().serialize()?.heatDissipationConsumed).toBeUndefined(); + forceUnit.addArmorHits('LL', 1); - forceUnit.setHeatsinksOff(7); + 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', + ); + }); - expect(forceUnit.turnState().heatDissipationBalance()).toBe(13); - expect(forceUnit.turnState().heatProjection().projected).toBe(3); + 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.applyHeat(); + forceUnit.addArmorHits('LL', 1); - expect(forceUnit.getHeat().current).toBe(15); - expect(forceUnit.turnState().serialize()?.heatDissipationConsumed).toBeUndefined(); + 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', + ); + }); - forceUnit.setHeatsinksOff(3); - expect(forceUnit.turnState().heatDissipationBalance()).toBe(17); - expect(forceUnit.turnState().heatProjection().projected).toBe(0); + 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.endTurn(); + forceUnit.addArmorHits('LL', forceUnit.getArmorPoints('LL')); - expect(forceUnit.getHeat().current).toBe(0); - expect(forceUnit.getHeat().next).toBeUndefined(); + expect(Math.random).not.toHaveBeenCalled(); + expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeTrue(); }); - it('settles a persisted dissipation deficit when automations are disabled', () => { - 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); - forceUnit.setHeat(3); + 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.applyHeat(); + forceUnit.addArmorHits('LL', 1); - expect(forceUnit.getHeat().current).toBe(3); - expect(forceUnit.turnState().serialize()?.heatDissipationConsumed).toBe(13); - expect(forceUnit.turnState().heatDissipationBalance()).toBe(0); - expect(forceUnit.turnState().heatSources()).toContain(jasmine.objectContaining({ id: 'movement' })); - expect(forceUnit.turnState().hasPendingHeatResolution()).toBeTrue(); + expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeFalse(); + expect(triggers).toEqual([ + jasmine.objectContaining({ + kind: 'hull-breach-check', + location: 'LL', + commit: false, + }), + ]); }); - it('reactivates only damaged-engine heat for rules-driven critical writes', () => { - const forceUnit = createForceUnit(); - initialize(forceUnit, createMekDamageSvg()); - forceUnit.turnState().moveMode.set('run'); - forceUnit.turnState().acknowledgeHeatSources(); + it('marks flooding when pending armor breaches underwater and commits it at phase end', () => { + const { forceUnit } = createCriticalHeatSinkForceUnit(); + forceUnit.turnState().setCover('underwater-depth-1'); - forceUnit.writeCrits([{ id: 'engine@CT#0', name: 'Engine', loc: 'CT', slot: 0, destroying: 1 }]); + forceUnit.addArmorHits('LL', 5); - expect(forceUnit.turnState().heatSources().map(source => source.id)).toEqual(['damaged-engine']); + expect(forceUnit.getCommittedArmorHits('LL')).toBe(0); + 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('keeps projection graphics until automated end-turn resolution', () => { - const forceUnit = createForceUnit(createEmptyUnit({ - ...createMekUnit(), - heat: 20, - dissipation: 0, + 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', 1); + expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeFalse(); + expect(triggers).toEqual([]); + + forceUnit.addArmorHits('LL', 4); + 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('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 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 }); + + reinforced.addInternalHits('LT', 1); + expect(reinforced.turnState().dmgReceived()).toBe(0); + + reinforced.addInternalHits('LT', 1); + 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(); + 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', () => { + const normal = createCriticalHeatSinkForceUnit().forceUnit; + normal.setArmorHits('LT', 5); + normal.turnState().setCover('underwater-depth-2'); + expect(normal.getLocationCondition('LT', 'flooded')).toBeTrue(); + + const superheavy = createCriticalHeatSinkForceUnit().forceUnit; + superheavy.getUnit().tons = 150; + superheavy.setArmorHits('LT', 5); + superheavy.turnState().setCover('underwater-depth-1'); + expect(superheavy.getLocationCondition('LT', 'flooded')).toBeFalse(); + superheavy.turnState().setCover('underwater-depth-2'); + expect(superheavy.getLocationCondition('LT', 'flooded')).toBeFalse(); + superheavy.turnState().setCover('underwater-depth-3'); + expect(superheavy.getLocationCondition('LT', 'flooded')).toBeTrue(); + }); + + it('floods a location when either front or rear armor facing is breached', () => { + const rearBreached = createCriticalHeatSinkForceUnit().forceUnit; + rearBreached.locations!.armor.set('LT-rear', { loc: 'LT', rear: true, points: 5 }); + rearBreached.setLocations({ + LT: { armor: 5 }, + 'LT-rear': { armor: 0 }, + }, true); + rearBreached.turnState().setCover('underwater-depth-1'); + rearBreached.setCondition('prone', true); + expect(rearBreached.getLocationCondition('LT', 'flooded')).toBeTrue(); + + const frontBreached = createCriticalHeatSinkForceUnit().forceUnit; + frontBreached.locations!.armor.set('LT-rear', { loc: 'LT', rear: true, points: 5 }); + frontBreached.setLocations({ + LT: { armor: 0 }, + 'LT-rear': { armor: 5 }, + }, true); + frontBreached.turnState().setCover('underwater-depth-1'); + frontBreached.setCondition('prone', true); + expect(frontBreached.getLocationCondition('LT', 'flooded')).toBeTrue(); + }); + + it('disables heat sinks in flooded and blown-off locations', () => { + const { forceUnit, legSlot } = createCriticalHeatSinkForceUnit(); + forceUnit.turnState().setCover('underwater-depth-1'); + forceUnit.addArmorHits('LL', 5); + forceUnit.endPhase(); + + expect(forceUnit.getLocationCondition('LL', 'flooded')).toBeTrue(); + expect(forceUnit.getEquipmentStatus(legSlot)).toBe('disabled'); + expect(forceUnit.rules.heatDissipation()?.totalDissipation).toBe(4); + + forceUnit.setLocationCondition('LL', 'blown-off', true); + forceUnit.endPhase(); + + expect(forceUnit.getEquipmentStatus(legSlot)).toBe('destroyed'); + expect(forceUnit.rules.heatDissipation()?.totalDissipation).toBe(4); + }); + + it('shows a single dissipation value when all remaining cooling is used', () => { + const forceUnit = createForceUnit(createMekUnitWithDissipation(5)); 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(6); const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgService(forceUnit, unitInitializer)); - forceUnit.turnState().addFiredHeat(5); - svgService.refreshHeat(); - const projectionPath = svg.querySelector('#heat-projection-path'); - expect(projectionPath).not.toBeNull(); + svgService.refreshTurnState(); + + const lines = Array.from(svg.querySelectorAll('#damagedEngineHeatText > tspan')); + expect(forceUnit.turnState().heatProjection().projected).toBe(3); + expect(lines.map(line => line.textContent)).toEqual([ + 'Weapons: +6', + 'Sink: -5', + ]); + expect(lines[1].getAttribute('fill')).toBe('#2070d1'); + }); + + it('keeps heatsink changes in the projection until the turn ends', () => { + const forceUnit = createForceUnit(createMekUnitWithDissipation(20)); + forceUnit.setHeatData({ current: 15, previous: 15 }); + forceUnit.turnState().moveMode.set('walk'); + expect(forceUnit.turnState().heatProjection().projected).toBe(0); forceUnit.applyHeat(); - svgService.refreshHeat(); - expect(svg.querySelector('#heat-projection-path')).toBe(projectionPath); + expect(forceUnit.getHeat().current).toBe(15); + expect(forceUnit.turnState().serialize()?.heatDissipationConsumed).toBeUndefined(); - forceUnit.endTurn(); - svgService.refreshHeat(); + forceUnit.setHeatsinksOff(7); - expect(svg.querySelector('#heat-projection-path')).toBeNull(); - expect(svg.querySelector('#projection-arrow')).toBeNull(); + expect(forceUnit.turnState().heatDissipationBalance()).toBe(13); + expect(forceUnit.turnState().heatProjection().projected).toBe(3); + + forceUnit.applyHeat(); + + expect(forceUnit.getHeat().current).toBe(15); + expect(forceUnit.turnState().serialize()?.heatDissipationConsumed).toBeUndefined(); + + forceUnit.setHeatsinksOff(3); + expect(forceUnit.turnState().heatDissipationBalance()).toBe(17); + expect(forceUnit.turnState().heatProjection().projected).toBe(0); + + forceUnit.endTurn({ heatAndDissipationResolution: true }); + + expect(forceUnit.getHeat().current).toBe(0); + expect(forceUnit.getHeat().next).toBeUndefined(); + }); + + 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); + heatAutomationMode.set('no'); + forceUnit.setHeat(3); + + forceUnit.applyHeat(); + + expect(forceUnit.getHeat().current).toBe(3); + expect(forceUnit.turnState().serialize()?.heatDissipationConsumed).toBe(13); + expect(forceUnit.turnState().heatDissipationBalance()).toBe(0); + expect(forceUnit.turnState().heatSources()).toContain(jasmine.objectContaining({ id: 'movement' })); + expect(forceUnit.turnState().hasPendingHeatResolution()).toBeTrue(); + }); + + it('reactivates only damaged-engine heat for rules-driven critical writes', () => { + const forceUnit = createForceUnit(); + initialize(forceUnit, createMekDamageSvg()); + forceUnit.turnState().moveMode.set('run'); + forceUnit.turnState().acknowledgeHeatSources(); + + forceUnit.writeCrits([{ id: 'engine@CT#0', name: 'Engine', loc: 'CT', slot: 0, destroying: 1 }]); + + expect(forceUnit.turnState().heatSources().map(source => source.id)).toEqual(['damaged-engine']); }); - it('separates a manual heat target from the automated projection UI', () => { + it('keeps projection graphics until automated end-turn resolution', () => { const forceUnit = createForceUnit(createEmptyUnit({ ...createMekUnit(), heat: 20, @@ -2056,7 +2579,6 @@ describe('CBTForceUnit direct inventory ammo bins', () => { })); const svg = new DOMParser().parseFromString(` - ${Array.from({ length: 11 }, (_, value) => ``).join('')} @@ -2064,43 +2586,23 @@ describe('CBTForceUnit direct inventory ammo bins', () => { `, '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)); + forceUnit.turnState().addFiredHeat(5); svgService.refreshHeat(); + const projectionPath = svg.querySelector('#heat-projection-path'); + expect(projectionPath).not.toBeNull(); - 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); + forceUnit.applyHeat(); 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(); + expect(svg.querySelector('#heat-projection-path')).toBe(projectionPath); - forceUnit.applyHeat(); + forceUnit.endTurn({ heatAndDissipationResolution: true }); 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(); + expect(svg.querySelector('#heat-projection-path')).toBeNull(); + expect(svg.querySelector('#projection-arrow')).toBeNull(); }); it('centers the overflow projection arrow over its body', () => { @@ -2231,153 +2733,635 @@ describe('CBTForceUnit direct inventory ammo bins', () => { svgService.refreshHeat(); - const heatDataPanel = svg.querySelector('#heatDataPanel'); - expect(forceUnit.turnState().hasPendingHeatResolution()).toBeFalse(); - expect(heatDataPanel?.classList.contains('heatApplicationAvailable')).toBeFalse(); - expect(heatDataPanel?.classList.contains('hot')).toBeFalse(); - expect(heatDataPanel?.classList.contains('cold')).toBeFalse(); - expect(svg.querySelector('#projection-arrow')).toBeNull(); - expect(svg.querySelector('#heat-projection-path')).toBeNull(); + const heatDataPanel = svg.querySelector('#heatDataPanel'); + expect(forceUnit.turnState().hasPendingHeatResolution()).toBeFalse(); + expect(heatDataPanel?.classList.contains('heatApplicationAvailable')).toBeFalse(); + expect(heatDataPanel?.classList.contains('hot')).toBeFalse(); + expect(heatDataPanel?.classList.contains('cold')).toBeFalse(); + expect(svg.querySelector('#projection-arrow')).toBeNull(); + expect(svg.querySelector('#heat-projection-path')).toBeNull(); + }); + + it('shows an orange manual marker for selected weapons when committed heat is zero', () => { + heatAutomationMode.set('no'); + const forceUnit = createForceUnit(createSelectedHeatUnit(equipment, 0)); + const svg = createSelectedHeatScaleSvg(); + initialize(forceUnit, svg); + forceUnit.setHeatData({ current: 0, previous: 0 }); + const variableLaser = forceUnit.getInventory() + .find(entry => entry.equipment?.id === 'VariableDamageLaser')!; + forceUnit.setInventoryControlEntrySelected(variableLaser, true); + const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgService(forceUnit, unitInitializer)); + + svgService.refreshHeat(); + + expect(svg.querySelector('#heat-projection-target-marker')).toBeNull(); + const marker = expectHeatMarkerAt(svg, 'heat-selected-weapons-target-marker', 7); + expect(marker?.getAttribute('fill')).toBe('orange'); + + forceUnit.setInventoryControlEntrySelected(variableLaser, false); + svgService.refreshHeat(); + expect(svg.querySelector('#heat-selected-weapons-target-marker')).toBeNull(); + }); + + it('shows the orange manual marker at zero when sinks fully dissipate selected heat', () => { + heatAutomationMode.set('no'); + const forceUnit = createForceUnit(createSelectedHeatUnit(equipment, 20)); + const svg = createSelectedHeatScaleSvg(); + initialize(forceUnit, svg); + forceUnit.setHeatData({ current: 0, previous: 0 }); + const variableLaser = forceUnit.getInventory() + .find(entry => entry.equipment?.id === 'VariableDamageLaser')!; + forceUnit.setInventoryControlEntrySelected(variableLaser, true); + const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgService(forceUnit, unitInitializer)); + + svgService.refreshHeat(); + + expect(svg.querySelector('#heat-projection-target-marker')).toBeNull(); + const marker = expectHeatMarkerAt(svg, 'heat-selected-weapons-target-marker', 0); + expect(marker?.getAttribute('fill')).toBe('orange'); + }); + + it('shows the committed manual marker at zero when sinks fully dissipate committed heat', () => { + heatAutomationMode.set('no'); + const forceUnit = createForceUnit(createSelectedHeatUnit(equipment, 20)); + const svg = createSelectedHeatScaleSvg(); + initialize(forceUnit, svg); + forceUnit.setHeatData({ current: 0, previous: 0 }); + forceUnit.turnState().addFiredHeat(5); + const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgService(forceUnit, unitInitializer)); + + svgService.refreshHeat(); + + const marker = expectHeatMarkerAt(svg, 'heat-projection-target-marker', 0); + expect(marker?.getAttribute('fill')).toBe('#2070d1'); + }); + + it('shows the committed manual marker for pure cooling without a committed heat source', () => { + heatAutomationMode.set('no'); + const forceUnit = createForceUnit(createSelectedHeatUnit(equipment, 20)); + const svg = createSelectedHeatScaleSvg(); + initialize(forceUnit, svg); + forceUnit.setHeatData({ current: 5, previous: 5 }); + const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgService(forceUnit, unitInitializer)); + + svgService.refreshHeat(); + + const marker = expectHeatMarkerAt(svg, 'heat-projection-target-marker', 0); + expect(marker?.getAttribute('fill')).toBe('#2070d1'); + }); + + it('paints an orange selected marker over a committed marker when both target zero', () => { + heatAutomationMode.set('no'); + const forceUnit = createForceUnit(createSelectedHeatUnit(equipment, 20)); + const svg = createSelectedHeatScaleSvg(); + initialize(forceUnit, svg); + forceUnit.setHeatData({ current: 5, previous: 5 }); + const variableLaser = forceUnit.getInventory() + .find(entry => entry.equipment?.id === 'VariableDamageLaser')!; + forceUnit.setInventoryControlEntrySelected(variableLaser, true); + forceUnit.turnState().addFiredHeat(2); + const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgService(forceUnit, unitInitializer)); + + svgService.refreshHeat(); + + const committedMarker = svg.querySelector('#heat-projection-target-marker')!; + const selectedMarker = svg.querySelector('#heat-selected-weapons-target-marker')!; + expectHeatMarkerAt(svg, 'heat-projection-target-marker', 0); + expectHeatMarkerAt(svg, 'heat-selected-weapons-target-marker', 0); + expect(selectedMarker.getAttribute('fill')).toBe('orange'); + expect(committedMarker.compareDocumentPosition(selectedMarker) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('shows committed and selected manual heat markers independently', () => { + heatAutomationMode.set('no'); + const forceUnit = createForceUnit(createSelectedHeatUnit(equipment, 0)); + const svg = createSelectedHeatScaleSvg(); + initialize(forceUnit, svg); + forceUnit.setHeatData({ current: 0, previous: 0 }); + const variableLaser = forceUnit.getInventory() + .find(entry => entry.equipment?.id === 'VariableDamageLaser')!; + forceUnit.setInventoryControlEntrySelected(variableLaser, true); + forceUnit.turnState().addFiredHeat(2); + const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgService(forceUnit, unitInitializer)); + + svgService.refreshHeat(); + + expectHeatMarkerAt(svg, 'heat-projection-target-marker', 2); + expectHeatMarkerAt(svg, 'heat-selected-weapons-target-marker', 7); + 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(PSR_CHECK_KIND.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 and its exact dice across save/reload until completion', () => { + 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, [{ + hitLocationDice: [5, 2], + tripodLegRoll: null, + }])).toBeTrue(); + expect(forceUnit.getPendingFall(pending.id)).toEqual(jasmine.objectContaining({ + orientationRoll: 4, + damageRolls: [{ + 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' }); + expect(forceUnit.turnState().getPendingUnitChecks()).toContain(jasmine.objectContaining({ + kind: 'seatbelt', + })); }); - it('hides calculated heat graphics when CBT automations are disabled', () => { - cbtAutomations.set(false); + it('creates one seatbelt check per crew member with independent skills and unconscious failure', () => { const forceUnit = createForceUnit(createEmptyUnit({ - ...createMekUnit(), - heat: 20, - dissipation: 0, + type: 'Mek', + subtype: 'BattleMek', + crewSize: 3, })); - 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(); + 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('shows an orange manual marker for selected weapons when committed heat is zero', () => { - cbtAutomations.set(false); - const forceUnit = createForceUnit(createSelectedHeatUnit(equipment, 0)); - const svg = createSelectedHeatScaleSvg(); - initialize(forceUnit, svg); - forceUnit.setHeatData({ current: 0, previous: 0 }); - const variableLaser = forceUnit.getInventory() - .find(entry => entry.equipment?.id === 'VariableDamageLaser')!; - forceUnit.setInventoryControlEntrySelected(variableLaser, true); - const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgService(forceUnit, unitInitializer)); - - svgService.refreshHeat(); + 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(svg.querySelector('#heat-projection-target-marker')).toBeNull(); - const marker = expectHeatMarkerAt(svg, 'heat-selected-weapons-target-marker', 7); - expect(marker?.getAttribute('fill')).toBe('orange'); + expect(forceUnit.queueFall('psr')).toBeFalse(); - forceUnit.setInventoryControlEntrySelected(variableLaser, false); - svgService.refreshHeat(); - expect(svg.querySelector('#heat-selected-weapons-target-marker')).toBeNull(); + expect(triggers).toEqual([]); + expect(forceUnit.pendingFallCount()).toBe(0); + expect(forceUnit.turnState().getPendingUnitChecks()).toEqual([]); }); - it('shows the orange manual marker at zero when sinks fully dissipate selected heat', () => { - cbtAutomations.set(false); - const forceUnit = createForceUnit(createSelectedHeatUnit(equipment, 20)); - const svg = createSelectedHeatScaleSvg(); - initialize(forceUnit, svg); - forceUnit.setHeatData({ current: 0, previous: 0 }); - const variableLaser = forceUnit.getInventory() - .find(entry => entry.equipment?.id === 'VariableDamageLaser')!; - forceUnit.setInventoryControlEntrySelected(variableLaser, true); - const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgService(forceUnit, unitInitializer)); + 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)); - svgService.refreshHeat(); + expect(forceUnit.queueFall('psr')).toBeFalse(); - expect(svg.querySelector('#heat-projection-target-marker')).toBeNull(); - const marker = expectHeatMarkerAt(svg, 'heat-selected-weapons-target-marker', 0); - expect(marker?.getAttribute('fill')).toBe('orange'); + expect(triggers).toEqual([]); + expect(forceUnit.pendingFallCount()).toBe(0); }); - it('shows the committed manual marker at zero when sinks fully dissipate committed heat', () => { - cbtAutomations.set(false); - const forceUnit = createForceUnit(createSelectedHeatUnit(equipment, 20)); - const svg = createSelectedHeatScaleSvg(); - initialize(forceUnit, svg); - forceUnit.setHeatData({ current: 0, previous: 0 }); - forceUnit.turnState().addFiredHeat(5); - const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgService(forceUnit, unitInitializer)); + 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)); - svgService.refreshHeat(); + forceUnit.turnState().addDmgReceived(19); + forceUnit.turnState().addDmgReceived(1); + forceUnit.turnState().addDmgReceived(5); - const marker = expectHeatMarkerAt(svg, 'heat-projection-target-marker', 0); - expect(marker?.getAttribute('fill')).toBe('#2070d1'); + expect(forceUnit.pendingFallCount()).toBe(0); + expect(forceUnit.turnState().PSRRollsCount()).toBe(0); + expect(triggers).toEqual([]); + expect(forceUnit.turnState().getPendingUnitChecks()).toEqual([]); }); - it('shows the committed manual marker for pure cooling without a committed heat source', () => { - cbtAutomations.set(false); - const forceUnit = createForceUnit(createSelectedHeatUnit(equipment, 20)); - const svg = createSelectedHeatScaleSvg(); - initialize(forceUnit, svg); - forceUnit.setHeatData({ current: 5, previous: 5 }); - const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgService(forceUnit, unitInitializer)); + 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)); - svgService.refreshHeat(); + forceUnit.turnState().addDmgReceived(20); + expect(forceUnit.turnState().PSRRollsCount()).toBe(1); - const marker = expectHeatMarkerAt(svg, 'heat-projection-target-marker', 0); - expect(marker?.getAttribute('fill')).toBe('#2070d1'); + forceUnit.setCondition('prone', true); + + expect(forceUnit.turnState().PSRRollsCount()).toBe(0); + expect(forceUnit.pendingFallCount()).toBe(0); + expect(triggers).toEqual([]); + expect(forceUnit.turnState().getPendingUnitChecks()).toEqual([]); }); - it('paints an orange selected marker over a committed marker when both target zero', () => { - cbtAutomations.set(false); - const forceUnit = createForceUnit(createSelectedHeatUnit(equipment, 20)); - const svg = createSelectedHeatScaleSvg(); - initialize(forceUnit, svg); - forceUnit.setHeatData({ current: 5, previous: 5 }); - const variableLaser = forceUnit.getInventory() - .find(entry => entry.equipment?.id === 'VariableDamageLaser')!; - forceUnit.setInventoryControlEntrySelected(variableLaser, true); - forceUnit.turnState().addFiredHeat(2); - const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgService(forceUnit, unitInitializer)); + it('keeps standing 20-point damage on the normal fall-PSR path', () => { + const forceUnit = createForceUnit(); - svgService.refreshHeat(); + forceUnit.turnState().addDmgReceived(20); - const committedMarker = svg.querySelector('#heat-projection-target-marker')!; - const selectedMarker = svg.querySelector('#heat-selected-weapons-target-marker')!; - expectHeatMarkerAt(svg, 'heat-projection-target-marker', 0); - expectHeatMarkerAt(svg, 'heat-selected-weapons-target-marker', 0); - expect(selectedMarker.getAttribute('fill')).toBe('orange'); - expect(committedMarker.compareDocumentPosition(selectedMarker) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + 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/), + })); }); - it('shows committed and selected manual heat markers independently', () => { - cbtAutomations.set(false); - const forceUnit = createForceUnit(createSelectedHeatUnit(equipment, 0)); - const svg = createSelectedHeatScaleSvg(); - initialize(forceUnit, svg); - forceUnit.setHeatData({ current: 0, previous: 0 }); - const variableLaser = forceUnit.getInventory() - .find(entry => entry.equipment?.id === 'VariableDamageLaser')!; - forceUnit.setInventoryControlEntrySelected(variableLaser, true); - forceUnit.turnState().addFiredHeat(2); - const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgService(forceUnit, unitInitializer)); + it('keeps the manual prone state override separate from falling automation', () => { + const forceUnit = createForceUnit(); + const triggers: CBTUnitAutomationTrigger[] = []; + forceUnit.automationTriggers.subscribe(trigger => triggers.push(trigger)); - svgService.refreshHeat(); + forceUnit.setCondition('prone', true); + forceUnit.setCondition('prone', false); - expectHeatMarkerAt(svg, 'heat-projection-target-marker', 2); - expectHeatMarkerAt(svg, 'heat-selected-weapons-target-marker', 7); - expect(svg.querySelector('#heat-selected-weapons-target-marker')?.getAttribute('fill')).toBe('orange'); + 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', () => { @@ -2704,6 +3688,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 +3700,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)!); @@ -3016,6 +4010,18 @@ describe('CBTForceUnit direct inventory ammo bins', () => { 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(); @@ -3061,25 +4067,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', () => { @@ -3097,6 +4118,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'); }); @@ -3982,6 +5007,89 @@ 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(), + 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 = { @@ -4428,6 +5536,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()); @@ -4582,32 +5748,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', @@ -5338,4 +6478,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 26bfb4a55..77c93bd11 100644 --- a/src/app/models/cbt-force-unit.model.ts +++ b/src/app/models/cbt-force-unit.model.ts @@ -3,23 +3,24 @@ // 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 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'; 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'; 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'; @@ -28,10 +29,13 @@ 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'; +import { getMekLegLocations, getMekLocationParent, inferMekConfigFromLocations, MEK_REAR_ARMOR_LOCATIONS, type ArmorType } from './entity/types'; +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, EquipmentInteractionRegistry, @@ -42,14 +46,22 @@ 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 { 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'; +import { + createPilotDamageGroup, + isHeatPilotDamageGroup, + isImmediatePilotDamageGroup, +} from '../utils/pilot-damage-group.util'; import { combineEquipmentStatuses, type CriticalSlotStatusFacts, @@ -58,6 +70,14 @@ 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'; +import { isEcmModeActive } from '../utils/ecm-state.util'; export type EquipmentStatusSource = MountedEquipment | CriticalSlot; export type EquipmentAction = @@ -69,6 +89,91 @@ 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 { + /** 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; + /** 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; + /** 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; +} + +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; +} + +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, + 'orientationRoll' | 'damageRolls' +> & { + readonly orientationRoll: number | null; + readonly damageRolls: readonly CBTMekFallDamageRoll[]; +}; + +function fallDamageRollForDialog(roll: SerializedMekFallDamageRoll): CBTMekFallDamageRoll { + return { + hitLocationDice: roll.hitLocationDice ?? null, + tripodLegRoll: roll.tripodLegRoll ?? null, + }; +} + +function rollD6(random: () => number): number { + return Math.floor(random() * 6) + 1; +} + +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; + } + | { + readonly kind: 'hull-breach-check'; + readonly id: string; + readonly location: 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 +183,14 @@ 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>>({}); + readonly pendingFallCount = computed(() => this.turnState().pendingFallCount()); readonly gameRules: CBTGameRules; viewState: ViewportTransform; locations?: { @@ -133,8 +245,13 @@ export class CBTForceUnit extends ForceUnit { const optionsService = this.injector.get(OptionsService, null, { optional: true }); if (optionsService) { this.optionalRulesEffect = effect(() => { + const optionsInitialized = this.isOptionsInitialized(); optionsService.options().CBTOptionalRules?.forcedWithdrawal; - if (this.isLoaded()) untracked(() => this.reconcileRuleChecks()); + optionsService.options().CBTOptionalRules?.sprinting; + if (this.isLoaded()) untracked(() => { + this.reconcileRuleChecks(); + if (optionsInitialized) this.turnState().reconcileRestoredMoveMode(); + }); }, { injector: this.injector, manualCleanup: true }); } this.turnState().capturePassiveHeatSourceBaseline(); @@ -147,18 +264,37 @@ 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 { return this.injector.get(OptionsService, null, { optional: true })?.options().CBTOptionalRules?.extremeRange ?? false; } + usesSprinting(): boolean { + return this.injector.get(OptionsService, null, { optional: true })?.options().CBTOptionalRules?.sprinting ?? false; + } + + isOptionsInitialized(): boolean { + const optionsService = this.injector.get(OptionsService, null, { optional: true }); + return typeof optionsService?.initialized === 'function' + ? optionsService.initialized() + : true; + } + usesForcedWithdrawal(): boolean { 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(); } @@ -328,6 +464,7 @@ export class CBTForceUnit extends ForceUnit { throw new Error(`Unit "${this.unit.name}" loaded but SVG is missing`); } this.isLoaded.set(true); + this.turnState().reconcileRestoredMoveMode(); if (isDevMode()) this.reportUnknownDirectInventoryInstallationLocations(); this.reconcileRuleChecks(); } finally { @@ -647,7 +784,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 { @@ -735,7 +874,64 @@ 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, + ): 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, + damageReceived > 0, + ); + 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, + resolution.appliedDamage > 0, + ); + } + return resolution; + } + + private recordArmorHits( + loc: string, + hits: number, + rear: boolean | undefined, + consolidateImmediately: boolean, + damageReceived: number, + armorDamageApplied: boolean, + ): void { const locKey = rear ? `${loc}-rear` : loc; const locations = { ...this.state.locations() }; @@ -748,12 +944,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); if (consolidateImmediately) this.state.consolidateLocations(); + else this.applyUnderwaterBreachAndFlooding(); + if (armorDamageApplied) this.applyUnderwaterHullBreachCheck(loc, consolidateImmediately); this.evaluateDestroyed(); this.setModified(); } @@ -768,10 +962,48 @@ 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(); } + 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; } @@ -781,7 +1013,53 @@ 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 = {}, + ): 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] = {}; @@ -797,11 +1075,44 @@ 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 = boundedCurrentHits - boundedPreviousHits; + // 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), + 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) { + if (!context.armorDamagedBySameHit) { + this.applyUnderwaterBreachAndFlooding(consolidateImmediately); + this.applyUnderwaterHullBreachCheck(loc, consolidateImmediately); + } + this.queueMekCriticalChance(loc, { + ...context, + locationDestroyed: boundedCurrentHits >= internalPoints, + consolidateImmediately, + }); + } + return phaseDamage; } setInternalHits(loc: string, hits: number) { @@ -853,6 +1164,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); } } @@ -902,31 +1215,144 @@ 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; 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. - 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); - return !armor || this.getCommittedArmorHits(loc, rear) >= this.getArmorPoints(loc, rear); - }); - if (armorBreached) this.setLocationCondition(loc, 'flooded', true, commit); + if (!this.isFloodableLocation(loc)) continue; + if (this.isLocationArmorDepletedForFlooding(loc, commit) + && !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 { @@ -1065,18 +1491,27 @@ 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 (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 === '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; return this.rules.canPerformEquipmentAction(entry, action); @@ -1564,6 +1999,8 @@ export class CBTForceUnit extends ForceUnit { }); this.state.inventory.set([...inventory]); this.inventoryControl.markAmmoSourcesChanged(); + this.psrOutcomeSelections.set({}); + this.psrDiceSelections.set({}); this.state.resetTurnState(); this.evaluateDestroyed(); this.setModified(); @@ -1590,7 +2027,10 @@ export class CBTForceUnit extends ForceUnit { 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 => option.mode === 'stationary' || !unitHasTransmittingGroundMobileHpg(this)) .filter(option => this._rules.isMotiveModeAvailable(option.mode)) .map(option => ({ ...option, @@ -1608,12 +2048,22 @@ export class CBTForceUnit extends ForceUnit { PSRTargetRoll = computed(() => this._rules.PSRTargetRoll()); endPhase() { + this.dispatchEndPhaseEquipmentLifecycle(); 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 } + 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 => @@ -1634,22 +2084,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; + } + + 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'); + 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[] { + return this.turnState().getPendingFalls().map(pending => ({ + ...pending, + orientationRoll: pending.orientationRoll ?? null, + damageRolls: (pending.damageRolls ?? []).map(fallDamageRollForDialog), + })); + } + + getPendingFall(id?: string): CBTPendingMekFall | undefined { + const pending = this.turnState().getPendingFall(id); + if (!pending) return undefined; + return { + ...pending, + orientationRoll: pending.orientationRoll ?? null, + damageRolls: (pending.damageRolls ?? []).map(fallDamageRollForDialog), + }; + } + + setPendingFallRolls( + id: string, + orientationRoll: number | null, + damageRolls: readonly CBTMekFallDamageRoll[], + ): boolean { + const pending = this.getPendingFall(id); + if (!pending) return false; + const normalizedOrientation = orientationRoll !== null + && Number.isInteger(orientationRoll) + && orientationRoll >= 1 + && orientationRoll <= 6 + ? orientationRoll + : null; + 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) + && roll.tripodLegRoll >= 1 + && roll.tripodLegRoll <= 6 + ? roll.tripodLegRoll + : null; + return { + ...(hitLocationDice ? { hitLocationDice } : {}), + ...(tripodLegRoll !== null ? { tripodLegRoll } : {}), + }; + }); + return this.turnState().setPendingFallRolls(id, { + ...(normalizedOrientation !== null ? { orientationRoll: normalizedOrientation } : {}), + damageRolls: normalizedDamageRolls, + }); + } + + /** + * 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; + 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 { + return this.turnState().discardPendingFall(id); + } + + /** + * 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 = {}) { + if (automationDecisions.phaseAlreadyEnded !== true) { + this.dispatchEndPhaseEquipmentLifecycle(); + } 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 @@ -1664,11 +2497,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.turnState().getTurnCounter() + 1); + 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 7cd295bee..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; @@ -69,14 +79,11 @@ 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 { - 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 { @@ -91,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); @@ -149,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(); } @@ -193,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'; @@ -210,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/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/force-serialization.spec.ts b/src/app/models/force-serialization.spec.ts index b74779fc9..35e8a47c4 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'; @@ -124,6 +124,24 @@ describe('heat state sanitization', () => { .toEqual({ id: 'crit' }); }); + it('preserves Sprint as a canonical turn movement mode', () => { + expect(Sanitizer.sanitize({ moveMode: 'sprint' }, TURN_STATE_SCHEMA)) + .toEqual({ moveMode: 'sprint' }); + expect(Sanitizer.sanitize({ moveMode: 'dash' }, TURN_STATE_SCHEMA)) + .toEqual({}); + }); + + 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, @@ -178,6 +196,259 @@ 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, + orientationRoll: 4, + damageRolls: [ + { hitLocationDice: [5, 2] }, + {}, + ], + }, + { + 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', + hitLocationDice: [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, + orientationRoll: 4, + damageRolls: [ + { hitLocationDice: [5, 2] }, + {}, + ], + }, + { + 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', + hitLocationDice: [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', 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' }], + pendingCriticalChances: [{ id: 'legacy:3' }], + }, 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: [{ + 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('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', () => { diff --git a/src/app/models/force-serialization.ts b/src/app/models/force-serialization.ts index fd73457dc..97dc302fc 100644 --- a/src/app/models/force-serialization.ts +++ b/src/app/models/force-serialization.ts @@ -10,6 +10,23 @@ 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'; +import { isOpenCombatPilotDamageGroup } from '../utils/pilot-damage-group.util'; + +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,8 +96,181 @@ 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; +} + +export interface SerializedMekHitLocationRoll { + readonly hitLocationDice?: readonly [number, number]; + readonly tripodLegRoll?: number; +} + +/** Persisted location-table choice awaiting confirmation before slot resolution. */ +export interface SerializedPendingMekFloatingCriticalLocation extends SerializedMekHitLocationRoll { + readonly hitArc: MekHitArc; +} + +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'; + +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 = + | { 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; + /** Open combat-phase pilot damage event retained across save/reload. */ + pilotDamageGroup?: string; + endTurnCheckpoint?: SerializedEndTurnCheckpoint; airborne?: boolean; moveMode?: MotiveModes; moveDistance?: number; @@ -93,6 +283,7 @@ export interface SerializedTurnState { heatDissipationConsumed?: number; psrOutcomes?: Record; psrChecks?: SerializedPSRChecks; + pendingEvents?: SerializedPendingEvent[]; applyMovePSR?: boolean; spotting?: boolean; equipmentStateChanged?: boolean; @@ -424,7 +615,7 @@ export const HEAT_SCHEMA = Sanitizer.schema() .custom('heatsinksOff', sanitizeOptionalNonNegativeNumber) .build(); -const MOTIVE_MODE_VALUES: readonly MotiveModes[] = ['stationary', 'walk', 'run', 'jump', 'UMU', 'VTOL']; +const MOTIVE_MODE_VALUES: readonly MotiveModes[] = ['stationary', 'walk', 'run', 'sprint', 'jump', 'UMU', 'VTOL']; export const PSR_CHECKS_SCHEMA = Sanitizer.schema() .custom('legActuators', sanitizeNumberRecord) @@ -441,8 +632,320 @@ 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 sanitizeMekHitLocationRoll( + record: Record, +): SerializedMekHitLocationRoll | null { + const hitLocationDice = record['hitLocationDice'] === undefined + ? undefined + : 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 { + ...(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']; + 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']; + 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); + default: + return null; + } +} + +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(); + 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('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) .custom('moveMode', (value: unknown) => MOTIVE_MODE_VALUES.includes(value as MotiveModes) ? value as MotiveModes : undefined) .custom('moveDistance', sanitizeOptionalNonNegativeNumber) @@ -461,6 +964,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) @@ -628,7 +1132,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(); diff --git a/src/app/models/motiveModes.model.spec.ts b/src/app/models/motiveModes.model.spec.ts index 3e96bedac..49dcf0620 100644 --- a/src/app/models/motiveModes.model.spec.ts +++ b/src/app/models/motiveModes.model.spec.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { getMotiveModeLabel, getMotiveModesByUnit } from './motiveModes.model'; +import { getMotiveModeLabel, getMotiveModeMaxDistance, getMotiveModesByUnit } from './motiveModes.model'; import type { UnitSummary } from './unit-summary.model'; function createUnit(overrides: Partial = {}): UnitSummary { @@ -20,6 +20,16 @@ function createUnit(overrides: Partial = {}): UnitSummary { } describe('motiveModes', () => { + it('places Sprint after Run for grounded Meks', () => { + const unit = createUnit(); + + expect(getMotiveModesByUnit(unit, false)).toEqual([ + 'stationary', 'walk', 'run', 'sprint', 'jump', + ]); + expect(getMotiveModeLabel('sprint', unit)).toBe('Sprint'); + expect(getMotiveModeMaxDistance('sprint', unit)).toBe(10); + }); + it('maps Aero movement to stationary and thrust modes', () => { const unit = createUnit({ type: 'Aero', subtype: 'Spheroid DropShip', moveType: 'Spheroid', jump: 5, umu: 2 }); @@ -35,10 +45,11 @@ describe('motiveModes', () => { expect(getMotiveModesByUnit(unit, true)).not.toContain('stationary'); expect(getMotiveModesByUnit(unit, true)).toContain('walk'); expect(getMotiveModesByUnit(unit, true)).toContain('run'); + expect(getMotiveModesByUnit(unit, true)).not.toContain('sprint'); }); it('keeps stationary for grounded LAMs and non-LAM airborne units', () => { expect(getMotiveModesByUnit(createUnit({ subtype: 'Land-Air BattleMek' }), false)).toContain('stationary'); expect(getMotiveModesByUnit(createUnit({ moveType: 'VTOL' }), true)).toContain('stationary'); }); -}); \ No newline at end of file +}); diff --git a/src/app/models/motiveModes.model.ts b/src/app/models/motiveModes.model.ts index a6c3b5cde..929f0beea 100644 --- a/src/app/models/motiveModes.model.ts +++ b/src/app/models/motiveModes.model.ts @@ -6,7 +6,7 @@ import type { UnitSummary } from "./unit-summary.model"; export type MotiveState = '' -export type MotiveModes = 'stationary' | 'walk' | 'run' | 'jump' | 'UMU' | 'VTOL'; +export type MotiveModes = 'stationary' | 'walk' | 'run' | 'sprint' | 'jump' | 'UMU' | 'VTOL'; export interface MotiveModeOption { mode: MotiveModes; @@ -31,6 +31,8 @@ export function getMotiveModeLabel(mode: MotiveModes, unit: UnitSummary, airborn return (isVehicle || airborne) ? 'Cruise' : 'Walk'; case 'run': return (isVehicle || airborne) ? 'Flank' : 'Run'; + case 'sprint': + return 'Sprint'; case 'jump': return 'Jump'; case 'UMU': @@ -48,6 +50,8 @@ export function getMotiveModeMaxDistance(mode: MotiveModes, unit: UnitSummary, a return Math.max(unit.walk, unit.walk2); case 'run': return Math.max(unit.run, unit.run2); + case 'sprint': + return Math.max(unit.walk, unit.walk2) * 2; case 'jump': return unit.jump; case 'UMU': @@ -81,6 +85,10 @@ function canJump(unit: UnitSummary, airborne: boolean = false): boolean { return (unit.jump > 0 && !airborne); } +function canSprint(unit: UnitSummary, airborne: boolean = false): boolean { + return unit.type === 'Mek' && !airborne; +} + function canUMU(unit: UnitSummary, airborne: boolean = false): boolean { return (unit.umu > 0); } @@ -104,6 +112,9 @@ export function getMotiveModesByUnit(unit: UnitSummary, airborne: boolean = fals if (canRun(unit, airborne)) { modes.push('run'); } + if (canSprint(unit, airborne)) { + modes.push('sprint'); + } if (canJump(unit, airborne)) { modes.push('jump'); } @@ -122,4 +133,4 @@ export function getMotiveModesOptionsByUnit(unit: UnitSummary, airborne: boolean mode, label: getMotiveModeLabel(mode, unit, airborne) })); -} \ No newline at end of file +} diff --git a/src/app/models/options.model.ts b/src/app/models/options.model.ts index 3bd09db6b..1e56ebc5c 100644 --- a/src/app/models/options.model.ts +++ b/src/app/models/options.model.ts @@ -21,12 +21,28 @@ 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 const CBT_AUTOMATION_KEYS = [ + 'pilotSkillCheck', + 'heatAndDissipationResolution', + 'heatEffectsCheck', + 'pilotHitsAndConsciousnessCheck', + 'internalExplosionsCheck', + 'criticalHitChanceCheck', + 'breachAndFloodCheck', + 'fallingCheck', +] as const; + +export type CBTAutomationKey = typeof CBT_AUTOMATION_KEYS[number]; +export type CBTAutomationOptions = Record; export interface SkillRangeOption { min: number; @@ -61,8 +77,10 @@ export interface ForceGeneratorOptions { export type ForceViewerBVPVDisplay = typeof OPTION_VALUES.forceViewerBVPVDisplay[number]; export interface CBTOptionalRules { + floatingCriticals: boolean; forcedWithdrawal: boolean; extremeRange: boolean; + sprinting: boolean; } export interface Options { @@ -84,7 +102,7 @@ export interface Options { }, sidebarLipPosition?: string; trackPhaseAndTurn: boolean; - cbtAutomations: boolean; + cbtAutomationOptions: CBTAutomationOptions; CBTOptionalRules: CBTOptionalRules; CBTRules: typeof OPTION_VALUES.CBTRules[number]; ASUseHex: boolean; @@ -111,4 +129,4 @@ export interface Options { // Force Budget Optimizer forceBudgetOptimizerLastSkills: ForceBudgetOptimizerLastSkills; -} \ No newline at end of file +} 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..278544726 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,30 @@ export class AeroRules extends UnitTypeRulesBase { // ── PSR / Control Rolls ────────────────────────────────────────────────── + override getStandardControlRollTarget(): number { + const pilotCrewId = this.getActivePilotCrewId(); + return this.getBasePilotingSkill() + + (pilotCrewId === null ? 0 : this.unit.getCrewMember(pilotCrewId)?.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 +125,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 +133,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/game-rules.spec.ts b/src/app/models/rules/game-rules.spec.ts index 180795844..0950cc3b5 100644 --- a/src/app/models/rules/game-rules.spec.ts +++ b/src/app/models/rules/game-rules.spec.ts @@ -300,6 +300,28 @@ 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(); + }); + + 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(); + 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 203c1d631..a6b0b6421 100644 --- a/src/app/models/rules/game-rules.ts +++ b/src/app/models/rules/game-rules.ts @@ -183,6 +183,9 @@ 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[]; abstract readonly radicalHeatSinkFailureTargets: readonly number[]; @@ -213,6 +216,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. */ @@ -473,6 +478,8 @@ 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 = { punch: -1, @@ -522,6 +529,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 }; } @@ -576,7 +591,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; } @@ -601,6 +616,8 @@ 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 = { punch: 0, @@ -654,6 +671,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/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 3b08706d4..1418e483e 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'; @@ -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,10 +71,11 @@ function createRulesHarness(options: { jump?: number; umu?: number; tons?: number; - engine?: string; + engine?: UnitSummary['engine']; subtype?: UnitSubtype; rulesId?: 'core2026' | 'tw'; forcedWithdrawal?: boolean; + sprinting?: boolean; components?: UnitComponent[]; } = {}): MekRules { return createForceUnitHarness(options).rules as MekRules; @@ -135,10 +146,11 @@ function createForceUnitHarness(options: { jump?: number; umu?: number; tons?: number; - engine?: string; + engine?: UnitSummary['engine']; subtype?: UnitSubtype; rulesId?: 'core2026' | 'tw'; forcedWithdrawal?: boolean; + sprinting?: boolean; components?: UnitComponent[]; } = {}): CBTForceUnit { optionsService.options.update(current => ({ @@ -147,6 +159,7 @@ function createForceUnitHarness(options: { CBTOptionalRules: { ...current.CBTOptionalRules, forcedWithdrawal: options.forcedWithdrawal ?? true, + sprinting: options.sprinting ?? false, }, })); const crewStates = options.crewStates ?? ['healthy']; @@ -578,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({})); @@ -599,6 +719,161 @@ describe('MekRules', () => { registry.register(new VibrobladeHandler()); }); + it('offers Sprint after Run only when the optional rule is enabled', () => { + const disabled = createForceUnitHarness({ sprinting: false }); + expect(disabled.getAvailableMotiveModes(false).map(option => option.mode)) + .not.toContain('sprint'); + + const enabled = createForceUnitHarness({ sprinting: true }); + const enabledModes = enabled.getAvailableMotiveModes(false).map(option => option.mode); + expect(enabledModes).toContain('sprint'); + expect(enabledModes.indexOf('sprint')).toBe(enabledModes.indexOf('run') + 1); + }); + + it('requires two working hip actuators to Sprint', () => { + const oneWorkingHip = createForceUnitHarness({ + sprinting: true, + critSlots: [legActuatorCrit('hip', 'Hip', 'LL')], + }); + + expect(oneWorkingHip.getAvailableMotiveModes(false).map(option => option.mode)) + .not.toContain('sprint'); + expect(oneWorkingHip.rules.isMotiveModeAvailable('sprint')).toBeFalse(); + }); + + it('uses twice current Walking MP for Sprint and stacks active movement enhancers', () => { + const forceUnit = createForceUnitHarness({ + sprinting: true, + walk: 6, + critSlots: [crit('MASC', false), crit('Supercharger', false)], + }); + forceUnit.setInventory([ + miscEntry(forceUnit, miscEquipment('MASC', 'MASC', ['F_MASC'])), + miscEntry(forceUnit, miscEquipment('Supercharger', 'Supercharger', ['F_MASC', 'S_SUPERCHARGER'])), + ]); + const [masc, supercharger] = forceUnit.getInventory(); + const rules = forceUnit.rules as MekRules; + const turnState = forceUnit.turnState(); + turnState.moveMode.set('sprint'); + const enhancerChecks = () => rules.getPSRChecks(turnState).filter(check => + check.kind === PSR_CHECK_KIND.SPRINTING_WITH_MOVEMENT_ENHANCER); + + expect(rules.getMaxDistanceForMoveMode('sprint')).toBe(12); + expect(rules.getEffectiveMaxDistanceForMoveMode('sprint', turnState)).toBe(12); + expect(enhancerChecks()).toEqual([]); + + masc.setState(MASC_ACTIVE_STATE_KEY, 'true'); + expect(rules.getEffectiveMaxDistanceForMoveMode('sprint', turnState)).toBe(15); + expect(enhancerChecks()).toEqual([ + jasmine.objectContaining({ + movementMode: 'sprint', + pilotCheck: 0, + reason: 'Sprinting with MASC or supercharger', + }), + ]); + + supercharger.setState(MASC_ACTIVE_STATE_KEY, 'true'); + expect(rules.getEffectiveMaxDistanceForMoveMode('sprint', turnState)).toBe(18); + expect(enhancerChecks().map(check => check.reason)).toEqual([ + 'Sprinting with MASC', + 'Sprinting with supercharger', + ]); + + forceUnit.endPhase(); + + expect(turnState.applyMovePSR()).toBeFalse(); + expect(enhancerChecks()).toEqual([]); + }); + + it('rejects restored Sprint state when the option or current eligibility disallows it', () => { + optionsService.initialized.set(false); + const disabled = createForceUnitHarness({ sprinting: false }); + disabled.turnState().update({ moveMode: 'sprint', moveDistance: 4 }); + expect(disabled.turnState().moveMode()).toBe('sprint'); + expect(disabled.turnState().moveDistance()).toBe(4); + + optionsService.initialized.set(true); + disabled.turnState().reconcileRestoredMoveMode(); + + expect(disabled.turnState().moveMode()).toBeNull(); + expect(disabled.turnState().moveDistance()).toBeNull(); + + const damagedHip = createForceUnitHarness({ + sprinting: true, + critSlots: [legActuatorCrit('hip', 'Hip', 'LL')], + }); + damagedHip.turnState().update({ moveMode: 'sprint', moveDistance: 4 }); + expect(damagedHip.turnState().moveMode()).toBeNull(); + expect(damagedHip.turnState().moveDistance()).toBeNull(); + + const airborne = createForceUnitHarness({ sprinting: true }); + airborne.turnState().update({ airborne: true, moveMode: 'sprint', moveDistance: 4 }); + expect(airborne.turnState().moveMode()).toBeNull(); + expect(airborne.turnState().moveDistance()).toBeNull(); + }); + + it('retains eligible restored Sprint state but removes incompatible spotting', () => { + optionsService.initialized.set(true); + const forceUnit = createForceUnitHarness({ sprinting: true }); + + forceUnit.turnState().update({ + moveMode: 'sprint', + moveDistance: 4, + spotting: true, + }); + + expect(forceUnit.turnState().moveMode()).toBe('sprint'); + expect(forceUnit.turnState().moveDistance()).toBe(4); + expect(forceUnit.turnState().spotting()).toBeFalse(); + }); + + it('does not allow a TW Quad with two destroyed legs to Sprint', () => { + const forceUnit = createForceUnitHarness({ + sprinting: true, + rulesId: 'tw', + subtype: 'Quad BattleMek', + internalLocations: ['RLL', 'FLL', 'RRL', 'FRL'], + committedDestroyedLocations: ['RLL', 'FLL'], + walk: 5, + run: 8, + }); + const rules = forceUnit.rules as MekRules; + + expect(rules.movementState()).toEqual(jasmine.objectContaining({ walk: 1, run: 0 })); + expect(rules.isMotiveModeAvailable('sprint')).toBeFalse(); + expect(forceUnit.getAvailableMotiveModes(false).map(option => option.mode)) + .not.toContain('sprint'); + expect(rules.getEffectiveMaxDistanceForMoveMode('sprint', forceUnit.turnState())).toBe(0); + }); + + it('applies Sprint heat, piloting, defense, and attack restrictions', () => { + const forceUnit = createForceUnitHarness({ sprinting: true }); + const turnState = forceUnit.turnState(); + turnState.moveMode.set('sprint'); + turnState.moveDistance.set(5); + const rules = forceUnit.rules as MekRules; + + expect(rules.heatSources(turnState).find(source => source.id === 'movement')?.value).toBe(3); + expect(rules.PSRModifiers().modifiers).toContain(jasmine.objectContaining({ + pilotCheck: 2, + reason: 'Sprinting', + })); + expect(rules.getDefenseModifierBreakdown(turnState)).toContain(jasmine.objectContaining({ + label: 'Sprinting', + modifier: -1, + })); + expect(rules.canPerformEquipmentAction(directFireWeaponEntry(forceUnit), 'fire')).toBeFalse(); + expect(rules.canPerformEquipmentAction(punchEntry(forceUnit), 'physical-attack')).toBeFalse(); + }); + + it('rounds down the extra Sprint heat generated by an XXL engine', () => { + const forceUnit = createForceUnitHarness({ sprinting: true, engine: 'XXL (IS)' }); + forceUnit.turnState().moveMode.set('sprint'); + + expect(forceUnit.rules.heatSources(forceUnit.turnState()) + .find(source => source.id === 'movement')?.value).toBe(9); + }); + it('keeps Mek immobile false by default when crew are functional', () => { const rules = createRulesHarness(); @@ -611,6 +886,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')] }); @@ -1059,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], @@ -1133,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); @@ -1360,6 +1744,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: [ @@ -1875,22 +2428,45 @@ describe('MekRules', () => { } }); - it('uses the first active alternate pilot with a modifier when the Tripod dedicated pilot is disabled', () => { + 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); 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 +2594,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 +2606,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 +2974,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] }); + + forceUnit.endPhase(); - expect(rules.hasComputedCondition('abandoned')).toBeTrue(); + expect(forceUnit.rules.hasComputedCondition('abandoned')).toBeTrue(); }); it('does not mark Meks abandoned while any crew member is alive in the unit', () => { @@ -2461,13 +3041,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, @@ -2476,12 +3059,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({ @@ -2489,7 +3072,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, @@ -2498,7 +3081,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(); @@ -2513,7 +3096,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 => ({ @@ -2524,7 +3107,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 => ({ @@ -2535,7 +3118,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(); }); @@ -2545,14 +3128,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( @@ -2575,20 +3158,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(); }); @@ -2598,13 +3181,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'); @@ -2617,7 +3200,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(); @@ -2636,7 +3219,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(); }); @@ -2655,11 +3238,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(); }); @@ -2671,7 +3254,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(); }); @@ -2685,7 +3268,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 +4027,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'], @@ -3452,22 +4035,27 @@ 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'); 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, })); }); @@ -3597,6 +4185,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.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)) + .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'], @@ -3666,6 +4340,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'] }, @@ -3697,7 +4415,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'], @@ -3710,7 +4428,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); }); @@ -3758,9 +4478,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) @@ -3792,7 +4515,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', () => { @@ -3803,7 +4526,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(); }); @@ -3831,6 +4554,27 @@ 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.moveMode.set('run'); + turnState.moveDistance.set(0); + 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(); @@ -3870,8 +4614,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); }); @@ -4019,7 +4763,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, @@ -4212,12 +4958,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(); @@ -4227,6 +4988,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 89ccc6d42..cbd47c3f1 100644 --- a/src/app/models/rules/mek-rules.ts +++ b/src/app/models/rules/mek-rules.ts @@ -4,10 +4,10 @@ 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'; +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'; @@ -16,6 +16,7 @@ import { getDefaultAttackerMovementModifier, TN_PRONE, TN_PRONE_ADJACENT, TN_PRO import { getMekLegLocations, getMekLimbLocations, + getMekLocationParent, inferMekConfigFromLocations, isMekLegLocation, LEG_LOCATIONS, @@ -24,19 +25,35 @@ 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'; -const LEG_DAMAGE_MOVEMENT_CRITICAL_NAMES: Record = { - 'damaged-leg-actuator-movement': ['Leg', 'Foot', 'Hip'], - 'damaged-hip-movement': ['Hip'], +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'], }; interface MekArmStatus { @@ -99,6 +116,12 @@ 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 { + 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; } @@ -209,7 +232,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 +375,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,12 +389,54 @@ 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(() => { 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[] { @@ -383,11 +450,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', + reason: 'Crippling destruction', + failure: { kind: PSR_FAILURE_KIND.RULE_RESOLUTION, label: 'Crippled' }, resolution: { key: TORSO_CRIPPLE_CHECK_KEY, token: torsoCheck.token, @@ -395,6 +463,13 @@ 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; + + checks.push(...this.sprintingMovementEnhancerPSRChecks(turnState)); + if (psr.gyroDestroyed) { const destroyedGyroCheck = this.destroyedGyroPSRCheck(); if (destroyedGyroCheck) checks.push(this.withPSRLocation(destroyedGyroCheck, this.getGyroDamageLocation())); @@ -403,6 +478,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, @@ -413,6 +490,8 @@ export class MekRules extends UnitTypeRulesBase { } else { if (psr.shutdown) { checks.push({ + kind: PSR_CHECK_KIND.SHUTDOWN, + failure: FALL_PSR_FAILURE, fallCheck: 3, pilotCheck: 3, reason: 'Shutdown' @@ -420,6 +499,8 @@ export class MekRules extends UnitTypeRulesBase { } 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` @@ -442,6 +523,25 @@ export class MekRules extends UnitTypeRulesBase { return checks; } + private sprintingMovementEnhancerPSRChecks(turnState: TurnState): PSRCheck[] { + if (!turnState.applyMovePSR() || turnState.effectiveMoveMode() !== 'sprint') return []; + + const activeEnhancerCount = Math.max(0, Math.round( + this.unit.getRunMovementMultiplierBonus(turnState) * 2, + )); + const enhancerLabels = activeEnhancerCount === 1 + ? ['MASC or supercharger'] + : ['MASC', 'supercharger']; + return enhancerLabels.slice(0, activeEnhancerCount).map(enhancer => ({ + kind: PSR_CHECK_KIND.SPRINTING_WITH_MOVEMENT_ENHANCER, + failure: FALL_PSR_FAILURE, + movementMode: 'sprint', + fallCheck: 0, + pilotCheck: 0, + reason: `Sprinting with ${enhancer}`, + })); + } + private getGyroDamageLocation(): string | undefined { const gyroSlots = this.unit.getCritSlots().filter(slot => this.isNamedCrit(slot, 'Gyro')); return gyroSlots.find(slot => this.isDestroyedOrDestroyingCrit(slot))?.loc ?? gyroSlots[0]?.loc; @@ -454,27 +554,34 @@ 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({ + kind: PSR_CHECK_KIND.LEG_DAMAGE, + failure: FALL_PSR_FAILURE, + 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', + this.currentStandardHipHitLocations(psr.hipsHit).forEach(loc => { + checks.push({ + kind: PSR_CHECK_KIND.LEG_DAMAGE, + failure: FALL_PSR_FAILURE, + fallCheck: this.hipPSRModifier, + pilotCheck: this.hipPSRModifier, + loc, + reason: 'Hip hit', + }); }); - }); + } if (movementCheck) { checks.push(...(this.getLegActuatorMovementPSRChecks(movementCheck) ?? [])); } @@ -484,7 +591,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, { @@ -492,6 +599,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), @@ -502,6 +610,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 } { @@ -515,12 +664,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'); @@ -550,6 +707,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', @@ -560,6 +719,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', @@ -574,40 +735,50 @@ export class MekRules extends UnitTypeRulesBase { } override getCommittedDamageMovementModePSRCheck(moveMode: MotiveModes | null, moveDistance?: number | null): PSRCheck | null { - if (moveMode !== 'run' && moveMode !== 'jump') return null; + if (moveMode !== 'run' && moveMode !== 'sprint' && moveMode !== 'jump') return null; if (moveDistance === null) return null; - if (moveMode === 'run' + if ((moveMode === 'run' || moveMode === 'sprint') && moveDistance !== undefined && moveDistance < 1 && this.runningDamageCheckRequiresHexMovement()) return null; + const movementLabel = moveMode === 'jump' + ? 'Jumping' + : moveMode === 'sprint' ? 'Sprinting' : 'Running'; + const critSlots = this.unit.getCritSlots(); const damagedGyro = critSlots.find(slot => this.isCritUnavailable(slot) && this.isNamedCrit(slot, 'Gyro')); 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); } }); 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.unit.isInternalLocCommittedDestroyed(slot.loc)) 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 destroyedLegsApplyHipCheck = this.destroyedLegsApplyHipMovementCheck( + const twoDestroyedQuadLegsApplyHipEffects = this.twoDestroyedQuadLegsApplyHipMovementEffects( isQuadruped, destroyedLegsCount, ); @@ -617,11 +788,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: `${movementLabel} with two destroyed legs`, }; } if (hasDamagedLeg && damagedLegRequiresCheck) { @@ -631,18 +805,23 @@ 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 }), - reason: 'Jumping with damaged leg' + reason: `${movementLabel} with damaged leg` }; } 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' + reason: `${movementLabel} with damaged leg actuator` }; } return null; @@ -652,45 +831,52 @@ 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: `${movementLabel} 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 }), - reason: 'Running with damaged leg' + reason: `${movementLabel} with damaged leg` }; } 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, + failure: FALL_PSR_FAILURE, + movementMode: moveMode, fallCheck: 0, pilotCheck: 0, - kind: 'damaged-hip-movement', - reason: 'Running with damaged hip' + reason: `${movementLabel} with damaged hip` }; } } return null; } - protected damagedGyroMovementPSRCheck(moveMode: 'run' | 'jump'): PSRCheck | null { + protected damagedGyroMovementPSRCheck(moveMode: 'run' | 'sprint' | 'jump'): PSRCheck | null { if (this.hasHeavyDutyGyro()) { - if (moveMode === 'run') return null; + if (moveMode !== 'jump') 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', @@ -698,9 +884,12 @@ 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`, + reason: `${moveMode === 'jump' ? 'Jumping' : moveMode === 'sprint' ? 'Sprinting' : 'Running'} with damaged gyro`, }; } @@ -709,7 +898,7 @@ export class MekRules extends UnitTypeRulesBase { } protected destroyedLegMovementPSRModifier( - _moveMode: 'run' | 'jump', + _moveMode: 'run' | 'sprint' | 'jump', _isQuadruped: boolean, _destroyedLegsCount: number, ): number { @@ -724,14 +913,17 @@ 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; } 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) { @@ -753,31 +945,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(); @@ -797,6 +983,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; } @@ -826,22 +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') { + } else if (moveMode === 'run' || moveMode === 'sprint') { if (superCooledMyomerActive) return 0; - return hasXXLEngine ? 6 : 2; + 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); @@ -880,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 @@ -892,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); } @@ -1000,7 +1253,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; @@ -1120,10 +1373,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; @@ -1154,6 +1407,13 @@ export class MekRules extends UnitTypeRulesBase { let ignorePreExistingGyro = false; let currentModifiers = 0; const turnState = this.unit.turnState(); + if (turnState.effectiveMoveMode() === 'sprint') { + currentModifiers += 2; + modifiers.push({ + pilotCheck: 2, + reason: 'Sprinting', + }); + } const phasePSRs = turnState.getPSRChecks(); phasePSRs.forEach((check) => { if (check.pilotCheck === undefined) return; // No fall check, skip @@ -1183,7 +1443,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({ @@ -1233,7 +1493,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 => ({ @@ -1261,7 +1521,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) @@ -1272,9 +1532,13 @@ export class MekRules extends UnitTypeRulesBase { slots.push(slot); slotsByLocation.set(slot.loc!, slots); } - const modifiers: PSRCheck[] = []; + 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({ @@ -1310,7 +1574,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 { @@ -1329,10 +1593,28 @@ export class MekRules extends UnitTypeRulesBase { return this.getBasePilotingSkill() + modifiers.modifier; }); + private hasTwoWorkingHipActuators(): boolean { + const internalLocations = this.unit.locations?.internal; + if (!internalLocations) return false; + + const config = inferMekConfigFromLocations(internalLocations.keys()); + const unavailableHipLocations = new Set(this.unit.getCritSlots() + .filter(slot => slot.loc + && this.isNamedCrit(slot, 'Hip') + && this.isCritUnavailable(slot)) + .map(slot => slot.loc as string)); + const workingHips = getMekLegLocations(config).filter(location => + internalLocations.has(location) + && !this.isLegDestroyed(location) + && !unavailableHipLocations.has(location)); + return workingHips.length >= 2; + } + override getMaxDistanceForMoveMode(moveMode: MotiveModes): number | null { const movement = this.movementState(); if (moveMode === 'walk') return movement?.maxWalk ?? 0; if (moveMode === 'run') return movement?.maxRun ?? 0; + if (moveMode === 'sprint') return (movement?.walk ?? 0) * 2; if (moveMode === 'jump') return movement?.jump ?? 0; if (moveMode === 'UMU') return movement?.UMU ?? 0; return null; @@ -1345,6 +1627,12 @@ export class MekRules extends UnitTypeRulesBase { if (moveMode === 'run') { return (movement?.run ?? 0) > 0 || this.getRunningMinimumMovementDistance() > 0; } + if (moveMode === 'sprint') { + return this.unit.usesSprinting() + && (movement?.walk ?? 0) > 0 + && (movement?.run ?? 0) > 0 + && this.hasTwoWorkingHipActuators(); + } if (moveMode === 'jump') return (movement?.jump ?? 0) > 0; if (moveMode === 'UMU') return (movement?.UMU ?? 0) > 0; return true; @@ -1413,17 +1701,21 @@ export class MekRules extends UnitTypeRulesBase { } override getEffectiveMaxDistanceForMoveMode(moveMode: MotiveModes, turnState: TurnState): number | null { - if (moveMode !== 'run') return this.getMaxDistanceForMoveMode(moveMode); + if (moveMode !== 'run' && moveMode !== 'sprint') return this.getMaxDistanceForMoveMode(moveMode); const movement = this.movementState(); if (!movement) return 0; - if (movement.run === 0) return this.getRunningMinimumMovementDistance(); + if (moveMode === 'run' && movement.run === 0) return this.getRunningMinimumMovementDistance(); + if (moveMode === 'sprint' && movement.run === 0) return 0; + if (movement.walk === 0) return 0; - const runValueCoeff = 1.5 + this.unit.getRunMovementMultiplierBonus(turnState); - const armorModifierOnRun = (this.unit.getUnit().armorType === 'Hardened') ? -1 : 0; - return Math.max(0, Math.round(movement.walk * runValueCoeff) + armorModifierOnRun); + const movementValueCoeff = (moveMode === 'sprint' ? 2 : 1.5) + + this.unit.getRunMovementMultiplierBonus(turnState); + const armorModifier = moveMode === 'run' ? this.hardenedArmorRunModifier() : 0; + return Math.max(0, Math.round(movement.walk * movementValueCoeff) + armorModifier); } override getAttackMovementModifier(moveMode: MotiveModes | null | undefined, airborne: boolean = false): number { + if (moveMode === 'sprint') return 0; const baseUnit = this.unit.getUnit(); // LAM have different movement modifiers when airborne if (baseUnit.subtype === 'Land-Air BattleMek' && airborne) { @@ -1439,17 +1731,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[] { @@ -1522,13 +1804,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 { @@ -1548,7 +1830,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; } } @@ -1566,6 +1848,9 @@ export class MekRules extends UnitTypeRulesBase { override getDefenseModifierBreakdown(turnState: TurnState): UnitModifierBreakdownEntry[] { const entries = [...super.getDefenseModifierBreakdown(turnState)]; + if (turnState.effectiveMoveMode() === 'sprint') { + entries.push({ label: 'Sprinting', modifier: -1 }); + } if (turnState.unitState.hasCondition('prone')) { entries.push({ label: 'Prone', @@ -1682,7 +1967,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, @@ -1884,8 +2169,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) { @@ -1953,7 +2239,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) { @@ -2004,6 +2290,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; @@ -2011,7 +2298,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: { @@ -2040,6 +2329,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; @@ -2083,7 +2421,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( @@ -2122,6 +2466,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, @@ -2135,6 +2503,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) { @@ -2205,6 +2605,11 @@ 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; @@ -2269,7 +2674,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()) { @@ -2295,25 +2704,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()) { @@ -2360,7 +2753,94 @@ 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[] { + 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( @@ -2479,12 +2959,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; @@ -2504,7 +2986,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.spec.ts b/src/app/models/rules/tw-rules.spec.ts index a81410f1a..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 { @@ -109,6 +110,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 }, @@ -131,6 +151,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].failure).withContext(label).toEqual(FALL_PSR_FAILURE); + 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(); @@ -364,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); @@ -384,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 93c5357d6..34fd4f4e2 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'; @@ -63,6 +70,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); @@ -112,27 +154,34 @@ 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({ + kind: PSR_CHECK_KIND.LEG_ACTUATOR_HIT, + failure: FALL_PSR_FAILURE, + fallCheck: 1, + pilotCheck: 1, + loc, + reason: 'Leg actuator hit', + }); + } + }); + psr.hipsHit?.forEach(loc => { checks.push({ - fallCheck: 1, - pilotCheck: 1, + kind: PSR_CHECK_KIND.HIP_HIT, + failure: FALL_PSR_FAILURE, + 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, - reason: 'Hip hit', }); - }); + } if (this.isLegDamageMovementPSRCheck(movementCheck)) { checks.push(movementCheck); } @@ -142,12 +191,19 @@ 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.kind === PSR_CHECK_KIND.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) { @@ -156,7 +212,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(); @@ -180,6 +237,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. @@ -187,13 +245,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, ), ); } @@ -207,15 +269,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; } @@ -247,7 +319,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 { @@ -281,6 +353,12 @@ 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 { + return totalHipHits; + } protected override get lowerArmFireModifier(): number { return 1; } protected override get footHitsCausePSR(): boolean { return true; } @@ -289,10 +367,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', @@ -302,6 +387,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', @@ -309,11 +396,14 @@ export class TWMekRules extends MekRules { }; } - protected override damagedGyroMovementPSRCheck(moveMode: 'run' | 'jump'): PSRCheck { + protected override damagedGyroMovementPSRCheck(moveMode: 'run' | 'sprint' | '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`, + reason: `${moveMode === 'jump' ? 'Jumping' : moveMode === 'sprint' ? 'Sprinting' : 'Running'} with damaged gyro`, }; } @@ -326,7 +416,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' }; @@ -349,7 +439,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 [{ @@ -360,7 +450,7 @@ export class TWMekRules extends MekRules { } protected override destroyedLegMovementPSRModifier( - moveMode: 'run' | 'jump', + moveMode: 'run' | 'sprint' | 'jump', isQuadruped: boolean, destroyedLegsCount: number, ): number { @@ -383,7 +473,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 272299fbd..6915fb8ab 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, @@ -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'; @@ -30,26 +30,95 @@ import type { UnitSystemStatusFacts, } from '../equipment-status.model'; -export type PSRCheckKind = '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', + SPRINTING_WITH_MOVEMENT_ENHANCER: 'sprinting-with-movement-enhancer', +} 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' | 'sprint' | '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; @@ -62,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. */ @@ -75,6 +146,8 @@ export interface UnitHeatSource { replacedByFiringEntryId?: string; } +export const EQUIPMENT_HEAT_SOURCE_GROUP = 'Equipment'; + export interface ChargeDamage { damage: number | null; maxDamage: number | null; @@ -256,7 +329,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; @@ -369,6 +442,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; @@ -436,7 +512,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(() => [ @@ -601,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[] { @@ -717,6 +796,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 +830,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 +874,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.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 0b796bb6d..ebf03f7f1 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'; @@ -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) { @@ -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), @@ -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/stealth-equipment.model.ts b/src/app/models/stealth-equipment.model.ts index 89a59a3ce..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 { ECM_MODE_STATE_KEY } from '../equipment-handlers/ecm.handler'; +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 = equipment.states.get(ECM_MODE_STATE_KEY) ?? ECMMode.ECM; +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 1a68ab31a..382e08a69 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'; @@ -34,6 +40,10 @@ interface TurnStateHarnessOptions { skidding?: boolean; rulesType?: 'mek' | 'infantry' | 'aero'; rulesId?: 'core2026' | 'tw'; + crewState?: 'healthy' | 'unconscious' | 'ejected' | 'killed'; + outOfControl?: boolean; + phaseTracking?: boolean; + turnCounter?: number; } interface TurnStateHarness { @@ -86,6 +96,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 = { @@ -94,8 +110,15 @@ function createTurnStateHarness(options: TurnStateHarnessOptions = {}): TurnStat isLoaded: () => true, destroyed: options.destroyed ?? false, shutdown: options.shutdown ?? false, - getCondition: (condition: string) => condition === 'immobile' && (options.immobile ?? 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(), @@ -106,9 +129,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) @@ -125,7 +152,10 @@ function createTurnStateHarness(options: TurnStateHarnessOptions = {}): TurnStat return true; }, 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' }, @@ -152,7 +182,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) @@ -346,6 +376,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(); @@ -428,7 +490,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); @@ -444,23 +506,41 @@ 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); 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); }); - 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(); @@ -471,6 +551,141 @@ 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([ + { + 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); + 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([ + { + 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.kind === PSR_CHECK_KIND.DAMAGE_THRESHOLD); + 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([ + { + 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(); + 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: 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]); + + 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('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); @@ -540,6 +755,667 @@ describe('TurnState', () => { 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', + [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', + hitLocationDice: [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('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'); + 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); @@ -772,6 +1648,7 @@ describe('TurnState', () => { 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); @@ -1113,7 +1990,7 @@ describe('TurnState', () => { expect(getReasons(turnState)).not.toContain('Jumping with damaged leg actuator'); }); - it('folds TW destroyed-leg stand movement PSRs into the single stand roll', () => { + it('does not expose separate TW movement fall PSRs while prone during a destroyed-leg stand', () => { const scenarios = [ { label: 'biped with damaged gyro', @@ -1151,8 +2028,8 @@ describe('TurnState', () => { turnState.moveDistance.set(0); const standModifier = rules.PSRModifiers().modifier; - expect(getReasons(turnState)).withContext(scenario.label).toContain(scenario.movementReason); - expect(turnState.PSRRollsCount()).withContext(scenario.label).toBe(1); + 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(); @@ -1162,7 +2039,7 @@ describe('TurnState', () => { } }); - it('keeps ordinary TW running PSRs separate from an ordinary stand roll', () => { + it('does not create an ordinary TW running fall PSR after a failed stand', () => { const { turnState } = createTurnStateHarness({ prone: true, rulesId: 'tw', @@ -1173,8 +2050,8 @@ describe('TurnState', () => { expect(turnState.resolveStandAttempt('failed')).toBeTrue(); - expect(getReasons(turnState)).toContain('Running with damaged gyro'); - expect(turnState.PSRRollsCount()).toBe(1); + expect(getReasons(turnState)).not.toContain('Running with damaged gyro'); + expect(turnState.PSRRollsCount()).toBe(0); }); }); @@ -1534,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; @@ -1568,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(); @@ -1585,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(); @@ -1600,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 88f8fb0c9..738f88f4e 100644 --- a/src/app/models/turn-state.model.ts +++ b/src/app/models/turn-state.model.ts @@ -5,9 +5,39 @@ import { computed, signal, type WritableSignal } from "@angular/core"; import { canChangeAirborneGround, getMotiveModeMaxDistance, type MotiveModes } from "./motiveModes.model"; import type { CBTForceUnitState } from "./cbt-force-unit-state.model"; -import type { RuleCheckOutcome, SerializedPSRChecks, SerializedTurnState } from "./force-serialization"; -import { calculateModifierTotal, type PSRCheck, type UnitHeatSource, type UnitModifierBreakdownEntry, type UnitModifierTotal } from "./rules/unit-type-rules"; +import type { + PendingEventInput, + RuleCheckOutcome, + SerializedEndTurnCheckpoint, + SerializedMekCriticalChanceResult, + SerializedPendingEvent, + SerializedPendingMekCriticalCaseII, + SerializedPendingMekCritical, + SerializedPendingMekCriticalChance, + SerializedPendingMekFloatingCriticalLocation, + SerializedPendingMekFall, + SerializedPendingUnitCheck, + SerializedPSRChecks, + SerializedTurnState, +} from "./force-serialization"; +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, + 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"; @@ -49,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; @@ -56,6 +87,8 @@ 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; @@ -123,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 @@ -155,7 +189,6 @@ export class TurnState { return { ...check, id: occurrence === 0 ? baseId : `${baseId}#${occurrence + 1}`, - failureOutcome: check.failureOutcome ?? 'Fall', }; }); }); @@ -201,13 +234,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 => !isFallPSRCheck(check)).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 !== PSR_CHECK_KIND.SHUTDOWN); + } + getPSROutcome(checkId: string): RuleCheckOutcome | undefined { return this.psrOutcomes()[checkId]; } @@ -215,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]; @@ -227,7 +287,12 @@ 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 (isFallPSRCheck(check) && !this.unitState.hasCondition('prone')) { + this.unitState.unit.queueFall('psr'); + this.unitState.unit.setCondition('prone', true); + } + } return true; } @@ -240,7 +305,11 @@ export class TurnState { this.carefulStand.set(true); this.clampMoveDistanceToCurrentModeRange(); } - if (outcome === 'success') this.unitState.unit.setCondition('prone', false); + if (outcome === 'success') { + this.unitState.unit.setCondition('prone', false); + } else { + this.unitState.unit.queueFall('stand-attempt'); + } return true; } @@ -254,6 +323,26 @@ export class TurnState { 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); @@ -282,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('|'); } @@ -305,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)); }); @@ -438,8 +528,10 @@ export class TurnState { 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; @@ -458,16 +550,26 @@ 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; + 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); this.moveDistance.set(data?.moveDistance ?? null); @@ -482,20 +584,61 @@ 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); }); + this.reconcileRestoredMoveMode(); + } + + /** Revalidates persisted Sprint state once enough unit state is available. */ + reconcileRestoredMoveMode(): void { + if (this.moveMode() !== 'sprint') return; + + const forceUnit = this.unitState.unit; + if (!forceUnit.isLoaded() || !forceUnit.isOptionsInitialized()) return; + + const airborne = this.airborne() === true; + const sprintAvailable = forceUnit.getAvailableMotiveModes(airborne) + .some(option => option.mode === 'sprint'); + + this.withSuppressedModified(() => { + if (!sprintAvailable) { + this.moveMode.set(null); + this.moveDistance.set(null); + return; + } + + // A valid Sprint and spotting can never coexist, including in restored data. + this.spotting.set(false); + }); } getTurnCounter(): number { return this.turnCounter; } - markEquipmentStateChanged(): void { + 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); } @@ -553,8 +696,530 @@ 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, + dice: readonly number[] | null, + tripodLegRoll: number | null = null, + ): boolean { + const pending = this.getPendingCriticalHit(id); + const floating = pending?.floatingLocation; + if (!pending || !floating) return false; + if (dice !== null && (dice.length !== 2 + || 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, + ...(dice !== null ? { hitLocationDice: [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 }); + } + + 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'); + } + + 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) { @@ -563,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/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..89a8e8827 --- /dev/null +++ b/src/app/models/unit-check.model.ts @@ -0,0 +1,381 @@ +// 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; + /** Presentation only; check behavior is selected by the typed registry key. */ + 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/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/models/unit-summary.model.ts b/src/app/models/unit-summary.model.ts index 9b1969395..3cceddd32 100644 --- a/src/app/models/unit-summary.model.ts +++ b/src/app/models/unit-summary.model.ts @@ -35,6 +35,42 @@ 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'; + +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) ); @@ -73,6 +109,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; @@ -114,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; @@ -133,7 +177,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... @@ -145,6 +189,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/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/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..72f6c9b99 --- /dev/null +++ b/src/app/services/cbt-end-turn.service.spec.ts @@ -0,0 +1,566 @@ +// 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; + 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; + 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, + getTurnCounter: () => turnCounter, + 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(); + 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('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); + 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..0d5d66faf --- /dev/null +++ b/src/app/services/cbt-end-turn.service.ts @@ -0,0 +1,404 @@ +// 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 endTurnQueue: Promise = Promise.resolve(); + private readonly pendingEndTurns = new Map>(); + + /** Commits a turn only after its resumable phase, heat, and consequence sequence is complete. */ + 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 { + 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..54578d797 --- /dev/null +++ b/src/app/services/cbt-phase-resolution.service.spec.ts @@ -0,0 +1,400 @@ +// 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 { + 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'; +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: PSR_CHECK_KIND.SHUTDOWN, + failure: FALL_PSR_FAILURE, reason: 'Shutdown', fallCheck: 3, + }, + { + 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 !== PSR_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 && isFallPSRCheck(check) && !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, + 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 new file mode 100644 index 000000000..d57ee9c42 --- /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 { 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'; +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 => !isFallPSRCheck(candidate)) ?? unresolved[0] + : unresolved[0]; + const target = unit.PSRTargetRoll(); + const automaticFailure = turnState.isPSRCheckAutomaticFailure(check) + || (turnState.autoFall() && isFallPSRCheck(check)); + 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/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 fb4deee71..f844147c9 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'; @@ -306,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); } @@ -431,6 +440,10 @@ export class DataService { return this.unitSearchIndexService.getIndexedFilterValues(filterKey); } + public getIndexedASSpecials(unitUuid: string): ParsedASSpecials | undefined { + return this.unitSearchIndexService.getIndexedASSpecials(unitUuid); + } + public getSearchWorkerIndexSnapshot(): UnitSearchWorkerIndexSnapshot { return this.unitSearchIndexService.getSearchWorkerIndexSnapshot(); } @@ -439,7 +452,11 @@ export class DataService { return this.unitSearchIndexService.getSearchWorkerFactionEraSnapshot(); } - public getDropdownOptionUniverse(filterKey: string): Array<{ name: string; img?: string }> { + 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/equipment-interaction-registry.service.ts b/src/app/services/equipment-interaction-registry.service.ts index 07bd8fff3..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, @@ -97,6 +105,7 @@ export type HandlerNotifications = Pick; export interface HandlerDialogsService { createDialog: DialogsService['createDialog']; + requestConfirmation: DialogsService['requestConfirmation']; showError: DialogsService['showError']; showNoticeHtml: DialogsService['showNoticeHtml']; } @@ -173,13 +182,18 @@ 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. */ 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, @@ -432,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 { @@ -444,6 +461,12 @@ export class EquipmentInteractionRegistry { } } + onEndPhase(equipment: MountedEquipment): void { + for (const handler of this.getHandlers(equipment)) { + handler.onEndPhase?.(equipment); + } + } + getCriticalDelayedExplosion( hitEntry: MountedEquipment, explosionContext: CriticalDelayedExplosionContext, @@ -567,11 +590,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/falling-resolution.service.spec.ts b/src/app/services/falling-resolution.service.spec.ts new file mode 100644 index 000000000..1917870a5 --- /dev/null +++ b/src/app/services/falling-resolution.service.spec.ts @@ -0,0 +1,419 @@ +// 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 { resolveMekFallArmorDamage } from '../utils/mek-falling.util'; +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); + return hits; + }); + const applyHeadHitCrewHits = jasmine.createSpy('applyHeadHitCrewHits').and.returnValue(1); + const pendingFalls: Array<{ + id: string; + source: 'psr' | 'stand-attempt'; + levelsFallen: number; + orientationRoll: number | null; + damageRolls: CBTMekFallDamageRoll[]; + }> = [{ + id: 'fall:1', + source: 'psr' as const, + levelsFallen: 0, + orientationRoll: 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 }, + turnState: () => ({ cover: () => undefined }), + 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[], + ) => { + const pending = pendingFalls.find(candidate => candidate.id === id); + if (!pending) return false; + pending.orientationRoll = orientationRoll; + pending.damageRolls = [...damageRolls]; + return true; + }, + completePendingFall, + skipPendingFall, + getArmorPoints: (location: string) => location === 'HD' ? 9 : 10, + getArmorHits: (location: string) => armorHits.get(location) ?? 0, + 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').and.callFake((_location: string, hits: number) => hits), + queueMekCriticalChance: jasmine.createSpy('queueMekCriticalChance'), + 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..8ee213e54 --- /dev/null +++ b/src/app/services/falling-resolution.service.ts @@ -0,0 +1,238 @@ +// 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 } 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, + 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 damageGroups = resolvedMekFallDamageGroups(resolveMekFallDamage( + unit.gameRules.id, + unit.getUnit().tons, + trigger.levelsFallen, + unitCoverWaterDepth(unit.turnState().cover()), + )); + const hitLocationTable = clusterTableForUnit(unit.getUnit()).hitLocationTable ?? 'biped'; + 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.'); + } + return { ...result, damage: damageGroups[index] }; + }); + + unit.setPendingFallRolls( + trigger.id, + rolled.orientationRoll, + rolled.damageRolls, + ); + return { action: 'accept', orientation: rolled.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.appliedDamage, + ); + } + 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 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..56dde815e --- /dev/null +++ b/src/app/services/mek-critical-hit-automation.service.spec.ts @@ -0,0 +1,167 @@ +// 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: [] }), + getArmorTypeAt: () => 'STANDARD', + getStructureKindAt: () => 'standard', + 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..fdf3b4985 --- /dev/null +++ b/src/app/services/mek-critical-resolution.service.spec.ts @@ -0,0 +1,1049 @@ +// 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, + dice: readonly [number, number] | null, + tripodLegRoll: number | null, + ) => updateHit(id, pending => ({ + ...pending, + floatingLocation: { + hitArc: pending.floatingLocation!.hitArc, + ...(dice !== null ? { hitLocationDice: 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: [] }), + getArmorTypeAt: () => 'STANDARD', + getStructureKindAt: () => 'standard', + 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 { getArmorTypeAt: () => string }).getArmorTypeAt = () => 'HARDENED'; + 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([3, 4], null); + expect(pendingHits[0].floatingLocation).toEqual({ + hitArc: 'front', + hitLocationDice: [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', + hitLocationDice: [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', + 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.initialDice).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..53fe712f6 --- /dev/null +++ b/src/app/services/mek-critical-resolution.service.ts @@ -0,0 +1,708 @@ +// 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, 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'; +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, + initialDice: floating.hitLocationDice, + initialTripodLegRoll: floating.tripodLegRoll, + onDraftChange: (dice, tripodLegRoll) => + turnState.setPendingFloatingCriticalLocation( + pending.id, + 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 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 + && 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, + dice, + 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 f1808af80..d06e3f9ef 100644 --- a/src/app/services/options.service.spec.ts +++ b/src/app/services/options.service.spec.ts @@ -82,7 +82,6 @@ describe('OptionsService', () => { megaMekAvailabilityFiltersUseAllScopedOptions: 1, recordSheetDoubleTapZoomReset: 'always', trackPhaseAndTurn: 'true', - cbtAutomations: 1, CBTRules: 'basic', ASUseHex: 'false', c3NetworkConnectionsAboveNodes: 0, @@ -114,7 +113,6 @@ describe('OptionsService', () => { megaMekAvailabilityFiltersUseAllScopedOptions: true, recordSheetDoubleTapZoomReset: 'contextual', trackPhaseAndTurn: true, - cbtAutomations: false, CBTRules: 'tw', ASUseHex: false, c3NetworkConnectionsAboveNodes: false, @@ -190,6 +188,8 @@ describe('OptionsService', () => { expect(service.options().CBTOptionalRules).toEqual({ forcedWithdrawal: true, extremeRange: false, + floatingCriticals: false, + sprinting: false, }); expect(service.options().lastCanvasState).toBeUndefined(); expect(service.options().sidebarLipPosition).toBeUndefined(); @@ -243,30 +243,81 @@ describe('OptionsService', () => { }); }); - it('restores a disabled CBT automations preference', async () => { - savedOptions = { cbtAutomations: false }; + it('uses CBT optional-rule defaults', async () => { + savedOptions = null; const service = await createService(); - expect(service.options().cbtAutomations).toBeFalse(); + expect(service.options().CBTOptionalRules).toEqual({ + forcedWithdrawal: true, + extremeRange: false, + floatingCriticals: false, + sprinting: false, + }); }); - it('uses CBT optional-rule defaults', async () => { + it('uses the current CBT automation defaults', async () => { savedOptions = null; const service = await createService(); - expect(service.options().CBTOptionalRules).toEqual({ - forcedWithdrawal: true, - extremeRange: false, + expect(service.options().cbtAutomationOptions).toEqual({ + pilotSkillCheck: 'no', + heatAndDissipationResolution: 'no', + heatEffectsCheck: 'no', + pilotHitsAndConsciousnessCheck: 'no', + internalExplosionsCheck: 'ask', + criticalHitChanceCheck: 'no', + breachAndFloodCheck: 'no', + 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: { forcedWithdrawal: false, extremeRange: true, + floatingCriticals: true, + sprinting: true, }, }; @@ -275,6 +326,8 @@ describe('OptionsService', () => { expect(service.options().CBTOptionalRules).toEqual({ 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 c88365216..1b68cdb3f 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 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'; @@ -42,10 +42,21 @@ const DEFAULT_OPTIONS: Options = { recordSheetDoubleTapZoomReset: 'contextual', syncZoomBetweenSheets: true, trackPhaseAndTurn: true, - cbtAutomations: false, + cbtAutomationOptions: { + pilotSkillCheck: 'no', + heatAndDissipationResolution: 'no', + heatEffectsCheck: 'no', + pilotHitsAndConsciousnessCheck: 'no', + internalExplosionsCheck: 'ask', + criticalHitChanceCheck: 'no', + breachAndFloodCheck: 'no', + fallingCheck: 'no', + }, CBTOptionalRules: { + floatingCriticals: false, forcedWithdrawal: true, extremeRange: false, + sprinting: false, }, allowMultipleActiveSheets: false, CBTRules: 'tw', @@ -179,11 +190,21 @@ 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), + sprinting: resolveSavedValue(saved?.CBTOptionalRules?.sprinting, defaults.sprinting), }; } +function resolveCBTAutomationOptions(saved: Options | null | undefined): CBTAutomationOptions { + const defaults = DEFAULT_OPTIONS.cbtAutomationOptions; + 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'] { if (!saved || typeof saved !== 'object') { return undefined; @@ -229,7 +250,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, @@ -256,6 +277,7 @@ export class OptionsService { async initOptions() { const saved = await this.dbService.getOptions(); + const cbtAutomationOptions = resolveCBTAutomationOptions(saved); this.options.set({ colorScheme: resolveSavedValue(saved?.colorScheme, DEFAULT_OPTIONS.colorScheme, OPTION_VALUES.colorScheme), pickerStyle: resolveSavedValue(saved?.pickerStyle, DEFAULT_OPTIONS.pickerStyle, OPTION_VALUES.pickerStyle), @@ -272,7 +294,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, CBTOptionalRules: resolveCBTOptionalRules(saved), CBTRules: resolveSavedValue(saved?.CBTRules, DEFAULT_OPTIONS.CBTRules, OPTION_VALUES.CBTRules), ASUseHex: resolveSavedValue(saved?.ASUseHex, DEFAULT_OPTIONS.ASUseHex), @@ -301,6 +323,20 @@ export class OptionsService { await this.dbService.saveOptions(updated); } + async setCbtAutomationMode(key: CBTAutomationKey, value: AutomationMode) { + const current = this.options().cbtAutomationOptions; + if (current[key] === value) { + return; + } + + await this.setOption('cbtAutomationOptions', { ...current, [key]: value }); + } + + /** Returns the configured mode for one CBT automation. */ + cbtAutomationMode(key: CBTAutomationKey): AutomationMode { + return this.options().cbtAutomationOptions[key]; + } + async updateForceGeneratorOptions( updater: (options: ForceGeneratorOptions) => ForceGeneratorOptions, ) { @@ -311,4 +347,4 @@ export class OptionsService { this.options.set(updated); await this.dbService.saveOptions(updated); } -} \ No newline at end of file +} diff --git a/src/app/services/seo.service.ts b/src/app/services/seo.service.ts new file mode 100644 index 000000000..1f63d7300 --- /dev/null +++ b/src/app/services/seo.service.ts @@ -0,0 +1,91 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { DOCUMENT } from '@angular/common'; +import { DestroyRef, Injectable, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Meta, Title } from '@angular/platform-browser'; +import { NavigationEnd, Router } from '@angular/router'; +import { filter } from 'rxjs'; + +interface PageSeo { + title: string; + description: string; + canonicalUrl: string; +} + +const HOME_SEO: PageSeo = { + title: 'MekBay: BattleTech Force Builder & Record Sheets', + description: 'Build and manage BattleTech forces for Classic and Alpha Strike. Search units, balance BV/PV, generate forces, and use interactive or printable record sheets.', + canonicalUrl: 'https://mekbay.com/', +}; + +const FORCE_GENERATOR_SEO: PageSeo = { + title: 'BattleTech Force Generator | MekBay', + description: 'Generate balanced BattleTech forces for Classic and Alpha Strike by faction, era, unit type, Battle Value, or Point Value.', + canonicalUrl: 'https://mekbay.com/forcegenerator', +}; + +const UNIT_SEARCH_SEO: PageSeo = { + title: 'BattleTech Unit Search | MekBay', + description: 'Search and compare BattleTech units for Classic and Alpha Strike by name, faction, era, role, Battle Value, Point Value, and more.', + canonicalUrl: 'https://mekbay.com/?expanded=true', +}; + +@Injectable({ providedIn: 'root' }) +export class SeoService { + private readonly document = inject(DOCUMENT); + private readonly meta = inject(Meta); + private readonly title = inject(Title); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + private initialized = false; + + initialize(): void { + if (this.initialized) return; + this.initialized = true; + + this.applyForUrl(this.router.url); + this.router.events.pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef), + ).subscribe(event => this.applyForUrl(event.urlAfterRedirects)); + } + + private applyForUrl(url: string): void { + const urlWithoutFragment = url.split('#', 1)[0]; + const queryStart = urlWithoutFragment.indexOf('?'); + const rawPath = queryStart === -1 ? urlWithoutFragment : urlWithoutFragment.slice(0, queryStart); + const query = queryStart === -1 ? '' : urlWithoutFragment.slice(queryStart + 1); + const path = rawPath.replace(/\/+$/, '') || '/'; + const isUnitSearch = path === '/' && new URLSearchParams(query).get('expanded') === 'true'; + const isNonIndexableWorkspace = path === '/collection' || path === '/toe'; + const seo = path === '/forcegenerator' + ? FORCE_GENERATOR_SEO + : isUnitSearch + ? UNIT_SEARCH_SEO + : HOME_SEO; + + this.title.setTitle(seo.title); + this.meta.updateTag({ name: 'description', content: seo.description }); + this.meta.updateTag({ property: 'og:title', content: seo.title }); + this.meta.updateTag({ property: 'og:description', content: seo.description }); + this.meta.updateTag({ property: 'og:url', content: seo.canonicalUrl }); + this.meta.updateTag({ name: 'twitter:title', content: seo.title }); + this.meta.updateTag({ name: 'twitter:description', content: seo.description }); + if (isNonIndexableWorkspace) { + this.meta.updateTag({ name: 'robots', content: 'noindex, follow' }); + } else { + this.meta.removeTag('name="robots"'); + } + + let canonical = this.document.head.querySelector('link[rel="canonical"]'); + if (!canonical) { + canonical = this.document.createElement('link'); + canonical.rel = 'canonical'; + this.document.head.appendChild(canonical); + } + canonical.href = seo.canonicalUrl; + } +} diff --git a/src/app/services/toast.service.ts b/src/app/services/toast.service.ts index e520f2aa4..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'; @@ -14,13 +14,15 @@ export interface Toast { } const TOAST_DURATION_MS = 3000; -const MAX_TOASTS = 3; +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; + } +} 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..4afc29b11 --- /dev/null +++ b/src/app/services/unit-check-resolution.service.spec.ts @@ -0,0 +1,1170 @@ +// 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 { FALL_PSR_FAILURE, PSR_CHECK_KIND } from '../models/rules/unit-type-rules'; +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}`, + kind: PSR_CHECK_KIND.DAMAGE_THRESHOLD, + failure: FALL_PSR_FAILURE, + fallCheck: 0, + reason: `PSR ${index + 1}`, + })); + 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-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/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.model.ts b/src/app/services/unit-search-filters.model.ts index 89680db33..5872ce84b 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) @@ -484,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 }, diff --git a/src/app/services/unit-search-filters.service.spec.ts b/src/app/services/unit-search-filters.service.spec.ts index 81adae4c0..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, @@ -4804,6 +4819,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 +5088,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'; @@ -5251,13 +5400,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'; @@ -5271,7 +5415,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', () => { @@ -5542,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 () => { @@ -5870,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: { @@ -5899,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']); }); @@ -6302,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, @@ -6336,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: [], @@ -6388,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, @@ -6403,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, @@ -6451,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, @@ -6474,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 6f4908517..4ce628bd1 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,13 +186,13 @@ 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 []; } - 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; @@ -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, }; @@ -297,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()) { @@ -1978,14 +1985,21 @@ export class UnitSearchFiltersService { private buildWorkerSearchRequest(corpusVersion: string): UnitSearchWorkerQueryRequest { const gameSystem = this.gameService.currentGameSystem(); const workerFilterState = this.getWorkerFilterState(this.getApplicableFilterState(this.effectiveFilterState())); - const executionQuery = this.isComplexQuery() - ? this.searchText().trim() - : buildWorkerExecutionQuery({ - effectiveFilterState: workerFilterState, - effectiveTextSearch: this.effectiveTextSearch(), - gameSystem, - totalRangesCache: this.totalRangesCache, - }); + 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 = buildWorkerExecutionQuery({ + effectiveFilterState: workerUiOnlyFilterState, + effectiveTextSearch: this.effectiveTextSearch(), + semanticTokenTexts: workerSemanticTokenTexts, + preservedQuery: this.isComplexQuery() ? this.searchText() : undefined, + gameSystem, + totalRangesCache: this.totalRangesCache, + }); this.searchRequestRevision += 1; @@ -2045,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; @@ -2073,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(), @@ -2296,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); } @@ -2751,7 +2765,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,17 +2775,17 @@ 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(); if (!isCountableFilter) { const universeNames = this.getIndexedUniverseNames(filterKey); if (universeNames.length > 0) { - const contextUnitIds = new Set(units.map(unit => unit.name)); - let constrainedUnitIds: Set | null = null; + const contextUnitIds = new Set(units.map(unit => unit.uuid)); + 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 +2814,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 unitsByUuid = new Map(units.map(unit => [unit.uuid, unit])); + for (const unitId of Array.from(constrainedUnitIds)) { - if (excludedIds.has(unitId)) { + const unit = unitsByUuid.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) { @@ -3015,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({ @@ -3149,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, @@ -3156,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); @@ -3185,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({ @@ -3194,7 +3210,9 @@ export class UnitSearchFiltersService { }); return context === null ? new Set() - : this.unitAvailabilitySource.getMegaMekMembershipUnitIds(context); + : this.getMegaMekUnitUuids( + this.unitAvailabilitySource.getMegaMekMembershipUnitIds(context), + ); } if (filterKey === 'faction') { @@ -3204,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( @@ -3214,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') { @@ -3224,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') { @@ -3239,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); @@ -3425,6 +3453,8 @@ export class UnitSearchFiltersService { return getPositiveDropdownNamesFromFilter(selectedFactionEntries, allFactionNames, wildcardPatterns); }, getAvailabilityLookupKey: unit => this.unitAvailabilitySource.getUnitAvailabilityKey(unit), + getIndexedUnitIds: (filterKey, value) => this.dataService.getIndexedUnitIds(filterKey, value), + getIndexedASSpecials: unitUuid => this.dataService.getIndexedASSpecials(unitUuid), unitMatchesAvailabilityFrom: (unit, availabilityFromName, scope) => this.unitMatchesAvailabilityFrom(unit, availabilityFromName, scope), unitMatchesAvailabilityRarity: (unit, rarityName, scope) => @@ -3459,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 @@ -3469,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(), @@ -3497,6 +3535,7 @@ export class UnitSearchFiltersService { }, getIndexedUnitIds: (filterKey: string, value: string, scope?: AvailabilityFilterScope) => this.getSemanticIndexedUnitIds(filterKey, value, scope), getIndexedFilterValues: (filterKey: string) => this.getSemanticIndexedFilterValues(filterKey), + getIndexedASSpecials: (unitUuid: string) => this.dataService.getIndexedASSpecials(unitUuid), availabilitySortScope: megaMekRaritySortScope, getMegaMekRaritySortScore: megaMekRaritySortScoreResolver ? (unit: UnitSummary) => megaMekRaritySortScoreResolver(unit) @@ -3958,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(); } @@ -4122,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 b5461ca63..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,101 @@ 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([ + 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..ba78ca239 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,32 +295,38 @@ 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.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.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.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); } } @@ -317,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); } } } @@ -327,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); } } } @@ -339,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 { @@ -355,7 +374,7 @@ export class UnitSearchIndexService { unitIds = new Set(); tagIndex.set(tag, unitIds); } - unitIds.add(unit.name); + unitIds.add(unit.uuid); } } @@ -380,13 +399,17 @@ export class UnitSearchIndexService { return this.searchFilterValues.get(filterKey) ?? []; } + public getIndexedASSpecials(unitUuid: string): ParsedASSpecials | undefined { + return this.asSpecialsByUnit.get(unitUuid); + } + public getSearchWorkerIndexSnapshot(): UnitSearchWorkerIndexSnapshot { const snapshot: UnitSearchWorkerIndexSnapshot = {}; 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); } } @@ -399,7 +422,24 @@ export class UnitSearchIndexService { ); } - public getDropdownOptionUniverse(filterKey: string): Array<{ name: string; img?: string }> { + 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 })) ?? []; } @@ -416,7 +456,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,14 +477,38 @@ 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 createFactionEraSnapshot(unitNamesByMulId: Map, eras: Era[], factions: Faction[]): UnitSearchWorkerFactionEraSnapshot { + private addASSpecialIndexValues(parsedSpecials: ParsedASSpecials | undefined, unitUuid: string): void { + for (const occurrence of parsedSpecials?.occurrences ?? []) { + if (!occurrence.token) { + continue; + } + + 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); + } + } + } + + private createFactionEraSnapshot(unitUuidsByMulId: Map, eras: Era[], factions: Faction[]): UnitSearchWorkerFactionEraSnapshot { const snapshot: UnitSearchWorkerFactionEraSnapshot = {}; const erasById = new Map(eras.map(era => [era.id, era])); @@ -459,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; } @@ -505,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); } } @@ -523,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/services/unit-svg-mek.service.ts b/src/app/services/unit-svg-mek.service.ts index 5864d7c44..5b80b7128 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'); @@ -262,6 +279,7 @@ export class UnitSvgMekService extends UnitSvgService { } this.renderInventoryEntryState(entry); + this.renderKickArcHitModifier(entry); }); this.renderInventoryControlSelection(); } @@ -351,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/services/unit-svg.service.ts b/src/app/services/unit-svg.service.ts index 7e2866f0f..beafd21f6 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('heatAndDissipationResolution'); + 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); @@ -1792,29 +1797,20 @@ 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, + { groupSources: true }, + ).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'); @@ -1839,11 +1835,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/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/unit-search.worker.spec.ts b/src/app/unit-search.worker.spec.ts index 511fcba89..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,80 @@ 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([]); }); + + 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: [lowAC.uuid, nestedHighAC.uuid], + TAG: [noAC.uuid], + TUR: [nestedHighAC.uuid], + }, + }, + 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([{ unitUuid: nestedHighAC.uuid }]); + }); + + 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: [mediumOnly.uuid, longOnly.uuid, both.uuid], + SNARC: [implicitSnarc.uuid], + }, + }, + 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([{ unitUuid: both.uuid }]); + expect(execute('specials="SNARC>=1"')) + .toEqual([{ unitUuid: implicitSnarc.uuid }]); + }); }); diff --git a/src/app/unit-search.worker.ts b/src/app/unit-search.worker.ts index 1db907e30..fa7488543 100644 --- a/src/app/unit-search.worker.ts +++ b/src/app/unit-search.worker.ts @@ -29,14 +29,16 @@ 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; units: UnitSummary[]; - allUnitNames: ReadonlySet; - indexedUnitIds: Map>>; + allUnitUuids: ReadonlySet; + indexedUnitUuids: Map>>; indexedFilterValues: Map; - factionEraUnitIds: Map>>; + indexedASSpecials: Map; + factionEraUnitUuids: Map>>; forcePackToLookupKey: Map>; } @@ -51,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); } @@ -75,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); } @@ -89,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); } } @@ -128,10 +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), - factionEraUnitIds: buildFactionEraUnitIds(snapshot.factionEraIndex), + indexedASSpecials: buildASSpecialsByUnitIndex( + snapshot.units, + unit => unit.uuid, + unit => unit.as?.specials, + ), + factionEraUnitUuids: buildFactionEraUnitUuids(snapshot.factionEraIndex), forcePackToLookupKey: buildForcePackIndex(snapshot.units), }; } @@ -146,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] }, @@ -228,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[] => { @@ -271,14 +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: unitUuid => runtime.indexedASSpecials.get(unitUuid), }); const parseStage: SearchTelemetryStage = { @@ -293,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/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-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/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; } } 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 new file mode 100644 index 000000000..e21bcf2be --- /dev/null +++ b/src/app/utils/ecm-state.util.ts @@ -0,0 +1,316 @@ +// 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'; +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; +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 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 + )); + 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. */ +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: return NOVA_CEWS_ON_STATE; + } +} + +/** 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 { + return !!equipment + && isNovaCews(equipment) + && getEffectiveEcmMode(equipment) !== ECMMode.OFF; +} + +/** 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 new file mode 100644 index 000000000..def9e0ff6 --- /dev/null +++ b/src/app/utils/force-viewer-electronics-display.util.spec.ts @@ -0,0 +1,126 @@ +// 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, { + 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), + }, + }); + 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/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 }], 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..a8ea827e1 --- /dev/null +++ b/src/app/utils/heat-effects.util.spec.ts @@ -0,0 +1,226 @@ +// 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 { AeroRules } from '../models/rules/aero-rules'; +import { MekRules } from '../models/rules/mek-rules'; +import { getHeatEffectDescriptors, getPreferredHeatAmmoExplosionCandidates } 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; + critSlots?: CriticalSlot[]; + } = {}): 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: () => options.critSlots ?? [], + isInternalLocDestroyed: () => false, + 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', + 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' }, + }), + ]); + }); + + 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('keeps independent automatic recovery effects together', () => { + expect(getHeatEffectDescriptors(createUnit({ + type: 'Aero', + shutdown: true, + heatControlRecovery: true, + }), 4)).toEqual([ + 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([]); + }); + + 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', + result: { kind: 'automatic', outcome: 'success' }, + }), + ]); + }); + + it('captures deterministic Life Support damage in the generated effect', () => { + expect(getHeatEffectDescriptors(createUnit({ lifeSupportHits: 2 }), 20)).toContain( + jasmine.objectContaining({ + kind: 'heat-life-support', + result: { kind: 'automatic', outcome: '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', + 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 new file mode 100644 index 000000000..38cc093e0 --- /dev/null +++ b/src/app/utils/heat-effects.util.ts @@ -0,0 +1,166 @@ +// 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 { + UNIT_CHECK_CAUSE, + UNIT_CHECK_KIND, + type HeatEffectDescriptor, +} from '../models/unit-check.model'; +import { + ammoExplosionDamagePerShot, + ammoRackSize, + criticalSlotTotalAmmo, +} from './mek-critical-hit.util'; + +export type { HeatEffectDescriptor, HeatEffectKind } from '../models/unit-check.model'; + +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[] = []; + 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: UNIT_CHECK_KIND.HEAT_SHUTDOWN, + ...(effects.shutdownTarget >= 100 || !consciousPilot + ? { result: { kind: 'automatic' as const, outcome: 'failed' as const } } + : { target: effects.shutdownTarget }), + }); + } + if (effects.ammoExplosionTarget !== undefined && getHeatAmmoExplosionCandidates(unit).length > 0) { + descriptors.push({ + kind: UNIT_CHECK_KIND.HEAT_AMMO_EXPLOSION, + target: effects.ammoExplosionTarget, + }); + } + if (effects.randomMovementTarget !== undefined) { + descriptors.push({ + kind: UNIT_CHECK_KIND.HEAT_RANDOM_MOVEMENT, + target: effects.randomMovementTarget, + }); + } else if (heat < 5 && unit.turnState().getPendingUnitChecks().some(check => + check.kind === UNIT_CHECK_KIND.AERO_CONTROL_RECOVERY + && check.cause === UNIT_CHECK_CAUSE.HEAT_RANDOM_MOVEMENT)) { + descriptors.push({ + kind: UNIT_CHECK_KIND.HEAT_RANDOM_MOVEMENT, + result: { kind: 'automatic', outcome: 'success' }, + }); + } + if (effects.pilotDamageTarget !== undefined) { + descriptors.push({ + kind: UNIT_CHECK_KIND.HEAT_PILOT_DAMAGE, + target: effects.pilotDamageTarget, + hits: 1, + }); + } + const lifeSupportHits = unit.rules.heatLifeSupportPilotHits(heat); + if (lifeSupportHits > 0) { + descriptors.push({ + 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: UNIT_CHECK_KIND.LIFE_SUPPORT_DROWNING, + result: { kind: 'automatic', outcome: '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..0f7b3e21f --- /dev/null +++ b/src/app/utils/heat-summary.util.spec.ts @@ -0,0 +1,41 @@ +// 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: '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' }, + ]); + }); + + 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 new file mode 100644 index 000000000..8b157216b --- /dev/null +++ b/src/app/utils/heat-summary.util.ts @@ -0,0 +1,78 @@ +// 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; +} + +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[] = []; + 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; + + 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; +} 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 53512edfc..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; @@ -819,10 +819,11 @@ 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 ?? []); + const wildcardLocation = equipment instanceof ArmorEquipment || locations.length > 3; return { name: displayName, - location: wildcardLocation ? '*' : normalizeCell(Array.from(entry.locations ?? []).join('/')), + location: wildcardLocation ? '*' : normalizeCell(locations.join('/')), heat: weapon ? formatInventoryControlHeat(weapon.heat) : '—', damage: weapon ? '—' : physicalDamage, hit, diff --git a/src/app/utils/mek-critical-hit.util.spec.ts b/src/app/utils/mek-critical-hit.util.spec.ts index 93a939c78..6111a8dd6 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, @@ -38,24 +40,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 +83,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 +118,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 +161,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 +198,7 @@ describe('Mek critical-hit workflow', () => { } }); - it('does not 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); @@ -144,8 +214,170 @@ describe('Mek critical-hit workflow', () => { 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'); + const { unit, internalHits, armorHits } = explodingAmmoUnit(CORE_2026_GAME_RULES, 'composite'); const outcome = applyMekCriticalRoll(unit, 'LT', [1, 1], true); @@ -155,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); @@ -172,7 +422,7 @@ describe('Mek critical-hit workflow', () => { TW_GAME_RULES, ['F_GAUSS'], 'Gauss Rifle', - 'Composite', + 'composite', ); const outcome = applyMekCriticalRoll(unit, 'LT', [1, 1], true); @@ -182,6 +432,56 @@ 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); + + 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', @@ -285,10 +585,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 }, @@ -304,15 +604,58 @@ describe('Mek critical-hit workflow', () => { TW_GAME_RULES, [], [], - 'Reinforced', + '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 +732,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 +746,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,11 +884,40 @@ 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', armorDamagedBySameHit: true }, + ); + }); }); -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', @@ -542,18 +936,69 @@ function explodingAmmoUnit(gameRules: CBTGameRules, structureType: string | null eq: ammo, }; const slots = [slot]; - return { ...criticalUnit(gameRules, slots, [], structureType), slot }; + return { ...criticalUnit(gameRules, slots, [], structureKind), 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( gameRules: CBTGameRules, flags: EquipmentFlag[] = ['F_GAUSS'], name = 'Gauss Rifle', - structureType: string | null = null, + structureKind: MekStructureKind | null = null, ): { readonly unit: CBTForceUnit; readonly entry: MountedEquipment; readonly internalHits: Map; + readonly armorHits: Map; } { const weapon = new WeaponEquipment({ id: 'TestGauss', @@ -579,7 +1024,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 }; } @@ -800,16 +1245,21 @@ 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 features?: readonly string[] } = {}, + unitData: { + readonly armorType?: ArmorType; + 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 +1271,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); @@ -858,14 +1309,22 @@ 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; }, }), + 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 +1347,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 1aae318de..d8d3fd1f5 100644 --- a/src/app/utils/mek-critical-hit.util.ts +++ b/src/app/utils/mek-critical-hit.util.ts @@ -12,10 +12,11 @@ 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' } - | { 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 +29,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 = @@ -45,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 { @@ -55,6 +65,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 +100,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 +125,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 +173,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 +199,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( @@ -163,24 +255,29 @@ 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 (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, - }); + && unit.getArmorTypeAt(location) === 'HARDENED') { + 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 +306,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,26 +530,95 @@ export function applyMekCriticalRoll( }) : null; - if (delayedExplosion) { - queueMekComponentExplosion(unit, slot, targetLocation, delayedExplosion, consolidateImmediately); + 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 }), + }; + } + + 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 }), }; @@ -314,10 +628,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 { @@ -346,13 +669,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 @@ -360,7 +685,7 @@ function criticalSlotRollability( : 'already-damaged'; } if ((slot.hits ?? 0) > 0 - || !!slot.destroying + || (!!slot.destroying && !allowStructurallyDestroying) || !!slot.destroyed) return 'already-damaged'; return 'rollable'; } @@ -377,13 +702,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 { @@ -432,10 +755,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(), @@ -445,6 +768,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))) { @@ -484,6 +808,7 @@ export function resolvePendingMekComponentExplosion( pilotHits: pending.pilotHits, }, pending.consolidateImmediately, + pending.pilotDamageGroup, ); resolvedComponentExplosions.set(source, explosion); return explosion; @@ -551,7 +876,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']) @@ -567,7 +891,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; @@ -584,17 +912,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') { @@ -609,61 +937,136 @@ 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; const locations: MekExplosionLocationDamage[] = []; const visited = new Set(); 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 remainingInternalCapacity = remainingInternal / internalDamageMultiplier; + 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 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: remainingInternalCapacity, - remainingArmor, - originalArmor: unit.getArmorPoints(location, torso), + remainingInternal: mekStructureDamageCapacity(remainingInternal - sharedInternalDamage, structureKind), + remainingArmor: Math.ceil(remainingArmor / armorPipsPerPoint), + originalArmor: Math.ceil(armorPoints / armorPipsPerPoint), torso, armorBlowoutPending, }); - const armorDamage = Math.min(remainingArmor, resolution.armorDamage); - const internalDamage = Math.min( - remainingInternal, - resolution.internalDamage * internalDamageMultiplier, + const armorDamage = Math.min(remainingArmor, resolution.armorDamage * armorPipsPerPoint); + const structureDamage = resolveMekStructureDamage( + resolution.internalDamage, + 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, + armorDamage, + armorRear: resolution.armorRear, + protection, + ...(receivedSharedCompositePip ? { sharedCompositePip: true } : {}), + }); - 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; - const overflow = Math.max(0, resolution.internalDamage - appliedInternalDamage); - if (overflow === 0 || resolution.stopsTransfer) break; - location = topology[location as keyof typeof topology]?.transfersTo ?? null; + const overflow = structureDamage.overflowDamage; + 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; } - 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, + ...(damage.armorDamage > 0 ? { armorDamagedBySameHit: true } : {}), + ...(damage.sharedCompositePip ? { sharedCompositePip: true } : {}), + ...(pilotDamageGroup && { pilotDamageGroup }), + }, + ); + } } + + const pilotHits = plan.pilotHits > 0 + ? unit.applyInternalExplosionCrewHits(plan.pilotHits, pilotDamageGroup) + : 0; const automaticCritical = plan.automaticCriticalEntry ? applyAutomaticMekCritical(unit, plan.automaticCriticalEntry, consolidateImmediately) : undefined; @@ -671,7 +1074,7 @@ function applyMekEquipmentExplosion( return { equipment: plan.equipment, rawDamage: plan.rawDamage, - pilotHits: plan.pilotHits, + pilotHits, locations, ...(automaticCritical && { automaticCritical }), }; @@ -698,16 +1101,41 @@ 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'; 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, @@ -715,7 +1143,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..285313ff7 --- /dev/null +++ b/src/app/utils/mek-falling.util.spec.ts @@ -0,0 +1,570 @@ +// 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 { ArmorType } from '../models/entity/types'; +import { + applyMekFallDamage, + mekFallDamage, + mekFallDamageGroups, + resolvedMekFallDamageGroups, + rollMekFallDice, + resolveMekFallArmorDamage, + resolveMekFallDamage, + resolveMekFallHitLocation, + resolveMekFallOrientation, + twoD6ForTotal, + twoD6Total, + type ResolvedMekFallDamageGroup, +} from './mek-falling.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', () => { + 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 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]); + 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)', + 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('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 }, + 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}-rear`]: 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}-rear`)).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('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' }, + 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('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 }, + }); + + 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(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', () => { + 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(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 }, + 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(); + + 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(); + }); +}); + +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>; + modularArmor?: Readonly>; + rulesId?: 'core2026' | 'tw'; + armorType?: ArmorType; + armorTypes?: Readonly>; + structureKinds?: Readonly>; +}): { + 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, + _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] + ?? 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, + 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); + }, + getInternalPoints: (location: string) => options.internal[location] ?? 0, + getInternalHits: (location: string) => internalHits.get(location) ?? 0, + addInternalHits, + queueMekCriticalChance, + } as unknown as CBTForceUnit; + 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 new file mode 100644 index 000000000..f31f2f68e --- /dev/null +++ b/src/app/utils/mek-falling.util.ts @@ -0,0 +1,527 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +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'; +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 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. */ + readonly rear: boolean; + readonly modularArmorDamage: number; + readonly armorDamage: number; + readonly internalDamage: number; + /** Rule-adjusted damage points consumed at this location. */ + readonly appliedDamage: 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; all fall damage to the rear.' + : 'The Mek keeps its existing facing; 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 weightDamage = mekFallWeightDamage(tons); + const normalizedLevels = Number.isFinite(levelsFallen) + ? Math.max(0, Math.trunc(levelsFallen)) + : 0; + 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. */ +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; +} + +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; +} + +/** 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, + 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), + // 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 } : {}), + ...(adjustedTripodLegRoll !== undefined ? { adjustedTripodLegRoll } : {}), + }; +} + +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 } { + 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 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), + ); + const originalArmorType = unit.getArmorTypeAt(group.location); + const visited = new Set(); + let groupDamaged = false; + let sharedCompositePip = false; + + 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); + if (damage > 0) { + const armor = unit.applyMekFallArmorDamage( + location, + damage, + rear, + consolidateImmediately, + ); + armorDamage = armor.armorDamage; + damage = armor.remainingDamage; + appliedDamage += armor.appliedDamage; + locationAppliedDamage += 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 - sharedInternalDamage, + structureKind, + ); + internalDamage = sharedInternalDamage + structure.internalDamage; + if (internalDamage > 0) { + const appliedInternalDamage = unit.addInternalHits(location, internalDamage, consolidateImmediately, { + hardenedArmorApplies: armorType === 'HARDENED' && remainingArmor > 0, + ...(armorDamage > 0 ? { armorDamagedBySameHit: true } : {}), + ...(receivedSharedCompositePip ? { sharedCompositePip: true } : {}), + }); + appliedDamage += appliedInternalDamage; + locationAppliedDamage += appliedInternalDamage; + } + damage = structure.overflowDamage; + 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: locationAppliedDamage, + }); + + if (damage <= 0 && !sharedCompositePip) break; + location = nextLocation; + } + + if (group.location === 'HD' && groupDamaged) headHits++; + if (group.critical && groupDamaged + && !(unit.gameRules.id === 'core2026' + && originalArmorType === 'ANTI_PENETRATIVE_ABLATION' + && originalArmor > 0)) { + unit.queueMekCriticalChance(group.location, { + consolidateImmediately, + hardenedArmorApplies: originalArmorType === 'HARDENED' && originalArmor > 0, + throughArmorHitArc: throughArmorHitArc(group), + }); + } + } + + return { appliedDamage, headHits, locations }; +} + +/** 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, +): 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 + Math.min(incoming, Math.floor(armor / 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' + ? 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 { + armorDamage, + remainingDamage: modifiedDamage - armorDamage, + appliedDamage: armorDamage, + }; +} + +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'; + return group.rear ? 'rear' : 'front'; +} + +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); +} + +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 new file mode 100644 index 000000000..c5b893bdb --- /dev/null +++ b/src/app/utils/mek-structure-damage.util.spec.ts @@ -0,0 +1,63 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { + mekStructureDamageCapacity, + mekStructureDamageReceived, + 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, + overflowDamage: 0, + }); + expect(resolveMekStructureDamage(2, 1, 'composite')).toEqual({ + internalDamage: 1, + overflowDamage: 1, + }); + }); + + it('keeps incoming phase damage while composite structure survives', () => { + expect(resolveMekStructureDamage(1, 3, 'composite')).toEqual({ + internalDamage: 2, + overflowDamage: 0, + }); + }); + + 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, + overflowDamage: 0, + }); + expect(resolveMekStructureDamage(1, 3, 'reinforced')).toEqual({ + internalDamage: 1, + overflowDamage: 0, + }); + }); + + it('uses reinforced structure pips as two incoming damage points', () => { + expect(resolveMekStructureDamage(5, 2, 'reinforced')).toEqual({ + internalDamage: 2, + 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..b77eb6801 --- /dev/null +++ b/src/app/utils/mek-structure-damage.util.ts @@ -0,0 +1,76 @@ +// 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; +} + +/** 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, + overflowDamage: incoming - absorbedDamage, + }; +} + +/** + * 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 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. */ +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/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/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/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/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 996f7b706..455f786c9 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 @@ -1073,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; @@ -1125,48 +1130,23 @@ 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?: (unitUuid: 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(); @@ -1255,7 +1235,7 @@ interface ExternalFilterRuntimeCache { allNamesByKey: Map; expandedValuesByKey: Map>; unitMatchedNamesByKey: WeakMap>>; - indexedResultsByKey: Map }>; + indexedResultsByKey: Map }>; } const externalFilterRuntimeCache = new WeakMap(); @@ -1267,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); } @@ -1344,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) { @@ -1376,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'); } @@ -1408,7 +1380,7 @@ function buildIndexedExternalFilterCacheKey( } function addIndexedExternalUnitIds( - target: Set, + target: Set, context: EvaluatorContext, filterKey: string, names: Iterable, @@ -1431,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; } @@ -1443,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; } @@ -1461,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, @@ -1480,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); @@ -1500,7 +1472,7 @@ function getIndexedExternalFilterResult( result = { mode: 'match', - unitIds: matchingIds ?? new Set(), + unitIds: matchingIds ?? new Set(), }; } else { const allowedNamesByLower = new Map(); @@ -1521,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); @@ -1624,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) { @@ -1644,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. @@ -1799,18 +1785,39 @@ 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, values: string[], context: EvaluatorContext, activeScope?: AvailabilityFilterScope, -): Set | null { +): Set | null { if (!context.getIndexedUnitIds || !context.getIndexedFilterValues) { 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); @@ -1827,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); } } @@ -1840,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; @@ -1851,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); @@ -1861,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); } @@ -1879,7 +1886,7 @@ function buildIndexedCandidateSetForConfig( } } } - return candidateIds ?? new Set(); + return candidateIds ?? new Set(); } return null; @@ -1891,7 +1898,7 @@ function buildIndexedBooleanCandidateSet( values: string[], context: EvaluatorContext, activeScope?: AvailabilityFilterScope, -): Set | null { +): Set | null { if (operator === '&=') { return null; } @@ -1922,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) { @@ -1941,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() ); @@ -1960,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) { @@ -1969,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); @@ -1982,7 +1989,7 @@ function getIndexedCandidateIdsForNode( node: ASTNode, context: EvaluatorContext, activeScope?: AvailabilityFilterScope, -): Set | null { +): Set | null { switch (node.type) { case 'text': return null; @@ -1994,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)) { @@ -2020,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) { @@ -2029,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); @@ -2302,440 +2304,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 +2316,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 === '==')) { @@ -2966,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 { @@ -2987,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; @@ -2999,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); @@ -3008,7 +2577,7 @@ export function filterUnitsWithAST( } } - return candidateUnits.filter(unit => evaluateASTNode(ast, unit, context)); + return candidateUnits.filter(unit => evaluateASTNode(ast, unit, context, initialScope)); } /** @@ -3041,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) @@ -3066,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/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/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; + } +} diff --git a/src/app/utils/turn-movement-indicator.util.spec.ts b/src/app/utils/turn-movement-indicator.util.spec.ts new file mode 100644 index 000000000..8ee55ff40 --- /dev/null +++ b/src/app/utils/turn-movement-indicator.util.spec.ts @@ -0,0 +1,35 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { MotiveModes } from '../models/motiveModes.model'; +import { + getTurnMovementIndicator, + type TurnMovementColor, +} from './turn-movement-indicator.util'; + +describe('getTurnMovementIndicator', () => { + it('returns no indicator until movement is assigned', () => { + expect(getTurnMovementIndicator(null, 4)).toBeNull(); + expect(getTurnMovementIndicator(undefined, 4)).toBeNull(); + }); + + it('maps standard movement modes to their colors and defender-modifier labels', () => { + const cases: ReadonlyArray = [ + ['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, 4)).withContext(mode).toEqual({ color, letter }); + } + }); + + it('uses the jump color for special movement modes', () => { + 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 new file mode 100644 index 000000000..8b57b18ce --- /dev/null +++ b/src/app/utils/turn-movement-indicator.util.ts @@ -0,0 +1,35 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { MotiveModes } from '../models/motiveModes.model'; + +export type TurnMovementColor = 'stationary' | 'walk' | 'run' | 'jump' | 'sprint'; + +export interface TurnMovementIndicator { + readonly color: TurnMovementColor; + readonly letter: string; +} + +const TURN_MOVEMENT_INDICATORS: Readonly> = { + stationary: { color: 'stationary', letter: 'St' }, + walk: { color: 'walk', letter: 'W' }, + run: { color: 'run', letter: 'R' }, + jump: { color: 'jump', letter: 'J' }, + 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' }, +}; + +export function getTurnMovementIndicator( + mode: MotiveModes | null | undefined, + defenderModifier: number, +): TurnMovementIndicator | null { + if (mode === null || mode === undefined) return null; + + const indicator = TURN_MOVEMENT_INDICATORS[mode]; + return mode === 'stationary' + ? indicator + : { ...indicator, letter: `${indicator.letter}${defenderModifier}` }; +} 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/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-filter-kernel.util.ts b/src/app/utils/unit-filter-kernel.util.ts index 892f72f17..f52b6eeb9 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?: (unitUuid: 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.uuid)); + } + results = results.filter(unit => unitMatchesASSpecialSelections( + dependencies.getProperty(unit, conf.key), + specialSelections, + dependencies.getIndexedASSpecials?.(unit.uuid), + )); + continue; + } + results = filterUnitsByMultiState( results, conf.key, - normalizeMultiStateSelection(val), + selection, dependencies.getProperty, wildcardPatterns, ); diff --git a/src/app/utils/unit-metadata-builder.spec.ts b/src/app/utils/unit-metadata-builder.spec.ts index 9a5243361..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); @@ -208,14 +227,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 +244,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 +269,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..d83bbf50d 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 @@ -192,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 { @@ -273,6 +277,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'); + } } // ═══════════════════════════════════════════════════════════════════════════ 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-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 a3769ce4c..1900458e9 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); @@ -83,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', () => { @@ -119,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, @@ -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: unitUuid => unitUuid === unit.uuid ? 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.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.uuid]) : undefined + )); + const getIndexedASSpecials = jasmine.createSpy('getIndexedASSpecials') + .and.callFake((unitUuid: string) => parsedByUnit.get(unitUuid)); + + 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.uuid); + }); + 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..2481deecb 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[]; @@ -29,6 +30,7 @@ export interface UnitSearchExecutionRequest { searchTokens: SearchTokensGroup[]; uiOnlyFilterState?: FilterState; uiOnlyFilterDependencies?: UnitFilterKernelDependencies; + initialAvailabilityScope?: AvailabilityFilterScope; gameSystem: GameSystem; sortKey: string; sortDirection: 'asc' | 'desc'; @@ -51,13 +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?: (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; @@ -136,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); @@ -154,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, @@ -202,6 +205,7 @@ export function executeUnitSearch(request: UnitSearchExecutionRequest): UnitSear getDisplayName: request.getDisplayName, getIndexedUnitIds: request.getIndexedUnitIds, getIndexedFilterValues: request.getIndexedFilterValues, + getIndexedASSpecials: request.getIndexedASSpecials, }; let candidateUnits = allUnits; @@ -219,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, ); @@ -274,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) { @@ -358,22 +367,22 @@ 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, 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-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 08a52be42..9fc0ab3b9 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: { @@ -120,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 27f39d626..0887789be 100644 --- a/src/app/utils/unit-search-worker-request.util.ts +++ b/src/app/utils/unit-search-worker-request.util.ts @@ -22,6 +22,10 @@ interface UnitSearchWorkerCorpusCache { interface BuildWorkerExecutionQueryArgs { effectiveFilterState: FilterState; 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; } @@ -81,15 +85,27 @@ export function getWorkerCorpusSnapshot( export function buildWorkerExecutionQuery({ effectiveFilterState, effectiveTextSearch, + semanticTokenTexts = [], + preservedQuery, gameSystem, totalRangesCache, }: BuildWorkerExecutionQueryArgs): string { - return filterStateToSemanticText( + 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) + .join(' '); } export function buildWorkerSearchRequest(args: BuildWorkerSearchRequestArgs): UnitSearchWorkerQueryRequest { @@ -107,4 +123,4 @@ export function buildWorkerSearchRequest(args: BuildWorkerSearchRequestArgs): Un pilotPilotingSkill: args.pilotPilotingSkill, normalization: args.normalization, }; -} \ No newline at end of file +} 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 +} diff --git a/src/index.html b/src/index.html index a944c3301..1382fd684 100644 --- a/src/index.html +++ b/src/index.html @@ -2,32 +2,78 @@ - MekBay + MekBay: BattleTech Force Builder & Record Sheets + - - - + + + - + - - + + - + + diff --git a/src/styles.scss b/src/styles.scss index c9e88f503..715a5115b 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); @@ -51,11 +51,15 @@ --undamaged-night-highlight-color: #300; --damaged-highlight-color: rgb(255, 105, 105); - --phase-move: #666; - --phase-ranged: #9191ff; - --phase-physical: #cfcc00; - --phase-heat: #dd7d00; - --phase-pending-effects: #d00; + --move-unassigned: #666; + --move-stationary: #005563; + --move-walk: #eee; + --move-run: #333; + --move-jump: #c00; + --move-sprint: #e6e600; + --move-on-dark: #fff; + --move-on-light: #111; + --hot-color: #f00; --cold-color: #0081FF; @@ -660,29 +664,42 @@ 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); --btn-border-color: rgb(222, 144, 0); @@ -694,14 +711,28 @@ hr { } } + &.muted { + &.selected { + background-color: #666; + color: #fff; + } + } + &.primary { - --btn-text-color: #ddddff; - --btn-border-color: #9a9aff; // rgb(58, 58, 255); + &.selected { + background-color: #9a9aff; + color: #fff; + } - &:hover { - background-color: #003; - --btn-text-color: #fff; - --btn-border-color: #c6c6ff; // rgb(81, 81, 255); + &:not(.selected) { + --btn-text-color: #ddddff; + --btn-border-color: #9a9aff; // rgb(58, 58, 255); + + &:hover { + background-color: #003; + --btn-text-color: #fff; + --btn-border-color: #c6c6ff; // rgb(81, 81, 255); + } } } @@ -715,12 +746,6 @@ hr { background-size: 100% 2px, 2px 100%, 100% 2px, 2px 100%, 100% 2px, 2px 100%, 100% 2px, 2px 100%; } - &[disabled], - &.disabled { - cursor: not-allowed; - --btn-text-color: #444; - } - &.square { display: flex; flex-shrink: 0; @@ -752,6 +777,13 @@ hr { border: none; background: none; } + + &[disabled], + &.disabled { + cursor: not-allowed !important; + --btn-text-color: #444 !important; + --btn-border-color: #444 !important; + } } /* Split button: a single bt-button frame with two clickable halves. @@ -904,22 +936,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 +977,43 @@ 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 36px; + width: 36px; + height: 36px; + background-size: 32px 32px; + + } + + &.huge { + 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;