From 4e110ca9fc9a7337dca8db3a94bf1750c02a84a7 Mon Sep 17 00:00:00 2001 From: exeea Date: Tue, 18 Aug 2026 22:35:19 +0200 Subject: [PATCH 01/12] 32 players lobby --- src/app/services/lobby.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/services/lobby.service.ts b/src/app/services/lobby.service.ts index 93593f559..ca0e7a880 100644 --- a/src/app/services/lobby.service.ts +++ b/src/app/services/lobby.service.ts @@ -16,7 +16,7 @@ import { WsService } from './ws.service'; import { normalizeDisplayName } from '../utils/display-name.util'; const LOBBY_CODE_PATTERN = /^[a-z0-9]{4}$/; -const MAX_LOBBY_PARTICIPANTS = 16; +const MAX_LOBBY_PARTICIPANTS = 32; const MAX_LOBBY_FORCES = 8; const MAX_REMOTE_LOAD_ATTEMPTS = 8; From a8233856d6934c01dc9907109c920d27bf206005 Mon Sep 17 00:00:00 2001 From: exeea Date: Wed, 19 Aug 2026 17:33:04 +0200 Subject: [PATCH 02/12] cleanup of the walk/run/standup rules --- .../hex-slider/hex-slider.component.html | 7 +- .../hex-slider/hex-slider.component.scss | 16 +- .../hex-slider/hex-slider.component.spec.ts | 17 + .../hex-slider/hex-slider.component.ts | 17 +- .../page-psr-warning-panel.component.spec.ts | 36 +- .../page-psr-warning-panel.component.ts | 20 +- .../page-standing-up-panel.component.html | 129 +++-- .../page-standing-up-panel.component.scss | 10 + .../page-standing-up-panel.component.spec.ts | 180 ++++++- .../page-standing-up-panel.component.ts | 68 ++- .../page-turn-summary-panel.component.html | 36 +- .../page-turn-summary-panel.component.scss | 37 ++ .../page-turn-summary-panel.component.spec.ts | 155 ++++++ .../page-turn-summary-panel.component.ts | 49 +- .../overlay/page-turn-summary.util.spec.ts | 37 +- .../overlay/page-turn-summary.util.ts | 19 + .../svg-interaction.service.spec.ts | 1 + .../coolant-system-failure.util.ts | 2 +- .../escalating-equipment.handler.spec.ts | 4 +- src/app/models/cbt-force-unit.model.ts | 10 +- src/app/models/force-serialization.ts | 2 + src/app/models/rules/mek-rules.spec.ts | 419 ++++++++++++++- src/app/models/rules/mek-rules.ts | 263 +++++++-- src/app/models/rules/tw-rules.spec.ts | 21 + src/app/models/rules/tw-rules.ts | 50 +- src/app/models/rules/unit-type-rules.ts | 64 ++- src/app/models/rules/vehicle-rules.spec.ts | 1 + src/app/models/turn-state.model.spec.ts | 499 +++++++++++++++++- src/app/models/turn-state.model.ts | 108 ++-- .../services/force-builder.service.spec.ts | 1 + src/app/services/unit-svg-mek.service.ts | 17 +- src/app/services/unit-svg.service.ts | 2 +- src/app/testing/unit-test-helpers.ts | 4 +- ...nventory-control-opfor-target.util.spec.ts | 1 + .../inventory-control-opfor-target.util.ts | 2 +- src/app/utils/mek-critical-hit.util.spec.ts | 4 +- 36 files changed, 2060 insertions(+), 248 deletions(-) create mode 100644 src/app/components/page-viewer/overlay/page-turn-summary-panel.component.spec.ts diff --git a/src/app/components/hex-slider/hex-slider.component.html b/src/app/components/hex-slider/hex-slider.component.html index f4daf72ed..2295ee208 100644 --- a/src/app/components/hex-slider/hex-slider.component.html +++ b/src/app/components/hex-slider/hex-slider.component.html @@ -5,7 +5,7 @@ tabindex="0" [attr.aria-label]="ariaLabel()" [attr.aria-valuemin]="effectiveMinValue()" - [attr.aria-valuemax]="maxValue()" + [attr.aria-valuemax]="effectiveMaxValue()" [attr.aria-valuenow]="clampedValue()" [attr.aria-valuetext]="valueLabel()" (keydown)="onKeyDown($event)" @@ -13,7 +13,10 @@
@for (tick of displayTicks(); track tick) { diff --git a/src/app/components/hex-slider/hex-slider.component.scss b/src/app/components/hex-slider/hex-slider.component.scss index 5f5be8acf..fe013e8f0 100644 --- a/src/app/components/hex-slider/hex-slider.component.scss +++ b/src/app/components/hex-slider/hex-slider.component.scss @@ -43,15 +43,23 @@ inset-inline-end: calc(var(--hex-slider-track-overhang) * -1); block-size: var(--hex-slider-track-height); transform: translateY(-50%); - background: rgba(70, 70, 70, 0.72); - border: 1px solid rgba(255, 255, 255, 0.06); + background: var(--background-input); box-sizing: border-box; overflow: hidden; } .blocked-track { - block-size: 100%; - background: #800; + position: absolute; + inset-block: 0; + background: #666; +} + +.blocked-min-track { + inset-inline-start: 0; +} + +.blocked-max-track { + inset-inline-end: 0; } .tick { diff --git a/src/app/components/hex-slider/hex-slider.component.spec.ts b/src/app/components/hex-slider/hex-slider.component.spec.ts index 9d6585977..042f949de 100644 --- a/src/app/components/hex-slider/hex-slider.component.spec.ts +++ b/src/app/components/hex-slider/hex-slider.component.spec.ts @@ -81,6 +81,23 @@ describe('HexSliderComponent', () => { expect(valueCommits).toEqual([1]); }); + it('shows and enforces a blocked upper range', () => { + fixture.componentRef.setInput('blockedMax', 6); + fixture.detectChanges(); + const slider = fixture.nativeElement.querySelector('.hex-slider') as HTMLDivElement; + + expect(component.effectiveMaxValue()).toBe(6); + expect(component.blockedMaxPercent()).toBe(40); + expect(slider.getAttribute('aria-valuemax')).toBe('6'); + expect(fixture.nativeElement.querySelector('.blocked-max-track')).not.toBeNull(); + + slider.dispatchEvent(pointerEvent('pointerdown', 1, 90)); + window.dispatchEvent(pointerEvent('pointerup', 1, 90)); + + expect(valueChanges).toEqual([6]); + expect(valueCommits).toEqual([6]); + }); + it('uses tick label overrides without replacing other generated tick labels', () => { fixture.componentRef.setInput('tickLabelOverrides', { 8: 'RUN', 10: 'MASC' }); fixture.detectChanges(); diff --git a/src/app/components/hex-slider/hex-slider.component.ts b/src/app/components/hex-slider/hex-slider.component.ts index a44981e64..e29b2ca79 100644 --- a/src/app/components/hex-slider/hex-slider.component.ts +++ b/src/app/components/hex-slider/hex-slider.component.ts @@ -35,6 +35,7 @@ export class HexSliderComponent { readonly min = input(0); readonly max = input(100); readonly blockedMin = input(null); + readonly blockedMax = input(null); readonly step = input(1); readonly value = input(0); readonly ticks = input(null); @@ -57,11 +58,23 @@ export class HexSliderComponent { if (blockedMin === null) return this.minValue(); return Math.max(this.minValue(), Math.min(this.maxValue(), this.normalizeNumber(blockedMin, this.minValue()))); }); + readonly effectiveMaxValue = computed(() => { + const blockedMax = this.blockedMax(); + if (blockedMax === null) return this.maxValue(); + return Math.max( + this.effectiveMinValue(), + Math.min(this.maxValue(), this.normalizeNumber(blockedMax, this.maxValue())), + ); + }); readonly stepValue = computed(() => Math.max(0.000001, Math.abs(this.normalizeNumber(this.step(), 1)))); readonly clampedValue = computed(() => this.alignToStep(this.value())); readonly valueLabel = computed(() => this.label() ?? `${this.clampedValue()}`); readonly valuePercent = computed(() => this.percentForValue(this.clampedValue())); readonly blockedMinPercent = computed(() => this.effectiveMinValue() > this.minValue() ? this.percentForValue(this.effectiveMinValue()) : 0); + readonly blockedMaxPercent = computed(() => this.effectiveMaxValue() < this.maxValue() + ? 100 - this.percentForValue(this.effectiveMaxValue()) + : 0 + ); readonly displayTicks = computed(() => { const explicitTicks = this.ticks(); if (explicitTicks !== null) { @@ -134,7 +147,7 @@ export class HexSliderComponent { if (event.key === 'PageUp') next = this.clampedValue() + step * 5; if (event.key === 'PageDown') next = this.clampedValue() - step * 5; if (event.key === 'Home') next = this.effectiveMinValue(); - if (event.key === 'End') next = this.maxValue(); + if (event.key === 'End') next = this.effectiveMaxValue(); if (next === null) return; event.preventDefault(); @@ -198,7 +211,7 @@ export class HexSliderComponent { private alignToStep(value: number): number { const min = this.minValue(); - const max = this.maxValue(); + const max = this.effectiveMaxValue(); const effectiveMin = this.effectiveMinValue(); const step = this.stepValue(); const stepped = min + Math.round((value - min) / step) * step; diff --git a/src/app/components/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 aaa0cead9..68a6cff27 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 @@ -2,13 +2,45 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { signal } from '@angular/core'; +import { Injector, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +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 { PageInteractionOverlayComponent } from './page-interaction-overlay.component'; -import { PagePsrWarningPanelComponent, psrRollOutcome } from './page-psr-warning-panel.component'; +import { PagePsrWarningPanelComponent, psrRollOutcome, togglePsrWarningOverlay } from './page-psr-warning-panel.component'; + +describe('togglePsrWarningOverlay', () => { + it('keeps Turn Summary open until PSR Warning closes', () => { + const closed = new Subject(); + const overlayManager = { + has: jasmine.createSpy('has').and.returnValue(false), + closeManagedOverlay: jasmine.createSpy('closeManagedOverlay'), + createManagedOverlay: jasmine.createSpy('createManagedOverlay').and.returnValue({ closed }), + blockCloseUntil: jasmine.createSpy('blockCloseUntil'), + unblockClose: jasmine.createSpy('unblockClose'), + } as unknown as OverlayManagerService; + const parent = { unit: signal({ id: 'unit-1' }) } as unknown as PageInteractionOverlayComponent; + const overlay = { scrollStrategies: { block: () => ({}) } } as unknown as Overlay; + + togglePsrWarningOverlay( + parent, + overlayManager, + Injector.create({ providers: [] }), + overlay, + ); + + expect(overlayManager.blockCloseUntil).toHaveBeenCalledOnceWith('turnSummary-unit-1'); + expect(overlayManager.closeManagedOverlay).not.toHaveBeenCalledWith('turnSummary-unit-1'); + + closed.next(); + + expect(overlayManager.unblockClose).toHaveBeenCalledOnceWith('turnSummary-unit-1'); + expect(overlayManager.closeManagedOverlay).not.toHaveBeenCalledWith('turnSummary-unit-1'); + }); +}); describe('psrRollOutcome', () => { it('succeeds on or above the target and fails below it', () => { 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 2356af01e..3c57890a8 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 @@ -9,7 +9,7 @@ import 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 { displayPsrModifiers } from './page-turn-summary.util'; +import { displayPsrModifiers, openTurnSummaryChildOverlay } from './page-turn-summary.util'; import { getMekLocationLabel } from '../../../models/entity/types'; export function psrRollOutcome(sum: number, target: number): 'success' | 'failed' { @@ -40,14 +40,16 @@ export function togglePsrWarningOverlay( parent: injector }); const portal = new ComponentPortal(PagePsrWarningPanelComponent, null, customInjector); - overlayManager.createManagedOverlay(overlayKey, null, portal, { - hasBackdrop: true, - backdropClass: 'cdk-overlay-dark-backdrop', - panelClass: 'psr-warning-overlay-panel', - closeOnOutsideClick: true, - scrollStrategy: overlay.scrollStrategies.block(), - positions: [] - }); + openTurnSummaryChildOverlay(overlayManager, unitId, () => + overlayManager.createManagedOverlay(overlayKey, null, portal, { + hasBackdrop: true, + backdropClass: 'cdk-overlay-dark-backdrop', + panelClass: 'psr-warning-overlay-panel', + closeOnOutsideClick: true, + scrollStrategy: overlay.scrollStrategies.block(), + positions: [] + }) + ); } @Component({ 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 8c909a91c..dd599555c 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 @@ -1,9 +1,11 @@
-
Standing Up
-
- Target roll - {{ targetRoll() }} -
+
{{ reviewOnly ? 'Standing Up Review' : 'Standing Up' }}
+ @if (!canStandWithoutPSR()) { +
+ Target roll + {{ targetRoll() }} +
+ }
Attempts @@ -15,51 +17,60 @@ aria-label="Increase stand attempts" title="Increase stand attempts" (click)="adjustAttempts(1)">+
-
-
-
-
-
Stand up!
-
Failure: Remain prone
-
- @if (lastOutcome(); as result) { -
{{ result }}
- } -
- @if (lastOutcome() !== 'success') { -
- - - + @if (!canStandWithoutPSR()) { +
+
+
+
+
Stand up! + @if (attemptLimit(); as limit) { + ({{ limit }} attempt per turn) + }
+
Failure: Remain prone
+
+ @if (lastOutcome(); as result) { +
{{ result }}
+ }
- } -
-
- - @if (modifiersList().length > 0) { -
-
Modifiers
-
- @for (modifier of modifiersList(); track $index) { -
- - @if (modifier.loc) { {{ modifier.loc }} } @else { — } - - {{ modifier.reason }} - - {{ modifier.pilotCheck >= 0 ? '+' : '' }}{{ modifier.pilotCheck }} - + @if (!reviewOnly && lastOutcome() !== 'success' && canAttemptStand()) { +
+ + +
}
+ @if (!reviewOnly && supportsCarefulStand()) { + + } + @if (modifiersList().length > 0) { +
+
Modifiers
+
+ @for (modifier of modifiersList(); track $index) { +
+ + @if (modifier.loc) { {{ modifier.loc }} } @else { — } + + {{ modifier.reason }} + + {{ modifier.pilotCheck >= 0 ? '+' : '' }}{{ modifier.pilotCheck }} + +
+ } +
+
+ } }
@@ -67,16 +78,18 @@
- +@if (!reviewOnly && !canStandWithoutPSR()) { + +} diff --git a/src/app/components/page-viewer/overlay/page-standing-up-panel.component.scss b/src/app/components/page-viewer/overlay/page-standing-up-panel.component.scss index d59df5652..f826d41da 100644 --- a/src/app/components/page-viewer/overlay/page-standing-up-panel.component.scss +++ b/src/app/components/page-viewer/overlay/page-standing-up-panel.component.scss @@ -12,6 +12,11 @@ margin: 0; } +.careful-stand.disabled { + opacity: 0.55; + cursor: not-allowed; +} + .modifier-badge { flex: 0 0 24px; inline-size: 24px; @@ -42,6 +47,11 @@ font-weight: 700; } +.stand-attempt-limit { + font-size: 0.8em; + color: var(--bt-yellow); +} + .attempts strong { min-inline-size: 2ch; color: var(--text-color); diff --git a/src/app/components/page-viewer/overlay/page-standing-up-panel.component.spec.ts b/src/app/components/page-viewer/overlay/page-standing-up-panel.component.spec.ts index 19f4ced18..16f5fa0b0 100644 --- a/src/app/components/page-viewer/overlay/page-standing-up-panel.component.spec.ts +++ b/src/app/components/page-viewer/overlay/page-standing-up-panel.component.spec.ts @@ -7,26 +7,48 @@ import { TestBed } from '@angular/core/testing'; import { DiceRollerComponent } from '../../dice-roller/dice-roller.component'; import { OverlayManagerService } from '../../../services/overlay-manager.service'; import { PageInteractionOverlayComponent } from './page-interaction-overlay.component'; -import { PageStandingUpPanelComponent } from './page-standing-up-panel.component'; +import { PageStandingUpPanelComponent, STANDING_UP_REVIEW_ONLY } from './page-standing-up-panel.component'; describe('PageStandingUpPanelComponent', () => { it('applies careful standing, resolves rolls, and adjusts the attempt count', () => { const attempts = signal(undefined); - const resolveStandAttempt = jasmine.createSpy('resolveStandAttempt').and.callFake(() => { + const carefulStand = signal(false); + const canStandUp = signal(true); + const canCarefulStand = signal(true); + const resolveStandAttempt = jasmine.createSpy('resolveStandAttempt').and.callFake(( + _outcome: string, + options: { carefulStand?: boolean }, + ) => { attempts.update(current => (current ?? 0) + 1); + if (options.carefulStand) { + carefulStand.set(true); + canStandUp.set(false); + } return true; }); const adjustStandAttempts = jasmine.createSpy('adjustStandAttempts').and.callFake((delta: number) => { attempts.update(current => Math.max(0, (current ?? 0) + delta)); + if (delta < 0) { + carefulStand.set(false); + canStandUp.set(true); + } }); const turnState = { standAttempts: attempts, + carefulStand, + canStandUp, + canStandWithoutPSR: signal(false), resolveStandAttempt, adjustStandAttempts, }; const unit = { id: 'unit-1', - rules: { standingUpPSRModifier: -1 }, + rules: { + standingUpPSRModifier: -1, + getStandAttemptLimit: () => 1, + supportsCarefulStand: true, + canCarefulStand: () => canCarefulStand() && !carefulStand(), + }, turnState: () => turnState, PSRTargetRoll: () => 8, PSRModifiers: () => ({ modifiers: [{ pilotCheck: 1, reason: 'Gyro damaged' }] }), @@ -49,6 +71,15 @@ describe('PageStandingUpPanelComponent', () => { 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(); + canCarefulStand.set(false); + fixture.detectChanges(); + expect(carefulStandCheckbox.disabled).toBeTrue(); + canCarefulStand.set(true); + fixture.detectChanges(); expect(component.targetRoll()).toBe(7); expect(component.attempts()).toBe(0); @@ -64,7 +95,7 @@ describe('PageStandingUpPanelComponent', () => { component.onRollFinished({ results: [3, 3], sum: 6 }); expect(roller.roll).toHaveBeenCalledTimes(1); - expect(resolveStandAttempt).toHaveBeenCalledOnceWith('success'); + expect(resolveStandAttempt).toHaveBeenCalledOnceWith('success', { carefulStand: true }); expect(component.lastOutcome()).toBe('success'); expect(component.rolledResult()).toBe('SUCCESS'); expect(component.attempts()).toBe(1); @@ -90,8 +121,18 @@ describe('PageStandingUpPanelComponent', () => { it('does not apply the Core standing modifier under TW rules', () => { const unit = { id: 'unit-1', - rules: { standingUpPSRModifier: 0 }, - turnState: () => ({ standAttempts: signal(undefined) }), + rules: { + standingUpPSRModifier: 0, + getStandAttemptLimit: () => null, + supportsCarefulStand: true, + canCarefulStand: () => false, + }, + turnState: () => ({ + standAttempts: signal(undefined), + carefulStand: signal(false), + canStandUp: signal(true), + canStandWithoutPSR: signal(false), + }), PSRTargetRoll: () => 8, PSRModifiers: () => ({ modifiers: [] }), }; @@ -108,4 +149,131 @@ describe('PageStandingUpPanelComponent', () => { expect(component.targetRoll()).toBe(8); expect(component.modifiersList()).toEqual([]); }); + + it('does not offer careful stand under Core rules', () => { + const unit = { + id: 'unit-1', + rules: { + standingUpPSRModifier: -1, + getStandAttemptLimit: () => null, + supportsCarefulStand: false, + canCarefulStand: () => false, + }, + turnState: () => ({ + standAttempts: signal(undefined), + carefulStand: signal(false), + canStandUp: signal(true), + canStandWithoutPSR: signal(false), + }), + PSRTargetRoll: () => 8, + PSRModifiers: () => ({ modifiers: [] }), + }; + + TestBed.configureTestingModule({ + imports: [PageStandingUpPanelComponent], + providers: [ + { provide: PageInteractionOverlayComponent, useValue: { unit: signal(unit) } }, + { provide: OverlayManagerService, useValue: { closeManagedOverlay: jasmine.createSpy('closeManagedOverlay') } }, + ], + }); + const fixture = TestBed.createComponent(PageStandingUpPanelComponent); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.careful-stand')).toBeNull(); + expect(fixture.componentInstance.carefulStand()).toBeFalse(); + }); + + 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'); + const unit = { + id: 'unit-1', + rules: { + standingUpPSRModifier: -1, + getStandAttemptLimit: () => 1, + supportsCarefulStand: true, + canCarefulStand: () => false, + }, + turnState: () => ({ + standAttempts: attempts, + carefulStand, + canStandUp: signal(true), + canStandWithoutPSR: signal(false), + resolveStandAttempt, + adjustStandAttempts, + }), + PSRTargetRoll: () => 8, + PSRModifiers: () => ({ modifiers: [{ pilotCheck: 1, reason: 'Gyro damaged' }] }), + }; + + TestBed.configureTestingModule({ + imports: [PageStandingUpPanelComponent], + providers: [ + { provide: PageInteractionOverlayComponent, useValue: { unit: signal(unit) } }, + { provide: OverlayManagerService, useValue: { closeManagedOverlay: jasmine.createSpy('closeManagedOverlay') } }, + { provide: STANDING_UP_REVIEW_ONLY, useValue: true }, + ], + }); + const fixture = TestBed.createComponent(PageStandingUpPanelComponent); + fixture.detectChanges(); + const component = fixture.componentInstance; + + expect(component.reviewOnly).toBeTrue(); + 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(); + + 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(); + }); + + it('shows only stand attempts when reviewing a unit that can stand without a PSR', () => { + const unit = { + id: 'quad-1', + rules: { + standingUpPSRModifier: -1, + getStandAttemptLimit: () => null, + supportsCarefulStand: true, + canCarefulStand: () => true, + }, + turnState: () => ({ + standAttempts: signal(1), + carefulStand: signal(false), + canStandUp: signal(true), + canStandWithoutPSR: signal(true), + adjustStandAttempts: jasmine.createSpy('adjustStandAttempts'), + }), + PSRTargetRoll: () => 8, + PSRModifiers: () => ({ modifiers: [{ pilotCheck: 1, reason: 'Gyro damaged' }] }), + }; + + TestBed.configureTestingModule({ + imports: [PageStandingUpPanelComponent], + providers: [ + { provide: PageInteractionOverlayComponent, useValue: { unit: signal(unit) } }, + { provide: OverlayManagerService, useValue: { closeManagedOverlay: jasmine.createSpy('closeManagedOverlay') } }, + { provide: STANDING_UP_REVIEW_ONLY, useValue: true }, + ], + }); + const fixture = TestBed.createComponent(PageStandingUpPanelComponent); + fixture.detectChanges(); + + 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(); + }); }); diff --git a/src/app/components/page-viewer/overlay/page-standing-up-panel.component.ts b/src/app/components/page-viewer/overlay/page-standing-up-panel.component.ts index 8a7199f3c..55f4ab569 100644 --- a/src/app/components/page-viewer/overlay/page-standing-up-panel.component.ts +++ b/src/app/components/page-viewer/overlay/page-standing-up-panel.component.ts @@ -2,21 +2,28 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { ChangeDetectionStrategy, Component, computed, inject, Injector, signal, viewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, InjectionToken, Injector, signal, viewChild } from '@angular/core'; import { Overlay } from '@angular/cdk/overlay'; import { ComponentPortal } from '@angular/cdk/portal'; import { OverlayManagerService } from '../../../services/overlay-manager.service'; import { DiceRollerComponent } from '../../dice-roller/dice-roller.component'; import { PageInteractionOverlayComponent } from './page-interaction-overlay.component'; -import { displayPsrModifiers } from './page-turn-summary.util'; +import { displayPsrModifiers, openTurnSummaryChildOverlay } from './page-turn-summary.util'; import { psrRollOutcome } from './page-psr-warning-panel.component'; import type { RuleCheckOutcome } from '../../../models/force-serialization'; +export const STANDING_UP_REVIEW_ONLY = new InjectionToken('Standing up review'); + +export interface StandingUpOverlayOptions { + readonly reviewOnly?: boolean; +} + export function toggleStandingUpOverlay( parent: PageInteractionOverlayComponent, overlayManager: OverlayManagerService, injector: Injector, overlay: Overlay, + options: StandingUpOverlayOptions = {}, ): void { const unitId = parent.unit()?.id; if (!unitId) return; @@ -28,18 +35,23 @@ export function toggleStandingUpOverlay( } const customInjector = Injector.create({ - providers: [{ provide: PageInteractionOverlayComponent, useValue: parent }], + providers: [ + { provide: PageInteractionOverlayComponent, useValue: parent }, + { provide: STANDING_UP_REVIEW_ONLY, useValue: options.reviewOnly ?? false }, + ], parent: injector, }); const portal = new ComponentPortal(PageStandingUpPanelComponent, null, customInjector); - overlayManager.createManagedOverlay(overlayKey, null, portal, { - hasBackdrop: true, - backdropClass: 'cdk-overlay-dark-backdrop', - panelClass: 'standing-up-overlay-panel', - closeOnOutsideClick: true, - scrollStrategy: overlay.scrollStrategies.block(), - positions: [], - }); + openTurnSummaryChildOverlay(overlayManager, unitId, () => + overlayManager.createManagedOverlay(overlayKey, null, portal, { + hasBackdrop: true, + backdropClass: 'cdk-overlay-dark-backdrop', + panelClass: 'standing-up-overlay-panel', + closeOnOutsideClick: true, + scrollStrategy: overlay.scrollStrategies.block(), + positions: [], + }) + ); } @Component({ @@ -57,7 +69,11 @@ export class PageStandingUpPanelComponent { private readonly overlayManager = inject(OverlayManagerService); readonly diceRoller = viewChild('roller'); readonly unit = this.parent.unit; - readonly carefulStand = signal(false); + readonly reviewOnly = inject(STANDING_UP_REVIEW_ONLY, { optional: true }) ?? false; + readonly carefulStand = signal( + this.unit()?.rules.supportsCarefulStand === true + && this.unit()?.turnState().carefulStand?.() === true + ); readonly lastOutcome = signal(null); readonly rolledResult = signal(null); readonly rolledResultTone = computed<'default' | 'success' | 'failed'>(() => { @@ -73,6 +89,17 @@ export class PageStandingUpPanelComponent { }); readonly attempts = computed(() => this.unit()?.turnState().standAttempts() ?? 0); + readonly canStandWithoutPSR = computed(() => this.unit()?.turnState().canStandWithoutPSR() ?? false); + readonly attemptLimit = computed(() => { + const unit = this.unit(); + return unit?.rules.getStandAttemptLimit(unit.turnState()) ?? null; + }); + readonly supportsCarefulStand = computed(() => this.unit()?.rules.supportsCarefulStand ?? false); + readonly canCarefulStand = computed(() => { + const unit = this.unit(); + return unit?.rules.canCarefulStand(unit.turnState()) ?? false; + }); + readonly canAttemptStand = computed(() => this.unit()?.turnState().canStandUp() ?? false); readonly modifiersList = computed(() => { const unit = this.unit(); @@ -92,10 +119,13 @@ export class PageStandingUpPanelComponent { } setCarefulStand(event: Event): void { - this.carefulStand.set((event.target as HTMLInputElement).checked); + if (this.reviewOnly) return; + const checked = (event.target as HTMLInputElement).checked; + this.carefulStand.set(checked && this.canCarefulStand()); } roll(): void { + if (this.reviewOnly) return; const roller = this.diceRoller(); if (!roller || roller.isRolling() || this.lastOutcome() === 'success') return; this.lastOutcome.set(null); @@ -104,20 +134,28 @@ export class PageStandingUpPanelComponent { } 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()); } resolve(outcome: RuleCheckOutcome): void { + if (this.reviewOnly) return; const unit = this.unit(); if (!unit || this.lastOutcome() === 'success') return; this.rolledResult.set(null); - if (unit.turnState().resolveStandAttempt(outcome)) this.lastOutcome.set(outcome); + if (unit.turnState().resolveStandAttempt(outcome, { carefulStand: this.carefulStand() })) { + this.lastOutcome.set(outcome); + } } adjustAttempts(delta: number): void { - this.unit()?.turnState().adjustStandAttempts(delta); + //Note: even in reviewOnly mode we still allow to adjust the attempts. That's the whole point of the review mode... + const turnState = this.unit()?.turnState(); + turnState?.adjustStandAttempts(delta); + const committedCarefulStand = turnState?.carefulStand?.(); + if (committedCarefulStand !== undefined) this.carefulStand.set(committedCarefulStand); if (this.lastOutcome() !== 'success') this.lastOutcome.set(null); this.rolledResult.set(null); } diff --git a/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.html b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.html index 76610a77c..578ffd421 100644 --- a/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.html +++ b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.html @@ -7,6 +7,9 @@
TURN TRACKER
+ @if (showImmobileStatus()) { +
Unit is immobile
+ } @else { @if (canSwitchAirborneMode()) {
-
- } - @if (canSwitchAirborneMode() === false || airborne() !== null) {
@for (moveMode of moveModes(); track moveMode.mode) { @@ -40,9 +37,11 @@ type="button" class="bt-button move-button" [class.stationary]="mode === 'stationary'" + [class.stationary-only]="mode === 'stationary' && onlyStationaryMoveMode()" [class.selected]="currentMoveMode() === mode" [class.danger]="moveMode.psr" [disabled]="isMoveModeDisabled(mode)" + [attr.aria-label]="mode === 'stationary' ? 'Stationary' : null" [attr.aria-pressed]="currentMoveMode() === mode" (click)="selectMove(mode)" > @@ -60,13 +59,35 @@ }
+ @if (prone() && canStandUp()) { +
+ +
+ } + + @if (standAttempts() > 0) { +
+ +
+ } + @if (currentMoveMode() !== 'stationary' && currentMoveMode() !== null) {
} } + }