Attempts
@@ -15,51 +17,60 @@
aria-label="Increase stand attempts" title="Increase stand attempts" (click)="adjustAttempts(1)">+
-
-
- @if (lastOutcome() !== 'success') {
-
-
-
-
+ @if (!canStandWithoutPSR()) {
+
-
- @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..cfff493bf 100644
--- a/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.html
+++ b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.html
@@ -7,6 +7,10 @@
+ @if (showImmobileStatus()) {
+ Unit is immobile
+ }
+ @if (showMovementControls()) {
@if (canSwitchAirborneMode()) {