From d139101621d029250817121466c772bbf00150ae Mon Sep 17 00:00:00 2001 From: Yuri Sim Date: Tue, 7 Oct 2025 17:18:00 -0500 Subject: [PATCH 01/24] docs: Add Material icon sizing guideline --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index c6d951e..807cbaf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,6 +49,11 @@ npx nx lint pac-shield - **Styling**: Tailwind utilities only, no custom CSS files - **Control Flow**: `@if/@for/@switch` only, no `*ngIf/*ngFor/*ngSwitch` - **Imports**: Direct paths only, no barrel exports +- **Icons**: NEVER use Tailwind text size classes on `` elements + - **Why**: Material icons have built-in sizing that works with Material Design typography + - **Wrong**: `icon` + - **Correct**: `icon` + - **Check**: `grep -r "mat-icon.*text-[0-9xs]" apps/pac-shield/src/` must return zero ### ๐Ÿ—ƒ๏ธ Database Schema & DTO Generation 1. Edit `apps/pac-shield-api/src/prisma/schema.prisma` From 11605241e2890dd216fc5b05a2d3dce41c327ba3 Mon Sep 17 00:00:00 2001 From: Yuri Sim Date: Tue, 7 Oct 2025 17:18:00 -0500 Subject: [PATCH 02/24] style: Remove Tailwind text sizing from Material icons --- apps/pac-shield/src/app/app.html | 2 +- .../country-access-dialog.component.html | 2 +- .../country-access-dice-roll-dialog.component.html | 4 ++-- .../flight-planner-dialog.component.html | 2 +- .../features/game/fos/fos-task-board.component.html | 11 +++++------ .../game-stats/ato-table/ato-table.component.html | 4 ++-- .../caoc-dashboard/caoc-dashboard.component.html | 12 ++++++------ .../fos-dashboard/fos-dashboard.component.html | 2 +- .../game/game-stats/game-stats.component.html | 2 +- .../mob-dashboard/mob-dashboard.component.html | 6 +++--- .../game-stats/scoreboard/scoreboard.component.html | 2 +- .../location-panel/location-panel.component.html | 10 +++++----- .../political-access/political-access.component.html | 12 ++++++------ 13 files changed, 35 insertions(+), 36 deletions(-) diff --git a/apps/pac-shield/src/app/app.html b/apps/pac-shield/src/app/app.html index 24bff98..1e7df50 100644 --- a/apps/pac-shield/src/app/app.html +++ b/apps/pac-shield/src/app/app.html @@ -138,7 +138,7 @@

Notifications

@for (notification of notificationService.notifications(); track notification.id) {
- + {{ notification.read ? 'notifications' : 'notifications_active' }}
diff --git a/apps/pac-shield/src/app/features/game/country-access-dialog/country-access-dialog.component.html b/apps/pac-shield/src/app/features/game/country-access-dialog/country-access-dialog.component.html index af9dbd7..1dc91c7 100644 --- a/apps/pac-shield/src/app/features/game/country-access-dialog/country-access-dialog.component.html +++ b/apps/pac-shield/src/app/features/game/country-access-dialog/country-access-dialog.component.html @@ -8,7 +8,7 @@

- info + info Access Level Details:
diff --git a/apps/pac-shield/src/app/features/game/country-access-dice-roll-dialog/country-access-dice-roll-dialog.component.html b/apps/pac-shield/src/app/features/game/country-access-dice-roll-dialog/country-access-dice-roll-dialog.component.html index dfbaf97..2f9cdea 100644 --- a/apps/pac-shield/src/app/features/game/country-access-dice-roll-dialog/country-access-dice-roll-dialog.component.html +++ b/apps/pac-shield/src/app/features/game/country-access-dice-roll-dialog/country-access-dice-roll-dialog.component.html @@ -65,7 +65,7 @@ [class.animate-pulse]="roll.isRolling" [attr.aria-label]="'Roll dice for ' + getCountryDisplayName(roll.country)" > - casino + casino @@ -73,7 +73,7 @@ [class.bg-md-sys-secondary]="roll.isRolling" [class.text-md-sys-on-secondary]="roll.isRolling"> @if (roll.isRolling) { - autorenew + autorenew } @else { {{ roll.diceValue }} } diff --git a/apps/pac-shield/src/app/features/game/dialogs/flight-planner/flight-planner-dialog.component.html b/apps/pac-shield/src/app/features/game/dialogs/flight-planner/flight-planner-dialog.component.html index 6e5e446..930bc41 100644 --- a/apps/pac-shield/src/app/features/game/dialogs/flight-planner/flight-planner-dialog.component.html +++ b/apps/pac-shield/src/app/features/game/dialogs/flight-planner/flight-planner-dialog.component.html @@ -253,7 +253,7 @@

@if (hasRouteWarnings) {
- warning
diff --git a/apps/pac-shield/src/app/features/game/fos/fos-task-board.component.html b/apps/pac-shield/src/app/features/game/fos/fos-task-board.component.html index 33659a2..5aec059 100644 --- a/apps/pac-shield/src/app/features/game/fos/fos-task-board.component.html +++ b/apps/pac-shield/src/app/features/game/fos/fos-task-board.component.html @@ -23,7 +23,7 @@

class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4" >
- analytics
@@ -49,7 +49,6 @@

[class.md-sys-color-on-surface-variant]="!isCompleted(task)" > @if (isCompleted(task)) { - check_circle + check_circle Done } @else { - schedule + schedule Pending } @@ -219,7 +218,7 @@

class="px-4 py-1 text-sm transition-transform group-hover:scale-105 active:scale-95 disabled:opacity-50" (click)="toggle(task)" > - + {{ isCompleted(task) ? 'undo' : 'check_circle' }} @@ -240,7 +239,7 @@

- + radio_button_unchecked

diff --git a/apps/pac-shield/src/app/features/game/game-stats/ato-table/ato-table.component.html b/apps/pac-shield/src/app/features/game/game-stats/ato-table/ato-table.component.html index a672085..947181f 100644 --- a/apps/pac-shield/src/app/features/game/game-stats/ato-table/ato-table.component.html +++ b/apps/pac-shield/src/app/features/game/game-stats/ato-table/ato-table.component.html @@ -24,7 +24,7 @@ {{ l.aircraftCallSign || 'โ€”' }} @if (l.riskTokenUsed) { - casino + casino } @@ -72,7 +72,7 @@ PPR Status
- + {{ getPprStatusIcon(l.pprStatus) }} {{ l.pprStatus || 'PENDING' }} diff --git a/apps/pac-shield/src/app/features/game/game-stats/caoc-dashboard/caoc-dashboard.component.html b/apps/pac-shield/src/app/features/game/game-stats/caoc-dashboard/caoc-dashboard.component.html index 54bb978..4a55c74 100644 --- a/apps/pac-shield/src/app/features/game/game-stats/caoc-dashboard/caoc-dashboard.component.html +++ b/apps/pac-shield/src/app/features/game/game-stats/caoc-dashboard/caoc-dashboard.component.html @@ -50,7 +50,7 @@ [value]="section.id" class="flex-1 min-h-[48px] px-2">
- {{ section.icon }} + {{ section.icon }} {{ section.shortLabel }}
@@ -439,7 +439,7 @@

CFACC Decision

@if (unallocatedPool$ | async; as aircraft) { @if (aircraft.length === 0) {
- flight_takeoff + flight_takeoff

All aircraft allocated

} @else { @@ -448,7 +448,7 @@

CFACC Decision

- flight + flight {{ aircraftItem.callSign }}
{{ aircraftItem.type }} @@ -460,7 +460,7 @@

CFACC Decision

@@ -560,7 +560,7 @@

CFACC Decision

- check_circle + check_circle Recommendations
    @@ -573,7 +573,7 @@

    CFACC Decision

    @if (analytics$ | async; as analytics) {
    - info + info Current Status
    diff --git a/apps/pac-shield/src/app/features/game/game-stats/fos-dashboard/fos-dashboard.component.html b/apps/pac-shield/src/app/features/game/game-stats/fos-dashboard/fos-dashboard.component.html index 6bc72d4..a895ca3 100644 --- a/apps/pac-shield/src/app/features/game/game-stats/fos-dashboard/fos-dashboard.component.html +++ b/apps/pac-shield/src/app/features/game/game-stats/fos-dashboard/fos-dashboard.component.html @@ -20,7 +20,7 @@ @if (showTeamFilter) {
    - group + group + Forward Operating Site identifier + + +
    OR
    + + + Hex Coordinate + + Hex grid coordinate + + + @if (spawnForm.hasError('locationRequired')) { +
    + Either FOS ID or Hex coordinate is required +
    + } + + + + + + + + `, + styles: [` + mat-dialog-content { + min-width: 400px; + max-width: 500px; + } + + h2 { + display: flex; + align-items: center; + gap: 8px; + } + + .animate-spin { + animation: spin 1s linear infinite; + } + + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + `] +}) +export class AircraftSpawnDialogComponent { + private fb = inject(FormBuilder); + private http = inject(HttpClient); + private dialogRef = inject(MatDialogRef); + readonly data: AircraftSpawnDialogData = inject(MAT_DIALOG_DATA); + + isSpawning = false; + + spawnForm: FormGroup; + + constructor() { + this.spawnForm = this.fb.group({ + type: ['C130', Validators.required], + subtype: [null], + teamId: [null, Validators.required], + locationFosId: [''], + locationHex: [''], + }, { + validators: this.locationValidator + }); + + // Set default team to first team + if (this.data.teams.length > 0) { + this.spawnForm.patchValue({ teamId: this.data.teams[0].id }); + } + } + + /** + * Custom validator to ensure either locationFosId or locationHex is provided + */ + private locationValidator(form: FormGroup) { + const fosId = form.get('locationFosId')?.value; + const hex = form.get('locationHex')?.value; + + if (!fosId && !hex) { + return { locationRequired: true }; + } + return null; + } + + /** + * Update default values when aircraft type changes + */ + onTypeChange(): void { + const type = this.spawnForm.get('type')?.value; + + // Clear subtype if not C5 + if (type !== 'C5') { + this.spawnForm.patchValue({ subtype: null }); + } else if (type === 'C5' && !this.spawnForm.get('subtype')?.value) { + // Set default subtype for C5 + this.spawnForm.patchValue({ subtype: 'BOBCAT' }); + } + } + + /** + * Spawn the aircraft via API + */ + async onSpawn(): Promise { + if (!this.spawnForm.valid) { + return; + } + + this.isSpawning = true; + + try { + const formValue = this.spawnForm.value; + const payload = { + gameId: this.data.gameId, + type: formValue.type, + subtype: formValue.type === 'C5' ? formValue.subtype : null, + teamId: formValue.teamId, + locationFosId: formValue.locationFosId || undefined, + locationHex: formValue.locationHex || undefined, + }; + + const result = await this.http.post( + `${environment.apiUrl}/allocation/aircraft/spawn`, + payload + ).toPromise(); + + // Close dialog with success result + this.dialogRef.close(result); + } catch (error) { + console.error('Failed to spawn aircraft:', error); + alert('Failed to spawn aircraft. Check console for details.'); + this.isSpawning = false; + } + } + + onCancel(): void { + this.dialogRef.close(); + } +} From 837c0303595ba60c61a08d05eca1e59ec286a70c Mon Sep 17 00:00:00 2001 From: Yuri Sim Date: Tue, 7 Oct 2025 17:18:01 -0500 Subject: [PATCH 09/24] feat(client): Refactor CAOC dashboard for GM aircraft management and signals --- .../caoc-dashboard.component.html | 352 +++++------------- .../caoc-dashboard.component.ts | 247 ++++++------ 2 files changed, 224 insertions(+), 375 deletions(-) diff --git a/apps/pac-shield/src/app/features/game/game-stats/caoc-dashboard/caoc-dashboard.component.html b/apps/pac-shield/src/app/features/game/game-stats/caoc-dashboard/caoc-dashboard.component.html index 4a55c74..586b95b 100644 --- a/apps/pac-shield/src/app/features/game/game-stats/caoc-dashboard/caoc-dashboard.component.html +++ b/apps/pac-shield/src/app/features/game/game-stats/caoc-dashboard/caoc-dashboard.component.html @@ -7,17 +7,6 @@ CFACC }
    - - @if (isCfacc) { - - }
    @@ -144,30 +133,56 @@

    Request Summary

    } - + -
    - flight -

    Aircraft Pool

    +
    +
    + flight +

    Aircraft Pool

    +
    + @if (isGM()) { + + }
    - @if (analytics$ | async; as analytics) { -
    -
    - C-17: - {{ analytics.available.C17 }} -
    +
    +
    + C-130 (AW): + {{ aircraftCounts().C130 }} +
    +
    + C-17 (ME): + {{ aircraftCounts().C17 }} +
    +
    + C-5 Bobcat (BO): + {{ aircraftCounts().C5_BOBCAT }} +
    +
    + C-5 Rhino (RH): + {{ aircraftCounts().C5_RHINO }} +
    + @if (isGM()) { +
    - C-130: - {{ analytics.available.C130 }} + F-16 (VIP): + {{ aircraftCounts().F16 }}
    - C-5: - {{ analytics.available.C5 }} + F-22 (RPT): + {{ aircraftCounts().F22 }}
    -
    - } @else { -
    - Loading aircraft pool... + } +
    + @if (loading().pool) { +
    + + Loading...
    } @@ -192,238 +207,84 @@

    Aircraft Pool

    - +
    - - assignment - MOB Aircraft Requests - @if (pendingRequests$ | async; as pending) { - @if (pending.length > 0) { - {{ pending.length }} Pending - } + +
    + flight_takeoff + Aircraft Distribution to MOBs +
    + @if (canAllocateAircraft) { + }
    - @if (isLoading$ | async) { + @if (loading().allocations) {
    } @else { - @if (allRequests$ | async; as requests) { - @if (requests.length === 0) { -
    - inbox -

    No aircraft requests submitted yet

    -

    MOB teams can submit requests using their dashboards

    -
    - } @else { -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - -
    MOB Team -
    - group - {{ request.team?.name || 'Unknown' }} -
    -
    Aircraft + @if (allocationsByTeam().size === 0) { +
    + flight_land +

    No aircraft allocated yet

    +

    Use the "Allocate Aircraft" button to distribute aircraft to MOBs

    +
    + } @else { + +
    + @for (team of mobTeams(); track team.id) { + + +
    - flight - {{ request.aircraftType }} + location_city +

    {{ team.name }}

    -
    Qty -
    - - {{ request.quantityRequested }} - - @if (request.quantityAllocated && request.quantityAllocated !== request.quantityRequested) { -
    - Allocated: {{ request.quantityAllocated }} -
    - } -
    -
    Priority - - {{ getPriorityLabel(request.priority) }} + + {{ getTeamAllocations(team.id).length }} Aircraft - Mission -
    -
    {{ request.missionJustification }}
    - @if (request.rationale) { -
    - {{ request.rationale }} + @if (getTeamAllocations(team.id).length === 0) { +

    No aircraft allocated

    + } @else { +
    + @for (allocation of getTeamAllocations(team.id); track allocation.id) { +
    +
    + flight + {{ allocation.aircraftInstance?.callSign }} +
    + @if (canAllocateAircraft) { + + }
    }
    -
    Status -
    - - {{ getStatusIcon(request.status) }} - - {{ request.status }} -
    - @if (request.cfaccNotes) { -
    - {{ request.cfaccNotes }} -
    - } -
    Submitted - {{ formatDate(request.submittedAt) }} - Actions - @if (request.status === StatusValues.PENDING && canReviewRequests) { -
    - - - -
    - } @else { - - {{ request.status === StatusValues.PENDING ? 'Pending Review' : 'Completed' }} - - } -
    -
    -
    - } - } @else { -
    - -

    Loading requests...

    + } + + + }
    } }
    - - - @if (selectedRequest) { - - - - rate_review - Review Request: {{ selectedRequest.team?.name }} - {{ selectedRequest.aircraftType }} - - - -
    -
    -

    Request Details

    -
    -
    Quantity: {{ selectedRequest.quantityRequested }}
    -
    Priority: {{ getPriorityLabel(selectedRequest.priority) }}
    -
    Mission: {{ selectedRequest.missionJustification }}
    -
    Rationale: {{ selectedRequest.rationale }}
    -
    -
    -
    -

    CFACC Decision

    -
    - - Quantity to Allocate - - - - CFACC Notes - - -
    - - - - -
    -
    -
    -
    -
    -
    - }
    @@ -625,17 +486,4 @@

    CFACC Decision

    }
    } - - - -@if (currentToastNotification) { -
    - -
    -} + \ No newline at end of file diff --git a/apps/pac-shield/src/app/features/game/game-stats/caoc-dashboard/caoc-dashboard.component.ts b/apps/pac-shield/src/app/features/game/game-stats/caoc-dashboard/caoc-dashboard.component.ts index 6a74832..2d99a1d 100644 --- a/apps/pac-shield/src/app/features/game/game-stats/caoc-dashboard/caoc-dashboard.component.ts +++ b/apps/pac-shield/src/app/features/game/game-stats/caoc-dashboard/caoc-dashboard.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, inject, OnInit, OnDestroy, Input } from '@angular/core'; +import { Component, inject, OnInit, OnDestroy, Input, computed } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatCardModule } from '@angular/material/card'; import { MatDividerModule } from '@angular/material/divider'; @@ -18,12 +18,11 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; -import { Observable, Subject, filter, takeUntil, BehaviorSubject } from 'rxjs'; +import { Observable, Subject, BehaviorSubject } from 'rxjs'; -import { AllocationNotificationBadgeComponent } from '../../notifications/allocation-notification-badge/allocation-notification-badge.component'; -import { AllocationNotificationCenterComponent } from '../../notifications/allocation-notification-center/allocation-notification-center.component'; -import { AllocationNotificationToastComponent } from '../../notifications/allocation-notification-toast/allocation-notification-toast.component'; import { AllocationWebSocketService } from '../../../../shared/services/allocation-websocket.service'; +import { AllocationSignalService } from '../../../../shared/services/allocation-signal.service'; +import { AircraftSpawnDialogComponent, AircraftSpawnDialogData } from '../../dialogs/aircraft-spawn-dialog/aircraft-spawn-dialog.component'; import { ResponsiveNavService } from '../responsive-nav.service'; import * as AllocationActions from '../../../../store/allocation/allocation.actions'; import * as AllocationSelectors from '../../../../store/allocation/allocation.selectors'; @@ -32,7 +31,6 @@ import { AircraftInstance } from '../../../../generated/aircraftInstance/aircraf import { AircraftAllocation } from '../../../../generated/aircraftAllocation/aircraftAllocation.entity'; import { AllocationCycle } from '../../../../generated/allocationCycle/allocationCycle.entity'; import { AllocationRequestStatus, AircraftType, TeamType, PlayerRole } from '../../../../generated/enums'; -import { AllocationNotification } from '../../../../store/allocation/allocation.state'; interface CaocSection { id: string; @@ -72,9 +70,7 @@ interface CaocSection { MatFormFieldModule, MatInputModule, MatBadgeModule, - MatTooltipModule, - AllocationNotificationBadgeComponent, - AllocationNotificationToastComponent + MatTooltipModule ], templateUrl: './caoc-dashboard.component.html', }) @@ -89,9 +85,46 @@ export class CaocDashboardComponent implements OnInit, OnDestroy { private readonly dialog = inject(MatDialog); private readonly snackBar = inject(MatSnackBar); private readonly webSocketService = inject(AllocationWebSocketService); + private readonly allocationSignalService = inject(AllocationSignalService); private readonly responsiveNavService = inject(ResponsiveNavService); private readonly destroy$ = new Subject(); + // Computed signals from AllocationSignalService + readonly aircraftCounts = this.allocationSignalService.aircraftCounts; + readonly loading = this.allocationSignalService.loading; + + // Computed property for GM check + readonly isGM = computed(() => this.currentUserRole === 'GM'); + + // MOB teams for direct allocation + readonly mobTeams = computed(() => { + // Filter for MOB teams only + const teams = [ + { id: 2, type: 'MOB_KADENA', name: 'Kadena AFB' }, + { id: 3, type: 'MOB_ANDERSEN', name: 'Andersen AFB' }, + { id: 4, type: 'MOB_YOKOTA', name: 'Yokota AB' }, + { id: 5, type: 'MOB_OSAN', name: 'Osan AB' }, + { id: 6, type: 'MOB_JBPHH', name: 'Joint Base Pearl Harbor' }, + ]; + return teams; + }); + + // Allocations grouped by team + readonly allocationsByTeam = computed(() => { + const allocations = this.allocationSignalService.allocations(); + const grouped = new Map(); + + allocations.forEach(allocation => { + const teamId = allocation.allocatedToTeamId; + if (!grouped.has(teamId)) { + grouped.set(teamId, []); + } + grouped.get(teamId)!.push(allocation); + }); + + return grouped; + }); + // Observable streams from NgRx store readonly currentCycle$: Observable = this.store.select(AllocationSelectors.selectCurrentAllocationCycle); readonly allRequests$: Observable = this.store.select(AllocationSelectors.selectAllRequests); @@ -101,14 +134,6 @@ export class CaocDashboardComponent implements OnInit, OnDestroy { readonly isLoading$: Observable = this.store.select(AllocationSelectors.selectIsAnyLoading); readonly analytics$ = this.store.select(AllocationSelectors.selectAllocationAnalytics); - // Notification observables - readonly unreadNotificationCount$ = this.store.select(AllocationSelectors.selectUnreadNotificationCount); - readonly hasUrgentNotifications$ = this.store.select(AllocationSelectors.selectHasUnreadUrgentNotifications); - readonly recentNotifications$ = this.store.select(AllocationSelectors.selectRecentNotifications); - readonly unacknowledgedNotifications$ = this.store.select(AllocationSelectors.selectUnacknowledgedNotifications); - - // Current displayed toast notification - currentToastNotification: AllocationNotification | null = null; // Responsive section management readonly caocSections: CaocSection[] = [ @@ -171,46 +196,6 @@ export class CaocDashboardComponent implements OnInit, OnDestroy { // TODO: Uncomment after dev server restart // this.setupDataRefresh(); - // TEMPORARILY DISABLED: Initialize WebSocket connection for real-time notifications - // TODO: Uncomment after dev server restart - // if (this.currentGameId) { - // this.webSocketService.connect({ - // gameId: this.currentGameId, - // teamId: this.isCaoc ? 1 : undefined, // TODO: Get actual team ID - // reconnect: true - // }); - // } - - // TEMPORARILY DISABLED: Listen for new notifications and show toast - // TODO: Uncomment after dev server restart - // this.recentNotifications$.pipe( - // filter(notifications => notifications.length > 0), - // takeUntil(this.destroy$) - // ).subscribe(notifications => { - // const latestNotification = notifications[0]; - // if (latestNotification && !latestNotification.read) { - // this.showToastNotification(latestNotification); - // } - // }); - - // TEMPORARILY DISABLED: Listen for urgent notifications and show snackbar - // TODO: Uncomment after dev server restart - // this.hasUrgentNotifications$.pipe( - // takeUntil(this.destroy$) - // ).subscribe(hasUrgent => { - // if (hasUrgent) { - // this.snackBar.open( - // 'Urgent allocation notification received!', - // 'View', - // { - // duration: 5000, - // panelClass: ['urgent-snackbar'] - // } - // ).onAction().subscribe(() => { - // this.openNotificationCenter(); - // }); - // } - // }); } ngOnDestroy(): void { @@ -219,6 +204,59 @@ export class CaocDashboardComponent implements OnInit, OnDestroy { this.webSocketService.disconnect(); } + // ============================================= + // GM AIRCRAFT MANAGEMENT (NEW) + // ============================================= + + /** + * Open aircraft spawn dialog for GMs + */ + async onSpawnAircraft(): Promise { + if (!this.isGM() || !this.currentGameId) { + return; + } + + // Get all teams for dropdown + const teams = await this.getAllTeams(); + + const dialogRef = this.dialog.open( + AircraftSpawnDialogComponent, + { + width: '500px', + data: { + gameId: this.currentGameId, + teams, + } + } + ); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.snackBar.open( + `Aircraft ${result.callSign} spawned successfully!`, + 'Close', + { duration: 3000 } + ); + // Signal service will auto-update via WebSocket + } + }); + } + + /** + * Get all teams (mock for now - would fetch from API) + */ + private async getAllTeams(): Promise { + // Mock teams - in real implementation, fetch from API + return [ + { id: 1, type: 'CAOC', name: 'CAOC Team' }, + { id: 2, type: 'MOB_KADENA', name: 'Kadena AFB' }, + { id: 3, type: 'MOB_ANDERSEN', name: 'Andersen AFB' }, + { id: 4, type: 'MOB_YOKOTA', name: 'Yokota AB' }, + { id: 5, type: 'MOB_OSAN', name: 'Osan AB' }, + { id: 6, type: 'MOB_JBPHH', name: 'Joint Base Pearl Harbor' }, + ]; + } + /** * Load all allocation-related data for the current game */ @@ -435,72 +473,6 @@ export class CaocDashboardComponent implements OnInit, OnDestroy { return item.id; } - /** - * Show toast notification for new allocation updates - */ - showToastNotification(notification: AllocationNotification): void { - this.currentToastNotification = notification; - - // Auto-dismiss toast after 8 seconds for non-urgent notifications - if (notification.priority !== 'URGENT') { - setTimeout(() => { - this.currentToastNotification = null; - }, 8000); - } - } - - /** - * Handle toast notification dismissal - */ - onToastDismissed(notificationId: string): void { - this.currentToastNotification = null; - this.store.dispatch(AllocationActions.dismissNotification({ notificationId })); - } - - /** - * Handle toast notification acknowledgment - */ - onToastAcknowledged(notificationId: string): void { - const notification = this.currentToastNotification; - if (notification) { - this.store.dispatch(AllocationActions.acknowledgeNotification({ - notificationId, - gameId: notification.gameId, - teamId: notification.targetTeamId || 0 - })); - } - this.currentToastNotification = null; - } - - /** - * Mark toast notification as read - */ - onToastRead(notificationId: string): void { - this.store.dispatch(AllocationActions.markNotificationAsRead({ notificationId })); - } - - /** - * Open notification center dialog - */ - openNotificationCenter(): void { - this.dialog.open(AllocationNotificationCenterComponent, { - width: '800px', - maxWidth: '90vw', - height: '600px', - maxHeight: '90vh', - disableClose: false, - autoFocus: false, - restoreFocus: true, - panelClass: 'notification-center-dialog' - }); - } - - /** - * Handle notification badge click - */ - onNotificationBadgeClick(): void { - this.openNotificationCenter(); - } /** * Set the current active section @@ -540,4 +512,33 @@ export class CaocDashboardComponent implements OnInit, OnDestroy { this.setCurrentSection(section.id); } } + + /** + * Open dialog to allocate aircraft to a MOB team + */ + async onAllocateToMOB(): Promise { + if (!this.canAllocateAircraft) { + this.snackBar.open('You do not have permission to allocate aircraft', 'Close', { duration: 3000 }); + return; + } + + const availableAircraft = this.allocationSignalService.aircraftPool(); + const teams = this.mobTeams(); + + if (availableAircraft.length === 0) { + this.snackBar.open('No aircraft available in pool', 'Close', { duration: 3000 }); + return; + } + + // For now, use a simple prompt - can be replaced with proper dialog later + this.snackBar.open('Direct allocation UI: Select aircraft and MOB team', 'Close', { duration: 3000 }); + } + + + /** + * Get aircraft allocated to a specific team + */ + getTeamAllocations(teamId: number): AircraftAllocation[] { + return this.allocationsByTeam().get(teamId) || []; + } } From 37a39418d67376e745c7b8917c4c245adbd8801d Mon Sep 17 00:00:00 2001 From: Yuri Sim Date: Tue, 7 Oct 2025 17:18:02 -0500 Subject: [PATCH 10/24] feat(client): Enhance ATO table with allocated aircraft data --- .../ato-table/ato-table.component.html | 8 +++-- .../ato-table/ato-table.component.ts | 29 +++++++++++++++++-- .../game/game-stats/game-stats.component.html | 1 + 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/pac-shield/src/app/features/game/game-stats/ato-table/ato-table.component.html b/apps/pac-shield/src/app/features/game/game-stats/ato-table/ato-table.component.html index 947181f..2cba5f2 100644 --- a/apps/pac-shield/src/app/features/game/game-stats/ato-table/ato-table.component.html +++ b/apps/pac-shield/src/app/features/game/game-stats/ato-table/ato-table.component.html @@ -8,8 +8,12 @@ }
    - @if (canCreateFlightPlan) { - diff --git a/apps/pac-shield/src/app/features/game/game-stats/ato-table/ato-table.component.ts b/apps/pac-shield/src/app/features/game/game-stats/ato-table/ato-table.component.ts index 7a933d1..eb7e9ca 100644 --- a/apps/pac-shield/src/app/features/game/game-stats/ato-table/ato-table.component.ts +++ b/apps/pac-shield/src/app/features/game/game-stats/ato-table/ato-table.component.ts @@ -13,6 +13,7 @@ import { ATOLine } from '../../../../generated/aTOLine/aTOLine.entity'; import { CreateATOLineDto } from '../../../../generated/aTOLine/create-aTOLine.dto'; import { UpdateATOLineDto } from '../../../../generated/aTOLine/update-aTOLine.dto'; import { TeamType, PlayerRole, PPRStatus } from '../../../../generated/enums'; +import { AircraftInstance } from '../../../../generated/aircraftInstance/aircraftInstance.entity'; import { FlightPlannerDialogComponent, FlightPlannerDialogData } from '../../dialogs/flight-planner/flight-planner-dialog.component'; import * as AtoActions from '../../../../store/ato/ato.actions'; @@ -42,6 +43,7 @@ export class AtoTableComponent { @Input() currentGameId: number | null = null; @Input() currentTurn = 1; @Input() readonly = false; + @Input() allocatedAircraft: AircraftInstance[] = []; private dialog = inject(MatDialog); private store = inject(Store); @@ -57,7 +59,28 @@ export class AtoTableComponent { } get canCreateFlightPlan(): boolean { - return (this.isMob || this.currentUserRole === 'GM') && !this.readonly; + // GMs can always create flight plans + if (this.currentUserRole === 'GM') { + return !this.readonly; + } + // MOB teams need allocated aircraft to create flight plans + return this.isMob && !this.readonly && this.allocatedAircraft.length > 0; + } + + get canCreateFlightPlanTooltip(): string { + if (this.readonly) { + return 'Read-only mode'; + } + if (this.currentUserRole === 'GM') { + return 'Create new flight plan'; + } + if (!this.isMob) { + return 'Only MOB teams can create flight plans'; + } + if (this.allocatedAircraft.length === 0) { + return 'No aircraft allocated to your team'; + } + return 'Create new flight plan'; } get canApprovePpr(): boolean { @@ -77,7 +100,7 @@ export class AtoTableComponent { const dialogData: FlightPlannerDialogData = { currentTurn: this.currentTurn, gameId: this.currentGameId, - availableAircraft: [], // TODO: Get from game state + availableAircraft: this.allocatedAircraft, }; const dialogRef = this.dialog.open(FlightPlannerDialogComponent, { @@ -108,7 +131,7 @@ export class AtoTableComponent { existingFlightPlan: line, currentTurn: this.currentTurn, gameId: this.currentGameId, - availableAircraft: [], // TODO: Get from game state + availableAircraft: this.allocatedAircraft, }; const dialogRef = this.dialog.open(FlightPlannerDialogComponent, { diff --git a/apps/pac-shield/src/app/features/game/game-stats/game-stats.component.html b/apps/pac-shield/src/app/features/game/game-stats/game-stats.component.html index e4a2b6d..e828945 100644 --- a/apps/pac-shield/src/app/features/game/game-stats/game-stats.component.html +++ b/apps/pac-shield/src/app/features/game/game-stats/game-stats.component.html @@ -87,6 +87,7 @@ [currentUserTeam]="currentUserTeam" [currentUserRole]="currentUserRole" [readonly]="false" + [allocatedAircraft]="allocatedAircraft()" >
    From 1170eab5713f4708b4626d8527ee3ad889bf4293 Mon Sep 17 00:00:00 2001 From: Yuri Sim Date: Tue, 7 Oct 2025 17:18:02 -0500 Subject: [PATCH 11/24] feat(client): Auto-populate aircraft location in flight planner --- .../flight-planner-dialog.component.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/pac-shield/src/app/features/game/dialogs/flight-planner/flight-planner-dialog.component.ts b/apps/pac-shield/src/app/features/game/dialogs/flight-planner/flight-planner-dialog.component.ts index 5af474f..fcc51b2 100644 --- a/apps/pac-shield/src/app/features/game/dialogs/flight-planner/flight-planner-dialog.component.ts +++ b/apps/pac-shield/src/app/features/game/dialogs/flight-planner/flight-planner-dialog.component.ts @@ -208,13 +208,27 @@ export class FlightPlannerDialogComponent implements OnInit, OnDestroy { onAircraftSelected(event: { value: AircraftInstance }): void { const selectedAircraft = event.value as AircraftInstance; if (selectedAircraft) { - // Auto-populate the call sign when aircraft is selected + // Auto-populate the call sign and start location when aircraft is selected this.flightPlanForm.patchValue({ - aircraftCallSign: selectedAircraft.callSign + aircraftCallSign: selectedAircraft.callSign, + startLocation: this.getAircraftLocation(selectedAircraft) }); } } + /** + * Get the current location of an aircraft in the format expected by the location field + */ + private getAircraftLocation(aircraft: AircraftInstance): string { + if (aircraft.locationType === 'FOS' && aircraft.locationFosId) { + return aircraft.locationFosId; + } else if (aircraft.locationHex) { + return aircraft.locationHex; + } + // Default fallback - should not normally happen + return ''; + } + /** * Get icon for aircraft type */ From 85b8e01a43d5bb5bfbe8499ecfb7f59394e3f775 Mon Sep 17 00:00:00 2001 From: Yuri Sim Date: Tue, 7 Oct 2025 17:18:02 -0500 Subject: [PATCH 12/24] feat(client): Implement responsive panel initial state --- .../src/app/features/game/game-board.component.ts | 10 ++++++++++ .../features/game/game-stats/game-stats.component.ts | 11 ++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/pac-shield/src/app/features/game/game-board.component.ts b/apps/pac-shield/src/app/features/game/game-board.component.ts index 70b93c0..6c7b709 100644 --- a/apps/pac-shield/src/app/features/game/game-board.component.ts +++ b/apps/pac-shield/src/app/features/game/game-board.component.ts @@ -3,6 +3,7 @@ import { Component, OnInit, inject, AfterViewInit, ElementRef, ViewChild, OnDest import { ActivatedRoute, Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { CommonModule } from '@angular/common'; +import { BreakpointObserver } from '@angular/cdk/layout'; import { map, take } from 'rxjs/operators'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatButtonModule } from '@angular/material/button'; @@ -109,6 +110,7 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { private dialog = inject(MatDialog); private fosService = inject(FosService); private snackBar = inject(MatSnackBar); + private breakpointObserver = inject(BreakpointObserver); /** * MapLibre GL map instance once initialized. * Exposed for template-bound components that require a direct Map reference. @@ -723,6 +725,14 @@ export class GameBoardComponent implements OnInit, AfterViewInit, OnDestroy { * - Error handling for missing or invalid game IDs */ ngOnInit(): void { + // Set initial collapsed state based on screen size (desktop starts expanded) + this.breakpointObserver.observe('(min-width: 768px)').subscribe(result => { + // Only set initial state if panel hasn't been manually changed or deep-linked + if (this.panelCollapsed === true && !this.route.snapshot.queryParamMap.get('panel')) { + this.panelCollapsed = !result.matches; // Desktop (โ‰ฅ768px) = false (expanded), Mobile (<768px) = true (collapsed) + } + }); + // Get the gameId from the route parameter const gameId = this.route.snapshot.paramMap.get('gameId'); if (gameId) { diff --git a/apps/pac-shield/src/app/features/game/game-stats/game-stats.component.ts b/apps/pac-shield/src/app/features/game/game-stats/game-stats.component.ts index e5739e2..6ab2f00 100644 --- a/apps/pac-shield/src/app/features/game/game-stats/game-stats.component.ts +++ b/apps/pac-shield/src/app/features/game/game-stats/game-stats.component.ts @@ -1,5 +1,6 @@ -import { Component, inject, OnInit, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core'; +import { Component, inject, OnInit, Input, Output, EventEmitter, OnChanges, SimpleChanges, computed } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { BreakpointObserver } from '@angular/cdk/layout'; import { MatTabsModule } from '@angular/material/tabs'; import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; @@ -94,6 +95,14 @@ export class GameStatsComponent implements OnInit, OnChanges { readonly currentTurnLabel = this.gameStatsService.currentTurnLabel; ngOnInit(): void { + // Set initial collapsed state based on screen size (desktop starts expanded) + this.breakpointObserver.observe('(min-width: 768px)').subscribe(result => { + // Only set initial state if collapsed hasn't been manually changed + if (this.collapsed === true) { + this.collapsed = !result.matches; // Desktop (โ‰ฅ768px) = false (expanded), Mobile (<768px) = true (collapsed) + } + }); + // Load demo data if requested (for development) if (this.loadDemoData) { this.gameStatsService.loadDemoData(); From fc3d696c48b8dcd230ce325160f05e58f178278d Mon Sep 17 00:00:00 2001 From: Yuri Sim Date: Tue, 7 Oct 2025 17:18:02 -0500 Subject: [PATCH 13/24] test(e2e): Add E2E tests for aircraft allocation and spawning --- .../src/aircraft-allocation.spec.ts | 443 ++++++++++++++++++ 1 file changed, 443 insertions(+) create mode 100644 apps/pac-shield-e2e/src/aircraft-allocation.spec.ts diff --git a/apps/pac-shield-e2e/src/aircraft-allocation.spec.ts b/apps/pac-shield-e2e/src/aircraft-allocation.spec.ts new file mode 100644 index 0000000..d6ccc9c --- /dev/null +++ b/apps/pac-shield-e2e/src/aircraft-allocation.spec.ts @@ -0,0 +1,443 @@ +import { test, expect } from '@playwright/test'; + +/** + * E2E Tests for Aircraft Allocation System + * + * Tests: + * 1. GM spawning aircraft with auto-generated callsigns + * 2. Direct allocation of aircraft to teams + * 3. Real-time WebSocket updates + * 4. ATO button enable/disable based on allocation + */ + +test.describe('Aircraft Allocation System', () => { + let gameId: number; + let teamId: number; + let aircraftId: number; + let authToken: string; + + test.beforeAll(async ({ request }) => { + // Create a test game and authenticate as GM + const gameResponse = await request.post('/api/game', { + data: { + roomCode: `TEST_${Date.now()}`, + victoryConditionMP: 100, + }, + }); + + expect(gameResponse.ok()).toBeTruthy(); + const game = await gameResponse.json(); + gameId = game.id; + + // Get or create CAOC team + const teamsResponse = await request.get(`/api/game/${gameId}/teams`); + const teams = await teamsResponse.json(); + teamId = teams.find((t: any) => t.type === 'CAOC')?.id || 1; + + // Authenticate (mock - in real scenario, would use actual auth) + authToken = 'mock-gm-token'; + }); + + test.describe('GM Aircraft Spawning', () => { + test('should spawn C-130 with auto-generated AW callsign', async ({ request }) => { + const response = await request.post('/api/allocation/aircraft/spawn', { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + data: { + gameId, + type: 'C130', + subtype: null, + teamId, + strength: 8, + rangeHexes: 12, + locationHex: '0x1234', + }, + }); + + expect(response.ok()).toBeTruthy(); + const aircraft = await response.json(); + + expect(aircraft).toHaveProperty('id'); + expect(aircraft.callSign).toMatch(/^AW\d{2,}$/); + expect(aircraft.type).toBe('C130'); + expect(aircraft.strength).toBe(8); + expect(aircraft.rangeHexes).toBe(12); + expect(aircraft.allocationStatus).toBe('AVAILABLE'); + + aircraftId = aircraft.id; + }); + + test('should spawn C-17 with auto-generated ME callsign', async ({ request }) => { + const response = await request.post('/api/allocation/aircraft/spawn', { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + data: { + gameId, + type: 'C17', + subtype: null, + teamId, + strength: 9, + rangeHexes: 15, + locationHex: '0x5678', + }, + }); + + expect(response.ok()).toBeTruthy(); + const aircraft = await response.json(); + + expect(aircraft.callSign).toMatch(/^ME\d{2,}$/); + expect(aircraft.type).toBe('C17'); + }); + + test('should spawn C-5 Bobcat with BO callsign', async ({ request }) => { + const response = await request.post('/api/allocation/aircraft/spawn', { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + data: { + gameId, + type: 'C5', + subtype: 'BOBCAT', + teamId, + strength: 10, + rangeHexes: 18, + locationHex: '0x9ABC', + }, + }); + + expect(response.ok()).toBeTruthy(); + const aircraft = await response.json(); + + expect(aircraft.callSign).toMatch(/^BO\d{2,}$/); + expect(aircraft.type).toBe('C5'); + expect(aircraft.subtype).toBe('BOBCAT'); + }); + + test('should spawn C-5 Rhino with RH callsign', async ({ request }) => { + const response = await request.post('/api/allocation/aircraft/spawn', { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + data: { + gameId, + type: 'C5', + subtype: 'RHINO', + teamId, + strength: 10, + rangeHexes: 18, + locationHex: '0xDEF0', + }, + }); + + expect(response.ok()).toBeTruthy(); + const aircraft = await response.json(); + + expect(aircraft.callSign).toMatch(/^RH\d{2,}$/); + expect(aircraft.type).toBe('C5'); + expect(aircraft.subtype).toBe('RHINO'); + }); + + test('should spawn F-16 with VIP callsign', async ({ request }) => { + const response = await request.post('/api/allocation/aircraft/spawn', { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + data: { + gameId, + type: 'F16', + subtype: null, + teamId, + strength: 7, + rangeHexes: 10, + locationHex: '0x1111', + }, + }); + + expect(response.ok()).toBeTruthy(); + const aircraft = await response.json(); + + expect(aircraft.callSign).toMatch(/^VIP\d{2,}$/); + expect(aircraft.type).toBe('F16'); + }); + + test('should spawn F-22 with RPT callsign', async ({ request }) => { + const response = await request.post('/api/allocation/aircraft/spawn', { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + data: { + gameId, + type: 'F22', + subtype: null, + teamId, + strength: 9, + rangeHexes: 12, + locationHex: '0x2222', + }, + }); + + expect(response.ok()).toBeTruthy(); + const aircraft = await response.json(); + + expect(aircraft.callSign).toMatch(/^RPT\d{2,}$/); + expect(aircraft.type).toBe('F22'); + }); + + test('should generate sequential callsigns', async ({ request }) => { + // Spawn two more C-130s + const response1 = await request.post('/api/allocation/aircraft/spawn', { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + data: { + gameId, + type: 'C130', + teamId, + strength: 8, + rangeHexes: 12, + locationHex: '0x3333', + }, + }); + + const response2 = await request.post('/api/allocation/aircraft/spawn', { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + data: { + gameId, + type: 'C130', + teamId, + strength: 8, + rangeHexes: 12, + locationHex: '0x4444', + }, + }); + + const aircraft1 = await response1.json(); + const aircraft2 = await response2.json(); + + // Extract numbers from callsigns + const num1 = parseInt(aircraft1.callSign.replace('AW', '')); + const num2 = parseInt(aircraft2.callSign.replace('AW', '')); + + expect(num2).toBe(num1 + 1); + }); + + test('should reject spawn without required location', async ({ request }) => { + const response = await request.post('/api/allocation/aircraft/spawn', { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + data: { + gameId, + type: 'C130', + teamId, + strength: 8, + rangeHexes: 12, + // Missing locationHex and locationFosId + }, + }); + + expect(response.status()).toBe(400); + }); + + test('should reject spawn for non-GM user', async ({ request }) => { + const response = await request.post('/api/allocation/aircraft/spawn', { + headers: { + 'Authorization': 'Bearer non-gm-token', + }, + data: { + gameId, + type: 'C130', + teamId, + strength: 8, + rangeHexes: 12, + locationHex: '0x5555', + }, + }); + + expect(response.status()).toBe(403); + }); + }); + + test.describe('Aircraft Retrieval', () => { + test('should get all aircraft for a game', async ({ request }) => { + const response = await request.get(`/api/allocation/aircraft/game/${gameId}`, { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + }); + + expect(response.ok()).toBeTruthy(); + const aircraft = await response.json(); + + expect(Array.isArray(aircraft)).toBeTruthy(); + expect(aircraft.length).toBeGreaterThan(0); + + // Verify we have different types + const types = new Set(aircraft.map((a: any) => a.type)); + expect(types.has('C130')).toBeTruthy(); + expect(types.has('C17')).toBeTruthy(); + expect(types.has('C5')).toBeTruthy(); + }); + }); + + test.describe('Direct Allocation', () => { + let allocationCycleId: number; + let mobTeamId: number; + + test.beforeAll(async ({ request }) => { + // Create allocation cycle + const cycleResponse = await request.post('/api/allocation/cycles', { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + data: { + gameId, + turn: 1, + }, + }); + + const cycle = await cycleResponse.json(); + allocationCycleId = cycle.id; + + // Get MOB team + const teamsResponse = await request.get(`/api/game/${gameId}/teams`); + const teams = await teamsResponse.json(); + mobTeamId = teams.find((t: any) => t.type.startsWith('MOB_'))?.id || 2; + }); + + test('should directly allocate aircraft to team', async ({ request }) => { + const response = await request.post('/api/allocation/allocate', { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + data: { + aircraftInstanceId: aircraftId, + allocatedToTeamId: mobTeamId, + allocationCycleId, + }, + }); + + expect(response.ok()).toBeTruthy(); + const allocation = await response.json(); + + expect(allocation).toHaveProperty('id'); + expect(allocation.aircraftInstanceId).toBe(aircraftId); + expect(allocation.allocatedToTeamId).toBe(mobTeamId); + expect(allocation.allocationCycleId).toBe(allocationCycleId); + }); + + test('should not allocate already allocated aircraft', async ({ request }) => { + const response = await request.post('/api/allocation/allocate', { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + data: { + aircraftInstanceId: aircraftId, // Same aircraft + allocatedToTeamId: mobTeamId, + allocationCycleId, + }, + }); + + expect(response.status()).toBe(400); + const error = await response.json(); + expect(error.message).toContain('already allocated'); + }); + + test('should reject allocation for non-CFACC user', async ({ request }) => { + const response = await request.post('/api/allocation/allocate', { + headers: { + 'Authorization': 'Bearer non-cfacc-token', + }, + data: { + aircraftInstanceId: aircraftId, + allocatedToTeamId: mobTeamId, + allocationCycleId, + }, + }); + + expect(response.status()).toBe(403); + }); + }); + + test.describe('Aircraft Deletion', () => { + let deleteableAircraftId: number; + + test.beforeAll(async ({ request }) => { + // Spawn aircraft specifically for deletion test + const response = await request.post('/api/allocation/aircraft/spawn', { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + data: { + gameId, + type: 'C130', + teamId, + strength: 8, + rangeHexes: 12, + locationHex: '0x9999', + }, + }); + + const aircraft = await response.json(); + deleteableAircraftId = aircraft.id; + }); + + test('should delete unallocated aircraft', async ({ request }) => { + const response = await request.delete(`/api/allocation/aircraft/${deleteableAircraftId}`, { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + }); + + expect(response.ok()).toBeTruthy(); + + // Verify deleted + const getResponse = await request.get(`/api/allocation/aircraft/game/${gameId}`, { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + }); + + const aircraft = await getResponse.json(); + const found = aircraft.find((a: any) => a.id === deleteableAircraftId); + expect(found).toBeUndefined(); + }); + + test('should not delete allocated aircraft', async ({ request }) => { + const response = await request.delete(`/api/allocation/aircraft/${aircraftId}`, { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + }); + + expect(response.status()).toBe(400); + const error = await response.json(); + expect(error.message).toContain('allocated'); + }); + + test('should reject deletion for non-GM user', async ({ request }) => { + const response = await request.delete(`/api/allocation/aircraft/999`, { + headers: { + 'Authorization': 'Bearer non-gm-token', + }, + }); + + expect(response.status()).toBe(403); + }); + }); + + test.afterAll(async ({ request }) => { + // Cleanup: Delete test game + if (gameId) { + await request.delete(`/api/game/${gameId}`, { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + }); + } + }); +}); From bd22dc2b9513e7f8960bda5fd9c67bb70c82a74f Mon Sep 17 00:00:00 2001 From: Yuri Sim Date: Tue, 7 Oct 2025 18:15:04 -0500 Subject: [PATCH 14/24] Refactor E2E: Adjust map load test flow --- apps/pac-shield-e2e/src/map-load-test.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/pac-shield-e2e/src/map-load-test.spec.ts b/apps/pac-shield-e2e/src/map-load-test.spec.ts index 64a2b68..d840f5f 100644 --- a/apps/pac-shield-e2e/src/map-load-test.spec.ts +++ b/apps/pac-shield-e2e/src/map-load-test.spec.ts @@ -21,8 +21,11 @@ test('map should load successfully', async ({ page }) => { // Verify no error messages await expect(page.locator('text=Error loading game')).toBeHidden(); + await page.getByRole('button', { name: 'Collapse' }).click(); + + await page.locator('div').filter({ hasText: /^homeKadena$/ }).locator('span').click(); - await page.locator('app-location-panel').getByRole('button').click(); + // await page.locator('app-location-panel').getByRole('button').click(); await page.getByRole('tab', { name: 'FOS' }).click(); await page.getByRole('button', { name: 'Activate' }).click(); await page.getByRole('combobox', { name: 'Assign to Team' }).locator('span').click(); From e2cdbc6a947b8d8470422e547139086c80772d7c Mon Sep 17 00:00:00 2001 From: Yura Sim Date: Wed, 8 Oct 2025 04:11:03 -0500 Subject: [PATCH 15/24] feat(api): Enable conditional API throttling for production --- apps/pac-shield-api/src/app/app.module.ts | 51 ++++++++++--------- .../src/game/game.controller.ts | 11 ++-- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/apps/pac-shield-api/src/app/app.module.ts b/apps/pac-shield-api/src/app/app.module.ts index 44334f2..07fa499 100644 --- a/apps/pac-shield-api/src/app/app.module.ts +++ b/apps/pac-shield-api/src/app/app.module.ts @@ -45,25 +45,27 @@ import { APP_GUARD } from '@nestjs/core'; }), // Schedule module for cron jobs (cleanup) ScheduleModule.forRoot(), - // Rate limiting: More liberal limits for development/testing - // Production: 500 req/sec (burst), 10000 req/min (sustained), 50k req/hour - ThrottlerModule.forRoot([ - { - name: 'burst', - ttl: 1000, // 1 second - limit: 500, // 500 req/sec - supports rapid test execution - }, - { - name: 'sustained', - ttl: 60000, // 1 minute - limit: 10000, // 10,000 req/min - supports extensive e2e tests - }, - { - name: 'hourly', - ttl: 3600000, // 1 hour - limit: 50000, // 50,000 req/hour - }, - ]), + // Rate limiting: Only enabled in production + // Disabled in development and test environments to prevent 429 errors + ...(process.env.NODE_ENV === 'production' ? [ + ThrottlerModule.forRoot([ + { + name: 'burst', + ttl: 1000, // 1 second + limit: 100, // Reasonable burst limit + }, + { + name: 'sustained', + ttl: 60000, // 1 minute + limit: 1000, // Reasonable sustained rate + }, + { + name: 'hourly', + ttl: 3600000, // 1 hour + limit: 10000, // Reasonable hourly limit + }, + ]), + ] : []), PrismaModule, GameModule, AuthModule, @@ -84,10 +86,13 @@ import { APP_GUARD } from '@nestjs/core'; provide: APP_INTERCEPTOR, useClass: LoggingInterceptor, }, - { - provide: APP_GUARD, - useClass: ThrottlerGuard, - }, + // Throttler guard only active in production environment + ...(process.env.NODE_ENV === 'production' ? [ + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, + ] : []), ], }) export class AppModule { } diff --git a/apps/pac-shield-api/src/game/game.controller.ts b/apps/pac-shield-api/src/game/game.controller.ts index 128bacb..d5c847f 100644 --- a/apps/pac-shield-api/src/game/game.controller.ts +++ b/apps/pac-shield-api/src/game/game.controller.ts @@ -17,12 +17,13 @@ export class GameController { constructor( private readonly gameService: GameService, private readonly scoringService: GameScoringService - ) {} + ) { } /** * POST /game/create * Creates a new game and generates a unique 6-char room code. - * Rate limited to 50 games per hour to prevent spam. + * Rate limited to 50 games per hour in production to prevent spam. + * Throttling is disabled in development and test environments. * * @param createGameDto Victory conditions and other init params. * @returns Persisted Game record with id and roomCode @@ -32,7 +33,7 @@ export class GameController { * // Returns: { id: 1, roomCode: "ABC123", ... } */ @Post('create') - @Throttle({ hourly: { ttl: 3600000, limit: 50 } }) // 50 games per hour + @Throttle({ hourly: { ttl: 3600000, limit: 50 } }) // 50 games per hour (only in production) async createGame(@Body() createGameDto: CreateGameDto) { return this.gameService.createGame(createGameDto); } @@ -71,7 +72,7 @@ export class GameController { * POST /game/join * Creates a player in the specified game and returns a session JWT. * Name conflict + PIN resume flow is implemented by the PlayerService. - * Rate limiting is skipped on this endpoint to support 200 simultaneous logins. + * Throttling explicitly skipped to support 200 simultaneous logins. * * @param joinGameDto Payload containing roomCode, playerName, and optional pin for resume * @returns Object containing a signed JWT token and player details @@ -81,7 +82,7 @@ export class GameController { * // Returns: { token: "...", player: { id: 5, name: "Ranger", ... } } */ @Post('join') - @SkipThrottle() // Skip rate limiting to support 200 simultaneous logins + @SkipThrottle() // Explicitly skip throttling for simultaneous logins async joinGame(@Body() joinGameDto: JoinGameDto) { return this.gameService.joinGame(joinGameDto); } From 2e4e4ad365348c087f73ed9511a2ae4405f139a0 Mon Sep 17 00:00:00 2001 From: Yura Sim Date: Wed, 8 Oct 2025 04:11:03 -0500 Subject: [PATCH 16/24] refactor(api-e2e): Refactor JWT and continue game E2E tests --- .../pac-shield-api/jwt-continue-game.spec.ts | 96 +++++++++++-------- 1 file changed, 58 insertions(+), 38 deletions(-) diff --git a/apps/pac-shield-api-e2e/src/pac-shield-api/jwt-continue-game.spec.ts b/apps/pac-shield-api-e2e/src/pac-shield-api/jwt-continue-game.spec.ts index baea44e..2195a46 100644 --- a/apps/pac-shield-api-e2e/src/pac-shield-api/jwt-continue-game.spec.ts +++ b/apps/pac-shield-api-e2e/src/pac-shield-api/jwt-continue-game.spec.ts @@ -24,8 +24,6 @@ import axios, { AxiosError } from 'axios'; describe('JWT and Continue Game API E2E', () => { let gameId: number; let roomCode: string; - let playerToken: string; - let playerId: number; beforeEach(async () => { // Create a fresh game for each test @@ -37,6 +35,16 @@ describe('JWT and Continue Game API E2E', () => { roomCode = createRes.data.roomCode; }); + /** + * Tests the GET /api/game/validate/:roomCode endpoint. + * + * Purpose: Validate room codes before joining a game to provide user-friendly feedback. + * + * Scenarios: + * - Valid room code returns {valid: true, gameId: number} + * - Invalid/non-existent room code returns {valid: false} + * - Malformed room codes (special chars, too short/long) return {valid: false} + */ describe('GET /api/game/validate/:roomCode', () => { it('should validate existing room code', async () => { const res = await axios.get(`/api/game/validate/${roomCode}`); @@ -67,9 +75,27 @@ describe('JWT and Continue Game API E2E', () => { const shortRes = await axios.get(`/api/game/validate/X`); expect(shortRes.status).toBe(200); expect(shortRes.data.valid).toBe(false); + + // Test with long code + const longRes = await axios.get(`/api/game/validate/VERYLONGCODE123`); + expect(longRes.status).toBe(200); + expect(longRes.data.valid).toBe(false); }); }); + /** + * Tests PIN-based player authentication and session resumption. + * + * Purpose: Verify secure player identity management using PINs. + * + * Scenarios: + * - New player joins with PIN -> creates player, returns JWT + * - Existing player name without PIN -> NAME_CONFLICT error + * - Existing player with correct PIN -> returns same player ID, new JWT + * - Existing player with wrong PIN -> INVALID_PIN error + * - Legacy player (no PIN) + attempt with PIN -> NO_PIN_SET error + * - ConflictUser scenario (mirrors frontend E2E test workflow) + */ describe('PIN-based player management', () => { it('should create new player with PIN', async () => { const joinRes = await axios.post(`/api/player/join`, { @@ -83,9 +109,6 @@ describe('JWT and Continue Game API E2E', () => { expect(joinRes.data).toHaveProperty('player'); expect(joinRes.data.player.name).toBe('TestPlayer'); // PIN should be stored in the database - - playerToken = joinRes.data.token; - playerId = joinRes.data.player.id; }); it('should detect name conflict when joining with existing name without PIN', async () => { @@ -223,6 +246,16 @@ describe('JWT and Continue Game API E2E', () => { }); }); + /** + * Tests basic player creation and JWT generation. + * + * Purpose: Verify player creation works with and without PINs, and JWTs are properly formatted. + * + * Scenarios: + * - Create player without PIN (legacy support) + * - Create player with PIN + * - Validate JWT structure (3-part token: header.payload.signature) + */ describe('Player creation and basic functionality', () => { it('should create player without PIN (legacy support)', async () => { const joinRes = await axios.post(`/api/player/join`, { @@ -266,6 +299,15 @@ describe('JWT and Continue Game API E2E', () => { }); }); + /** + * Tests player name isolation across different games. + * + * Purpose: Verify that player names are scoped to individual games, not globally. + * + * Scenarios: + * - Same player name in different games -> creates separate player records + * - No name conflicts detected across different games + */ describe('Multiple games and player isolation', () => { let secondGameRoomCode: string; @@ -319,6 +361,17 @@ describe('JWT and Continue Game API E2E', () => { }); }); + /** + * Tests error handling and edge cases for player join endpoint. + * + * Purpose: Verify robust error handling and validation. + * + * Scenarios: + * - Invalid room code -> 404 Not Found + * - Missing required fields -> 400 Bad Request + * - Empty player name -> 400 Bad Request + * - null/undefined PIN -> treated as no PIN (legacy support) + */ describe('Error handling and edge cases', () => { it('should handle invalid room codes', async () => { try { @@ -373,37 +426,4 @@ describe('JWT and Continue Game API E2E', () => { expect(joinRes.data.player.name).toBe('NoPin Player'); }); }); - - describe('Game validation endpoint comprehensive tests', () => { - it('should validate room codes correctly', async () => { - // Test with valid room code - const validRes = await axios.get(`/api/game/validate/${roomCode}`); - expect(validRes.status).toBe(200); - expect(validRes.data.valid).toBe(true); - expect(validRes.data.gameId).toBe(gameId); - - // Test with invalid room code - const invalidRes = await axios.get(`/api/game/validate/FAKE123`); - expect(invalidRes.status).toBe(200); - expect(invalidRes.data.valid).toBe(false); - expect(invalidRes.data.gameId).toBeUndefined(); - }); - - it('should handle various room code formats', async () => { - // Test with short code - const shortRes = await axios.get(`/api/game/validate/ABC`); - expect(shortRes.status).toBe(200); - expect(shortRes.data.valid).toBe(false); - - // Test with long code - const longRes = await axios.get(`/api/game/validate/VERYLONGCODE123`); - expect(longRes.status).toBe(200); - expect(longRes.data.valid).toBe(false); - - // Test with special characters - const specialRes = await axios.get(`/api/game/validate/ABC-123`); - expect(specialRes.status).toBe(200); - expect(specialRes.data.valid).toBe(false); - }); - }); }); From 1f45f8f11bc94cc6f5c68da9c625507da48bfbf8 Mon Sep 17 00:00:00 2001 From: Yura Sim Date: Wed, 8 Oct 2025 04:11:04 -0500 Subject: [PATCH 17/24] feat(api): Implement aircraft pool upsert logic --- .../allocation/aircraft-pool.service.spec.ts | 7 ++++--- .../app/allocation/aircraft-pool.service.ts | 20 ++++++++++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/pac-shield-api/src/app/allocation/aircraft-pool.service.spec.ts b/apps/pac-shield-api/src/app/allocation/aircraft-pool.service.spec.ts index 6386223..7dbda18 100644 --- a/apps/pac-shield-api/src/app/allocation/aircraft-pool.service.spec.ts +++ b/apps/pac-shield-api/src/app/allocation/aircraft-pool.service.spec.ts @@ -13,6 +13,7 @@ const mockPrismaService = { }, aircraftPool: { create: jest.fn(), + upsert: jest.fn(), findMany: jest.fn(), findUnique: jest.fn(), update: jest.fn(), @@ -227,7 +228,7 @@ describe('AircraftPoolService', () => { // Mock getAircraftPool to return previous turn's pools jest.spyOn(service, 'getAircraftPool').mockResolvedValue(previousPools as any); - prismaService.aircraftPool.create + prismaService.aircraftPool.upsert .mockResolvedValueOnce(newPools[0]) .mockResolvedValueOnce(newPools[1]) .mockResolvedValueOnce(newPools[2]); @@ -235,7 +236,7 @@ describe('AircraftPoolService', () => { const result = await service.processApportionment(gameId, turn, executionBlock); expect(result).toHaveLength(3); - expect(prismaService.aircraftPool.create).toHaveBeenCalledTimes(3); + expect(prismaService.aircraftPool.upsert).toHaveBeenCalledTimes(3); }); it('should handle USTRANSCOM C-5 delivery schedule correctly', async () => { @@ -269,7 +270,7 @@ describe('AircraftPoolService', () => { maintenanceCount: 0, }; - prismaService.aircraftPool.create.mockResolvedValue(expectedC5Pool); + prismaService.aircraftPool.upsert.mockResolvedValue(expectedC5Pool); const result = await service.processApportionment(gameId, turn, executionBlock); diff --git a/apps/pac-shield-api/src/app/allocation/aircraft-pool.service.ts b/apps/pac-shield-api/src/app/allocation/aircraft-pool.service.ts index 6dd5ade..8b564c7 100644 --- a/apps/pac-shield-api/src/app/allocation/aircraft-pool.service.ts +++ b/apps/pac-shield-api/src/app/allocation/aircraft-pool.service.ts @@ -107,9 +107,23 @@ export class AircraftPoolService { // Process random events (maintenance, etc.) newCounts = this.processRandomEvents(newCounts, aircraftType); - // Create new pool entry for this turn - const pool = await this.prisma.aircraftPool.create({ - data: { + // Upsert pool entry for this turn (update if exists, create if not) + const pool = await this.prisma.aircraftPool.upsert({ + where: { + gameId_turn_executionBlock_aircraftType: { + gameId, + turn, + executionBlock, + aircraftType, + }, + }, + update: { + availableCount: newCounts.available, + allocatedCount: newCounts.allocated, + inTransitCount: newCounts.inTransit, + maintenanceCount: newCounts.maintenance, + }, + create: { gameId, turn, executionBlock, From 0da588b61fcb4c82ddedee6230921b1da5c58897 Mon Sep 17 00:00:00 2001 From: Yura Sim Date: Wed, 8 Oct 2025 04:11:04 -0500 Subject: [PATCH 18/24] fix(api): Use sessionId for player lookups in allocation service --- .../src/app/allocation/allocation.service.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/pac-shield-api/src/app/allocation/allocation.service.ts b/apps/pac-shield-api/src/app/allocation/allocation.service.ts index 4cef3e1..9de9aa0 100644 --- a/apps/pac-shield-api/src/app/allocation/allocation.service.ts +++ b/apps/pac-shield-api/src/app/allocation/allocation.service.ts @@ -119,7 +119,7 @@ export class AllocationService { ): Promise { // Verify user has authority (CFACC or GM) const player = await this.prisma.player.findUnique({ - where: { sessionId: user.sub }, + where: { sessionId: user.sessionId }, include: { team: true }, }); @@ -261,7 +261,7 @@ export class AllocationService { async getRequestsForCycle(cycleId: number, user: any): Promise { // Verify user has authority (CFACC or GM) const player = await this.prisma.player.findUnique({ - where: { sessionId: user.sub }, + where: { sessionId: user.sessionId }, include: { team: true }, }); @@ -410,7 +410,7 @@ export class AllocationService { ): Promise { // Verify user has authority (CFACC or GM) const player = await this.prisma.player.findUnique({ - where: { sessionId: user.sub }, + where: { sessionId: user.sessionId }, include: { team: true }, }); @@ -464,7 +464,7 @@ export class AllocationService { ): Promise { // Verify user has authority (CFACC or GM) const player = await this.prisma.player.findUnique({ - where: { sessionId: user.sub }, + where: { sessionId: user.sessionId }, include: { team: true }, }); @@ -548,7 +548,7 @@ export class AllocationService { async deleteAircraftAllocation(allocationId: number, user: any): Promise { // Verify user has authority (CFACC or GM) const player = await this.prisma.player.findUnique({ - where: { sessionId: user.sub }, + where: { sessionId: user.sessionId }, include: { team: true }, }); @@ -638,7 +638,7 @@ export class AllocationService { */ private async validateTeamAccess(teamId: number, user: any): Promise { const player = await this.prisma.player.findUnique({ - where: { sessionId: user.sub }, + where: { sessionId: user.sessionId }, include: { team: true }, }); @@ -677,7 +677,7 @@ export class AllocationService { // Verify GM permissions if (user) { const player = await this.prisma.player.findUnique({ - where: { sessionId: user.sub }, + where: { sessionId: user.sessionId }, }); if (!player || player.role !== PlayerRole.GM) { @@ -768,7 +768,7 @@ export class AllocationService { ): Promise { // Verify GM permissions const player = await this.prisma.player.findUnique({ - where: { sessionId: user.sub }, + where: { sessionId: user.sessionId }, }); if (!player || player.role !== PlayerRole.GM) { @@ -840,7 +840,7 @@ export class AllocationService { ): Promise { // Verify CFACC/GM permissions const player = await this.prisma.player.findUnique({ - where: { sessionId: user.sub }, + where: { sessionId: user.sessionId }, include: { team: true }, }); From 29d61be68f4cf7c0154d29f9d2ae7a63276c4fad Mon Sep 17 00:00:00 2001 From: Yura Sim Date: Wed, 8 Oct 2025 04:11:04 -0500 Subject: [PATCH 19/24] refactor(e2e): Convert Playwright UI tests to API E2E tests --- .../src/aircraft-allocation.spec.ts | 610 ++++++------------ 1 file changed, 215 insertions(+), 395 deletions(-) diff --git a/apps/pac-shield-e2e/src/aircraft-allocation.spec.ts b/apps/pac-shield-e2e/src/aircraft-allocation.spec.ts index d6ccc9c..7963b04 100644 --- a/apps/pac-shield-e2e/src/aircraft-allocation.spec.ts +++ b/apps/pac-shield-e2e/src/aircraft-allocation.spec.ts @@ -1,443 +1,263 @@ import { test, expect } from '@playwright/test'; /** - * E2E Tests for Aircraft Allocation System + * PLACEHOLDER FILE FOR FUTURE PLAYWRIGHT UI E2E TESTS * - * Tests: - * 1. GM spawning aircraft with auto-generated callsigns - * 2. Direct allocation of aircraft to teams - * 3. Real-time WebSocket updates - * 4. ATO button enable/disable based on allocation + * This file previously contained Playwright browser tests that have been converted to API E2E tests. + * The API tests are now located in: apps/pac-shield-api-e2e/src/pac-shield-api/aircraft-allocation.spec.ts + * + * The tests below are placeholders for future Playwright UI tests that should verify the user interface + * and user interactions for the aircraft allocation system. */ -test.describe('Aircraft Allocation System', () => { - let gameId: number; - let teamId: number; - let aircraftId: number; - let authToken: string; - - test.beforeAll(async ({ request }) => { - // Create a test game and authenticate as GM - const gameResponse = await request.post('/api/game', { - data: { - roomCode: `TEST_${Date.now()}`, - victoryConditionMP: 100, - }, +test.describe('Aircraft Allocation System - UI Tests (PLACEHOLDERS)', () => { + + test.describe('GM Aircraft Spawning UI', () => { + // TODO: Playwright test should verify: + // - GM can see and click the "Spawn Aircraft" button in the aircraft management interface + // - Clicking the button opens an aircraft spawn dialog/modal with form fields + // - Form includes dropdown/select for aircraft type (C130, C17, C5, F16, F22) + // - Form includes dropdown/select for aircraft subtype (BOBCAT, RHINO for C-5 only) + // - Form includes input field for location (hex or FOS selection) + // - Form includes input fields for range hexes with reasonable defaults + // - Form shows validation errors when required fields are missing + // - Loading spinner/indicator appears during spawn operation + // - Success notification/toast appears after successful spawn + // - Newly spawned aircraft appears in the aircraft list/table + // - Aircraft card displays correct callsign, type, and status badge + // - Aircraft marker appears on the map at the specified location + // - Visual styling differentiates between aircraft types (cargo vs fighter) + // - C-5 variants (Bobcat/Rhino) have distinct visual indicators + test.skip('GM spawns aircraft via UI and sees visual confirmation', async ({ page }) => { + // Implementation needed }); - expect(gameResponse.ok()).toBeTruthy(); - const game = await gameResponse.json(); - gameId = game.id; - - // Get or create CAOC team - const teamsResponse = await request.get(`/api/game/${gameId}/teams`); - const teams = await teamsResponse.json(); - teamId = teams.find((t: any) => t.type === 'CAOC')?.id || 1; - - // Authenticate (mock - in real scenario, would use actual auth) - authToken = 'mock-gm-token'; - }); - - test.describe('GM Aircraft Spawning', () => { - test('should spawn C-130 with auto-generated AW callsign', async ({ request }) => { - const response = await request.post('/api/allocation/aircraft/spawn', { - headers: { - 'Authorization': `Bearer ${authToken}`, - }, - data: { - gameId, - type: 'C130', - subtype: null, - teamId, - strength: 8, - rangeHexes: 12, - locationHex: '0x1234', - }, - }); - - expect(response.ok()).toBeTruthy(); - const aircraft = await response.json(); - - expect(aircraft).toHaveProperty('id'); - expect(aircraft.callSign).toMatch(/^AW\d{2,}$/); - expect(aircraft.type).toBe('C130'); - expect(aircraft.strength).toBe(8); - expect(aircraft.rangeHexes).toBe(12); - expect(aircraft.allocationStatus).toBe('AVAILABLE'); - - aircraftId = aircraft.id; + // TODO: Playwright test should verify: + // - Auto-generated callsigns display correctly in the UI + // - Callsign format matches expected pattern (AW, ME, BO, RH, VIP, RPT + numbers) + // - Sequential spawning shows incrementing callsign numbers (e.g., AW01, AW02, AW03) + // - No duplicate callsigns appear in the aircraft list + // - Callsign is prominently displayed on aircraft card/tile + // - Callsign appears in map markers/tooltips + test.skip('Auto-generated callsigns display correctly in UI', async ({ page }) => { + // Implementation needed }); - test('should spawn C-17 with auto-generated ME callsign', async ({ request }) => { - const response = await request.post('/api/allocation/aircraft/spawn', { - headers: { - 'Authorization': `Bearer ${authToken}`, - }, - data: { - gameId, - type: 'C17', - subtype: null, - teamId, - strength: 9, - rangeHexes: 15, - locationHex: '0x5678', - }, - }); - - expect(response.ok()).toBeTruthy(); - const aircraft = await response.json(); - - expect(aircraft.callSign).toMatch(/^ME\d{2,}$/); - expect(aircraft.type).toBe('C17'); + // TODO: Playwright test should verify: + // - Non-GM users do not see the "Spawn Aircraft" button + // - Non-GM users cannot access the spawn dialog via any UI path + // - Attempting to navigate to spawn functionality shows permission error + // - UI clearly indicates GM-only features with badges/icons + test.skip('Non-GM users cannot access spawn UI controls', async ({ page }) => { + // Implementation needed }); + }); - test('should spawn C-5 Bobcat with BO callsign', async ({ request }) => { - const response = await request.post('/api/allocation/aircraft/spawn', { - headers: { - 'Authorization': `Bearer ${authToken}`, - }, - data: { - gameId, - type: 'C5', - subtype: 'BOBCAT', - teamId, - strength: 10, - rangeHexes: 18, - locationHex: '0x9ABC', - }, - }); - - expect(response.ok()).toBeTruthy(); - const aircraft = await response.json(); - - expect(aircraft.callSign).toMatch(/^BO\d{2,}$/); - expect(aircraft.type).toBe('C5'); - expect(aircraft.subtype).toBe('BOBCAT'); + test.describe('Aircraft List and Display UI', () => { + // TODO: Playwright test should verify: + // - Aircraft list/grid view displays all spawned aircraft + // - Each aircraft card shows: callsign, type, subtype, status, allocation state + // - Aircraft cards use color coding for different states (available, allocated, in-transit) + // - List supports filtering by aircraft type, status, or allocation state + // - List supports sorting by callsign, type, or spawn time + // - Search functionality filters aircraft by callsign + // - Pagination controls appear for large aircraft lists + // - Real-time updates: new aircraft appear without page refresh + // - Real-time updates: allocation changes update card status immediately + test.skip('Aircraft list displays with correct UI elements and real-time updates', async ({ page }) => { + // Implementation needed }); - test('should spawn C-5 Rhino with RH callsign', async ({ request }) => { - const response = await request.post('/api/allocation/aircraft/spawn', { - headers: { - 'Authorization': `Bearer ${authToken}`, - }, - data: { - gameId, - type: 'C5', - subtype: 'RHINO', - teamId, - strength: 10, - rangeHexes: 18, - locationHex: '0xDEF0', - }, - }); - - expect(response.ok()).toBeTruthy(); - const aircraft = await response.json(); - - expect(aircraft.callSign).toMatch(/^RH\d{2,}$/); - expect(aircraft.type).toBe('C5'); - expect(aircraft.subtype).toBe('RHINO'); + // TODO: Playwright test should verify: + // - Aircraft markers appear on the map at correct coordinates + // - Clicking aircraft marker shows info popup with details + // - Map markers have different icons/colors for different aircraft types + // - Allocated aircraft markers show team assignment visually + // - Hovering over marker highlights corresponding list item + // - Clicking list item centers/highlights map marker + test.skip('Aircraft map markers display and sync with list view', async ({ page }) => { + // Implementation needed }); + }); - test('should spawn F-16 with VIP callsign', async ({ request }) => { - const response = await request.post('/api/allocation/aircraft/spawn', { - headers: { - 'Authorization': `Bearer ${authToken}`, - }, - data: { - gameId, - type: 'F16', - subtype: null, - teamId, - strength: 7, - rangeHexes: 10, - locationHex: '0x1111', - }, - }); - - expect(response.ok()).toBeTruthy(); - const aircraft = await response.json(); - - expect(aircraft.callSign).toMatch(/^VIP\d{2,}$/); - expect(aircraft.type).toBe('F16'); + test.describe('Direct Allocation UI', () => { + // TODO: Playwright test should verify: + // - CFACC/GM can access allocation interface with drag-and-drop capability + // - Aircraft cards are draggable from available pool + // - Team allocation slots highlight when dragging aircraft over them + // - Drop target shows visual feedback (border, background color change) + // - Dropping aircraft onto team slot triggers allocation action + // - Success animation/feedback plays when allocation succeeds + // - Aircraft card moves from available pool to team's allocated section + // - Aircraft status badge updates from "AVAILABLE" to "ALLOCATED" + // - Team's allocated aircraft count increments in real-time + // - Allocation appears in activity log/history + test.skip('Drag and drop allocation provides visual feedback', async ({ page }) => { + // Implementation needed }); - test('should spawn F-22 with RPT callsign', async ({ request }) => { - const response = await request.post('/api/allocation/aircraft/spawn', { - headers: { - 'Authorization': `Bearer ${authToken}`, - }, - data: { - gameId, - type: 'F22', - subtype: null, - teamId, - strength: 9, - rangeHexes: 12, - locationHex: '0x2222', - }, - }); - - expect(response.ok()).toBeTruthy(); - const aircraft = await response.json(); - - expect(aircraft.callSign).toMatch(/^RPT\d{2,}$/); - expect(aircraft.type).toBe('F22'); + // TODO: Playwright test should verify: + // - Allocated aircraft cannot be dragged from team slots + // - Attempting to allocate already-allocated aircraft shows error modal + // - Error modal explains aircraft is unavailable + // - Error modal displays aircraft's current allocation details + // - Allocated aircraft cards have visual indicator (lock icon, different border) + // - Hover tooltip on allocated aircraft shows "Already allocated to [Team Name]" + test.skip('Already allocated aircraft shows appropriate UI feedback', async ({ page }) => { + // Implementation needed }); - test('should generate sequential callsigns', async ({ request }) => { - // Spawn two more C-130s - const response1 = await request.post('/api/allocation/aircraft/spawn', { - headers: { - 'Authorization': `Bearer ${authToken}`, - }, - data: { - gameId, - type: 'C130', - teamId, - strength: 8, - rangeHexes: 12, - locationHex: '0x3333', - }, - }); - - const response2 = await request.post('/api/allocation/aircraft/spawn', { - headers: { - 'Authorization': `Bearer ${authToken}`, - }, - data: { - gameId, - type: 'C130', - teamId, - strength: 8, - rangeHexes: 12, - locationHex: '0x4444', - }, - }); - - const aircraft1 = await response1.json(); - const aircraft2 = await response2.json(); - - // Extract numbers from callsigns - const num1 = parseInt(aircraft1.callSign.replace('AW', '')); - const num2 = parseInt(aircraft2.callSign.replace('AW', '')); - - expect(num2).toBe(num1 + 1); + // TODO: Playwright test should verify: + // - Non-CFACC users see read-only allocation view + // - Drag and drop is disabled for non-CFACC users + // - Allocation buttons/controls are hidden or disabled for non-CFACC + // - Attempting allocation actions shows permission error dialog + // - UI clearly indicates view-only mode with badges/icons + // - Non-CFACC can still view current allocations and history + test.skip('Non-CFACC users have read-only allocation UI', async ({ page }) => { + // Implementation needed }); + }); - test('should reject spawn without required location', async ({ request }) => { - const response = await request.post('/api/allocation/aircraft/spawn', { - headers: { - 'Authorization': `Bearer ${authToken}`, - }, - data: { - gameId, - type: 'C130', - teamId, - strength: 8, - rangeHexes: 12, - // Missing locationHex and locationFosId - }, - }); - - expect(response.status()).toBe(400); + test.describe('Aircraft Deletion UI', () => { + // TODO: Playwright test should verify: + // - GM can see delete/remove button on unallocated aircraft cards + // - Clicking delete button shows confirmation dialog + // - Confirmation dialog displays aircraft details (callsign, type) + // - Confirmation dialog has "Cancel" and "Delete" buttons with clear styling + // - Confirming deletion shows loading indicator + // - Aircraft card fades out and removes from list on successful deletion + // - Success notification appears confirming deletion + // - Deleted aircraft also removes from map markers + test.skip('GM can delete unallocated aircraft with confirmation', async ({ page }) => { + // Implementation needed }); - test('should reject spawn for non-GM user', async ({ request }) => { - const response = await request.post('/api/allocation/aircraft/spawn', { - headers: { - 'Authorization': 'Bearer non-gm-token', - }, - data: { - gameId, - type: 'C130', - teamId, - strength: 8, - rangeHexes: 12, - locationHex: '0x5555', - }, - }); - - expect(response.status()).toBe(403); + // TODO: Playwright test should verify: + // - Delete button is disabled/hidden for allocated aircraft + // - Hovering over disabled delete button shows tooltip explaining why + // - Attempting to delete allocated aircraft shows error dialog + // - Error dialog explains aircraft must be deallocated first + // - Error dialog provides link/button to deallocation interface + // - Allocated aircraft card styling clearly shows it's protected from deletion + test.skip('Allocated aircraft cannot be deleted via UI', async ({ page }) => { + // Implementation needed }); - }); - - test.describe('Aircraft Retrieval', () => { - test('should get all aircraft for a game', async ({ request }) => { - const response = await request.get(`/api/allocation/aircraft/game/${gameId}`, { - headers: { - 'Authorization': `Bearer ${authToken}`, - }, - }); - - expect(response.ok()).toBeTruthy(); - const aircraft = await response.json(); - - expect(Array.isArray(aircraft)).toBeTruthy(); - expect(aircraft.length).toBeGreaterThan(0); - // Verify we have different types - const types = new Set(aircraft.map((a: any) => a.type)); - expect(types.has('C130')).toBeTruthy(); - expect(types.has('C17')).toBeTruthy(); - expect(types.has('C5')).toBeTruthy(); + // TODO: Playwright test should verify: + // - Non-GM users do not see delete buttons on aircraft cards + // - Delete action is not available in context menus for non-GM + // - Non-GM attempting any delete action sees permission error + // - UI differentiates between GM and non-GM views clearly + test.skip('Non-GM users cannot access delete UI controls', async ({ page }) => { + // Implementation needed }); }); - test.describe('Direct Allocation', () => { - let allocationCycleId: number; - let mobTeamId: number; - - test.beforeAll(async ({ request }) => { - // Create allocation cycle - const cycleResponse = await request.post('/api/allocation/cycles', { - headers: { - 'Authorization': `Bearer ${authToken}`, - }, - data: { - gameId, - turn: 1, - }, - }); - - const cycle = await cycleResponse.json(); - allocationCycleId = cycle.id; - - // Get MOB team - const teamsResponse = await request.get(`/api/game/${gameId}/teams`); - const teams = await teamsResponse.json(); - mobTeamId = teams.find((t: any) => t.type.startsWith('MOB_'))?.id || 2; + test.describe('Real-time Updates and WebSocket UI', () => { + // TODO: Playwright test should verify: + // - When GM spawns aircraft in one browser, it appears in other users' views + // - Real-time update shows visual animation (fade in, highlight) + // - New aircraft notification/toast appears for other users + // - Aircraft count badges update in real-time across all clients + // - Map markers update in real-time when new aircraft are spawned + test.skip('Aircraft spawning updates all connected clients in real-time', async ({ page }) => { + // Implementation needed }); - test('should directly allocate aircraft to team', async ({ request }) => { - const response = await request.post('/api/allocation/allocate', { - headers: { - 'Authorization': `Bearer ${authToken}`, - }, - data: { - aircraftInstanceId: aircraftId, - allocatedToTeamId: mobTeamId, - allocationCycleId, - }, - }); - - expect(response.ok()).toBeTruthy(); - const allocation = await response.json(); - - expect(allocation).toHaveProperty('id'); - expect(allocation.aircraftInstanceId).toBe(aircraftId); - expect(allocation.allocatedToTeamId).toBe(mobTeamId); - expect(allocation.allocationCycleId).toBe(allocationCycleId); + // TODO: Playwright test should verify: + // - When aircraft is allocated, all clients see the status change + // - Allocated aircraft moves visually from available to allocated section + // - Team allocation counts update in real-time for all users + // - Notification shows which team received the allocation + // - Activity feed/log updates for all connected users + test.skip('Aircraft allocation updates all clients in real-time', async ({ page }) => { + // Implementation needed }); - test('should not allocate already allocated aircraft', async ({ request }) => { - const response = await request.post('/api/allocation/allocate', { - headers: { - 'Authorization': `Bearer ${authToken}`, - }, - data: { - aircraftInstanceId: aircraftId, // Same aircraft - allocatedToTeamId: mobTeamId, - allocationCycleId, - }, - }); - - expect(response.status()).toBe(400); - const error = await response.json(); - expect(error.message).toContain('already allocated'); + // TODO: Playwright test should verify: + // - When aircraft is deleted, it disappears from all users' views + // - Deletion animation (fade out) plays for all connected clients + // - Aircraft count decrements in real-time across all clients + // - Map marker removes in real-time for all users + // - Notification informs users of aircraft removal + test.skip('Aircraft deletion updates all clients in real-time', async ({ page }) => { + // Implementation needed }); - test('should reject allocation for non-CFACC user', async ({ request }) => { - const response = await request.post('/api/allocation/allocate', { - headers: { - 'Authorization': 'Bearer non-cfacc-token', - }, - data: { - aircraftInstanceId: aircraftId, - allocatedToTeamId: mobTeamId, - allocationCycleId, - }, - }); - - expect(response.status()).toBe(403); + // TODO: Playwright test should verify: + // - Connection status indicator shows when WebSocket is connected/disconnected + // - Reconnection attempts show loading/retry indicator + // - Lost connection shows warning banner to users + // - Successful reconnection syncs latest state and shows confirmation + // - During connection loss, UI indicates read-only/offline mode + test.skip('WebSocket connection status provides clear visual feedback', async ({ page }) => { + // Implementation needed }); }); - test.describe('Aircraft Deletion', () => { - let deleteableAircraftId: number; - - test.beforeAll(async ({ request }) => { - // Spawn aircraft specifically for deletion test - const response = await request.post('/api/allocation/aircraft/spawn', { - headers: { - 'Authorization': `Bearer ${authToken}`, - }, - data: { - gameId, - type: 'C130', - teamId, - strength: 8, - rangeHexes: 12, - locationHex: '0x9999', - }, - }); - - const aircraft = await response.json(); - deleteableAircraftId = aircraft.id; + test.describe('ATO Button State Based on Allocation', () => { + // TODO: Playwright test should verify: + // - ATO (Air Tasking Order) button is disabled when no aircraft are allocated + // - Disabled button shows tooltip explaining why it's disabled + // - ATO button becomes enabled when at least one aircraft is allocated + // - Enabled button visual styling changes (color, cursor) + // - ATO button state updates in real-time as allocations change + // - Clicking enabled ATO button opens ATO planning interface + // - ATO interface shows list of allocated aircraft available for planning + test.skip('ATO button enable/disable based on allocation status', async ({ page }) => { + // Implementation needed }); + }); - test('should delete unallocated aircraft', async ({ request }) => { - const response = await request.delete(`/api/allocation/aircraft/${deleteableAircraftId}`, { - headers: { - 'Authorization': `Bearer ${authToken}`, - }, - }); - - expect(response.ok()).toBeTruthy(); - - // Verify deleted - const getResponse = await request.get(`/api/allocation/aircraft/game/${gameId}`, { - headers: { - 'Authorization': `Bearer ${authToken}`, - }, - }); - - const aircraft = await getResponse.json(); - const found = aircraft.find((a: any) => a.id === deleteableAircraftId); - expect(found).toBeUndefined(); + test.describe('Form Validation and Error Handling UI', () => { + // TODO: Playwright test should verify: + // - Required field indicators (asterisks) show on form fields + // - Submitting form with missing fields shows inline validation errors + // - Error messages appear below each invalid field + // - Invalid fields have red border or error styling + // - Form cannot be submitted while validation errors exist + // - Submit button is disabled until all required fields are valid + // - Validation errors clear when user corrects the input + // - Success message clears previous error messages + test.skip('Form validation provides clear visual feedback', async ({ page }) => { + // Implementation needed }); - test('should not delete allocated aircraft', async ({ request }) => { - const response = await request.delete(`/api/allocation/aircraft/${aircraftId}`, { - headers: { - 'Authorization': `Bearer ${authToken}`, - }, - }); - - expect(response.status()).toBe(400); - const error = await response.json(); - expect(error.message).toContain('allocated'); + // TODO: Playwright test should verify: + // - Network errors show user-friendly error dialog + // - Error dialog explains what went wrong in plain language + // - Error dialog provides retry button for transient errors + // - Error dialog provides contact/support information for persistent errors + // - Loading states prevent duplicate submissions + // - Timeout errors show specific timeout message + test.skip('API errors display user-friendly error dialogs', async ({ page }) => { + // Implementation needed }); + }); - test('should reject deletion for non-GM user', async ({ request }) => { - const response = await request.delete(`/api/allocation/aircraft/999`, { - headers: { - 'Authorization': 'Bearer non-gm-token', - }, - }); - - expect(response.status()).toBe(403); + test.describe('Accessibility and Responsive Design', () => { + // TODO: Playwright test should verify: + // - All interactive elements are keyboard accessible + // - Tab order follows logical reading flow + // - Focus indicators are clearly visible + // - Screen reader announces important state changes + // - ARIA labels are present on all interactive controls + // - Color contrast meets WCAG AA standards + // - Error messages are associated with form fields for screen readers + test.skip('UI meets accessibility requirements', async ({ page }) => { + // Implementation needed }); - }); - test.afterAll(async ({ request }) => { - // Cleanup: Delete test game - if (gameId) { - await request.delete(`/api/game/${gameId}`, { - headers: { - 'Authorization': `Bearer ${authToken}`, - }, - }); - } + // TODO: Playwright test should verify: + // - Aircraft list displays correctly on mobile devices + // - Drag and drop works on touch devices + // - Dialogs/modals are properly sized for small screens + // - Map controls are touch-friendly + // - Navigation menus collapse appropriately on small screens + // - Text remains readable at all viewport sizes + test.skip('UI is responsive across device sizes', async ({ page }) => { + // Implementation needed + }); }); }); From 8e31b0fe70e35bf1cfea45359b51203b92be04fb Mon Sep 17 00:00:00 2001 From: Yura Sim Date: Wed, 8 Oct 2025 04:11:04 -0500 Subject: [PATCH 20/24] feat(ui): Implement autocomplete for aircraft spawn locations --- .../aircraft-spawn-dialog.component.ts | 194 +++++++++++++++++- 1 file changed, 186 insertions(+), 8 deletions(-) diff --git a/apps/pac-shield/src/app/features/game/dialogs/aircraft-spawn-dialog/aircraft-spawn-dialog.component.ts b/apps/pac-shield/src/app/features/game/dialogs/aircraft-spawn-dialog/aircraft-spawn-dialog.component.ts index 260a31b..d06c520 100644 --- a/apps/pac-shield/src/app/features/game/dialogs/aircraft-spawn-dialog/aircraft-spawn-dialog.component.ts +++ b/apps/pac-shield/src/app/features/game/dialogs/aircraft-spawn-dialog/aircraft-spawn-dialog.component.ts @@ -1,4 +1,4 @@ -import { Component, inject } from '@angular/core'; +import { Component, inject, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; @@ -7,10 +7,16 @@ import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { HttpClient } from '@angular/common/http'; +import { Store } from '@ngrx/store'; +import { Observable, Subject } from 'rxjs'; +import { filter, map, startWith, take, takeUntil } from 'rxjs/operators'; import { AircraftType } from '../../../../generated/enums'; import { Team } from '../../../../generated/team/team.entity'; import { environment } from '../../../../../environments/environment'; +import { FOS_LOCATIONS, MOB_LOCATIONS } from '../../../../shared/config/static-locations.config'; +import { selectHexGrid } from '../../../../core/store/game/game.selectors'; export interface AircraftSpawnDialogData { gameId: number; @@ -27,6 +33,17 @@ export interface AircraftSpawnResult { locationHex?: string; } +interface LocationOption { + /** Backend value (e.g., 'Kadena AB', 'FOS 7', '505A') */ + value: string; + /** Frontend display alias (e.g., 'Kadena Air Base', 'FOS 7 - Philippines', 'Hex 505A') */ + displayName: string; + /** Location type for filtering */ + type: 'MOB' | 'FOS' | 'Hex'; + /** Country for additional context */ + country: string; +} + /** * Dialog for GM to spawn new aircraft instances */ @@ -42,6 +59,7 @@ export interface AircraftSpawnResult { MatSelectModule, MatButtonModule, MatIconModule, + MatAutocompleteModule, ], template: `

    @@ -89,22 +107,60 @@ export interface AircraftSpawnResult {
    Starting Location
    - FOS/MOB ID - - Forward Operating Site identifier + FOS/MOB Location + + + @for (option of filteredLocationFosOptions$ | async; track option.value) { + +
    + {{ option.displayName }} + {{ option.type }} +
    +
    + } +
    + location_on + Select a Main Operating Base or Forward Operating Site
    OR
    Hex Coordinate - - Hex grid coordinate + + + @for (option of filteredLocationHexOptions$ | async; track option.value) { + +
    + {{ option.displayName }} + {{ option.type }} +
    +
    + } +
    + grid_on + Select a hex grid coordinate
    @if (spawnForm.hasError('locationRequired')) {
    - Either FOS ID or Hex coordinate is required + Either FOS/MOB location or Hex coordinate is required
    } @@ -153,17 +209,26 @@ export interface AircraftSpawnResult { } `] }) -export class AircraftSpawnDialogComponent { +export class AircraftSpawnDialogComponent implements OnDestroy { private fb = inject(FormBuilder); private http = inject(HttpClient); private dialogRef = inject(MatDialogRef); + private store = inject(Store); readonly data: AircraftSpawnDialogData = inject(MAT_DIALOG_DATA); isSpawning = false; spawnForm: FormGroup; + // Location autocomplete data + allLocationOptions: LocationOption[] = []; + filteredLocationFosOptions$: Observable = new Observable; + filteredLocationHexOptions$: Observable = new Observable; + + private destroy$ = new Subject(); + constructor() { + this.initializeLocationOptions(); this.spawnForm = this.fb.group({ type: ['C130', Validators.required], subtype: [null], @@ -178,6 +243,14 @@ export class AircraftSpawnDialogComponent { if (this.data.teams.length > 0) { this.spawnForm.patchValue({ teamId: this.data.teams[0].id }); } + + this.setupLocationAutocomplete(); + this.loadHexLocations(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); } /** @@ -246,4 +319,109 @@ export class AircraftSpawnDialogComponent { onCancel(): void { this.dialogRef.close(); } + + /** + * Initialize location options from static configuration + */ + private initializeLocationOptions(): void { + this.allLocationOptions = [ + // MOB locations with backend-compatible names + ...Object.entries(MOB_LOCATIONS).map(([id, location]) => ({ + value: this.getMOBBackendValue(id), + displayName: `${location.name} Air Base - ${location.country}`, + type: 'MOB' as const, + country: location.country, + })), + // FOS locations + ...Object.entries(FOS_LOCATIONS).map(([, location]) => ({ + value: location.name, + displayName: `${location.name} - ${location.country}`, + type: 'FOS' as const, + country: location.country, + })), + ]; + } + + /** + * Load hex locations from the game store + */ + private loadHexLocations(): void { + this.store.select(selectHexGrid).pipe( + filter((hexGrid): hexGrid is Record => hexGrid !== null), + take(1), + takeUntil(this.destroy$) + ).subscribe(hexGrid => { + const hexLocationOptions: LocationOption[] = Object.entries(hexGrid).map(([, visualCoord]) => ({ + value: visualCoord, + displayName: `Hex ${visualCoord}`, + type: 'Hex', + country: '', + })); + + this.allLocationOptions = [...this.allLocationOptions, ...hexLocationOptions]; + }); + } + + /** + * Map MOB IDs to backend values that match existing patterns + */ + private getMOBBackendValue(mobId: string): string { + const mobBackendMap: Record = { + kadena: 'Kadena AB', + andersen: 'Andersen AFB', + yokota: 'Yokota AB', + osan: 'Osan AB', + jbphh: 'JBPHH', + }; + return mobBackendMap[mobId] || MOB_LOCATIONS[mobId]?.name || mobId; + } + + /** + * Setup autocomplete filtering for location fields + */ + private setupLocationAutocomplete(): void { + const locationFosIdControl = this.spawnForm.get('locationFosId'); + const locationHexControl = this.spawnForm.get('locationHex'); + + if (locationFosIdControl) { + this.filteredLocationFosOptions$ = locationFosIdControl.valueChanges.pipe( + startWith(''), + map(value => this.filterLocations(value, ['MOB', 'FOS'])) + ); + } + + if (locationHexControl) { + this.filteredLocationHexOptions$ = locationHexControl.valueChanges.pipe( + startWith(''), + map(value => this.filterLocations(value, ['Hex'])) + ); + } + } + + /** + * Filter locations by search value and allowed types + */ + private filterLocations(value: string | null, allowedTypes: Array<'MOB' | 'FOS' | 'Hex'>): LocationOption[] { + const filtered = this.allLocationOptions.filter(opt => allowedTypes.includes(opt.type)); + + if (!value || typeof value !== 'string') { + return filtered; + } + + const filterValue = value.toLowerCase(); + return filtered.filter(option => + option.value.toLowerCase().includes(filterValue) || + option.displayName.toLowerCase().includes(filterValue) || + option.country.toLowerCase().includes(filterValue) + ); + } + + /** + * Display function for autocomplete - shows display name + */ + displayLocationFn = (value: string): string => { + if (!value) return ''; + const option = this.allLocationOptions.find(opt => opt.value === value); + return option ? option.displayName : value; + }; } From 83dc847177389d8b453f2df8a3f54670c70c069c Mon Sep 17 00:00:00 2001 From: Yura Sim Date: Wed, 8 Oct 2025 04:11:04 -0500 Subject: [PATCH 21/24] feat(ui): Enhance GM player management with role & team submenus --- .../all-players-tab.component.ts | 8 +++- .../gm-player-menus.component.ts | 4 +- .../app/features/lobby/lobby.component.html | 17 +++++--- .../src/app/features/lobby/lobby.component.ts | 13 ------ .../player-card/player-card.component.ts | 38 +++++++++++++--- .../lobby/team-card/team-card.component.ts | 9 +++- .../members-list/members-list.component.ts | 43 ++++++++++++++++--- .../team-list/team-list.component.ts | 7 ++- .../lobby/teams-tab/teams-tab.component.ts | 8 +++- .../unassigned-tab.component.ts | 8 +++- 10 files changed, 112 insertions(+), 43 deletions(-) diff --git a/apps/pac-shield/src/app/features/lobby/all-players-tab/all-players-tab.component.ts b/apps/pac-shield/src/app/features/lobby/all-players-tab/all-players-tab.component.ts index 35d8f7e..01de7e7 100644 --- a/apps/pac-shield/src/app/features/lobby/all-players-tab/all-players-tab.component.ts +++ b/apps/pac-shield/src/app/features/lobby/all-players-tab/all-players-tab.component.ts @@ -40,6 +40,8 @@ import { Player, Team } from '../../../generated'; variant="list" [showGMMenu]="showGMTools" [allTeams]="allTeams" + [playerRoles]="playerRoles" + [getTeamTypeInfo]="getTeamTypeInfo" (changeRole)="changeRole.emit($event)" (moveToTeam)="moveToTeam.emit($event)" (removeFromTeam)="removeFromTeam.emit($event)" @@ -65,6 +67,8 @@ export class AllPlayersTabComponent { @Input() filteredPlayers: Player[] = []; @Input() allTeams: Team[] = []; @Input() showGMTools = false; + @Input() playerRoles: string[] = ['GM', 'COMMANDER', 'DEPUTY', 'LNO', 'PLAYER']; + @Input() getTeamTypeInfo!: (team: Team) => { icon: string; color: string }; @Input() filters: FilterOptions = { searchTerm: '', filterTeamType: 'ALL', @@ -75,8 +79,8 @@ export class AllPlayersTabComponent { }; @Output() filtersChange = new EventEmitter(); - @Output() changeRole = new EventEmitter(); - @Output() moveToTeam = new EventEmitter(); + @Output() changeRole = new EventEmitter<{player: Player, role: string}>(); + @Output() moveToTeam = new EventEmitter<{player: Player, team: Team}>(); @Output() removeFromTeam = new EventEmitter(); @Output() removeFromGame = new EventEmitter(); diff --git a/apps/pac-shield/src/app/features/lobby/gm-player-menus/gm-player-menus.component.ts b/apps/pac-shield/src/app/features/lobby/gm-player-menus/gm-player-menus.component.ts index edafd94..3eb25ae 100644 --- a/apps/pac-shield/src/app/features/lobby/gm-player-menus/gm-player-menus.component.ts +++ b/apps/pac-shield/src/app/features/lobby/gm-player-menus/gm-player-menus.component.ts @@ -16,9 +16,9 @@ import { Player, Team } from '../../../generated'; ], template: ` - + -
    {{ player.name }}
    +
    {{ player.name }}
    - @@ -123,6 +123,32 @@ import { Player, Team } from '../../../generated';
    + + + + + @for (role of playerRoles; track role) { + + } + + + + + + + @for (team of teams; track team.id) { + + } + + ` }) export class PlayerCardComponent { @@ -130,9 +156,11 @@ export class PlayerCardComponent { @Input() variant: 'simple' | 'list' = 'simple'; @Input() showGMMenu = false; @Input() allTeams: Team[] = []; + @Input() playerRoles: string[] = ['GM', 'COMMANDER', 'DEPUTY', 'LNO', 'PLAYER']; + @Input() getTeamTypeInfo!: (team: Team) => { icon: string; color: string }; - @Output() changeRole = new EventEmitter(); - @Output() moveToTeam = new EventEmitter(); + @Output() changeRole = new EventEmitter<{player: Player, role: string}>(); + @Output() moveToTeam = new EventEmitter<{player: Player, team: Team}>(); @Output() removeFromTeam = new EventEmitter(); @Output() removeFromGame = new EventEmitter(); diff --git a/apps/pac-shield/src/app/features/lobby/team-card/team-card.component.ts b/apps/pac-shield/src/app/features/lobby/team-card/team-card.component.ts index d7432dc..10e67b8 100644 --- a/apps/pac-shield/src/app/features/lobby/team-card/team-card.component.ts +++ b/apps/pac-shield/src/app/features/lobby/team-card/team-card.component.ts @@ -84,6 +84,9 @@ export interface RoleGroup { [color]="teamTypeInfo.color" [showGMTools]="showGMTools" [dense]="dense" + [allTeams]="allTeams" + [playerRoles]="playerRoles" + [getTeamTypeInfo]="getTeamTypeInfo" (changeRole)="changeRole.emit($event)" (moveToTeam)="moveToTeam.emit($event)" (removeFromTeam)="removeFromTeam.emit($event)" @@ -122,12 +125,14 @@ export class TeamCardComponent { @Input() showGMTools = false; @Input() dense = false; @Input() unassignedCount = 0; + @Input() playerRoles: string[] = ['GM', 'COMMANDER', 'DEPUTY', 'LNO', 'PLAYER']; + @Input() getTeamTypeInfo!: (team: Team) => { icon: string; color: string }; @Output() joinTeam = new EventEmitter(); @Output() assignOneUnassigned = new EventEmitter(); @Output() toggleLock = new EventEmitter(); - @Output() changeRole = new EventEmitter(); - @Output() moveToTeam = new EventEmitter(); + @Output() changeRole = new EventEmitter<{player: Player, role: string}>(); + @Output() moveToTeam = new EventEmitter<{player: Player, team: Team}>(); @Output() removeFromTeam = new EventEmitter(); @Output() removeFromGame = new EventEmitter(); diff --git a/apps/pac-shield/src/app/features/lobby/teams-tab/components/members-list/members-list.component.ts b/apps/pac-shield/src/app/features/lobby/teams-tab/components/members-list/members-list.component.ts index 230df31..69d6156 100644 --- a/apps/pac-shield/src/app/features/lobby/teams-tab/components/members-list/members-list.component.ts +++ b/apps/pac-shield/src/app/features/lobby/teams-tab/components/members-list/members-list.component.ts @@ -4,7 +4,7 @@ import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { MatDividerModule } from '@angular/material/divider'; import { MatButtonModule } from '@angular/material/button'; -import { Player } from '../../../../../generated'; +import { Player, Team } from '../../../../../generated'; export interface RoleGroup { role: string; @@ -49,7 +49,7 @@ export interface RoleGroup { - @@ -93,6 +93,32 @@ export interface RoleGroup {
    + + + + + @for (role of playerRoles; track role) { + + } + + + + + + + @for (team of teams; track team.id) { + + } + + ` }) export class MembersListComponent { @@ -100,9 +126,12 @@ export class MembersListComponent { @Input() color = 'var(--mat-sys-primary)'; // derived from teamTypeInfo.color by parent @Input() showGMTools = false; @Input() dense = false; + @Input() allTeams: Team[] = []; + @Input() playerRoles: string[] = ['GM', 'COMMANDER', 'DEPUTY', 'LNO', 'PLAYER']; + @Input() getTeamTypeInfo!: (team: Team) => { icon: string; color: string }; - @Output() changeRole = new EventEmitter(); - @Output() moveToTeam = new EventEmitter(); + @Output() changeRole = new EventEmitter<{player: Player, role: string}>(); + @Output() moveToTeam = new EventEmitter<{player: Player, team: Team}>(); @Output() removeFromTeam = new EventEmitter(); @Output() removeFromGame = new EventEmitter(); } diff --git a/apps/pac-shield/src/app/features/lobby/teams-tab/components/team-list/team-list.component.ts b/apps/pac-shield/src/app/features/lobby/teams-tab/components/team-list/team-list.component.ts index c00ca98..0d3fabb 100644 --- a/apps/pac-shield/src/app/features/lobby/teams-tab/components/team-list/team-list.component.ts +++ b/apps/pac-shield/src/app/features/lobby/teams-tab/components/team-list/team-list.component.ts @@ -20,6 +20,8 @@ import { Player, Team } from '../../../../../generated'; [showGMTools]="showGMTools" [dense]="dense" [unassignedCount]="unassignedCount" + [playerRoles]="playerRoles" + [getTeamTypeInfo]="getTeamTypeInfo" (joinTeam)="onJoinTeam(team)" (assignOneUnassigned)="onAssignOne(team)" (toggleLock)="toggleLock.emit(team)" @@ -39,6 +41,7 @@ export class TeamListComponent { @Input() showGMTools = false; @Input() dense = false; @Input() unassignedCount = 0; + @Input() playerRoles: string[] = ['GM', 'COMMANDER', 'DEPUTY', 'LNO', 'PLAYER']; // Allow TeamsTab container to control layout differences per category @Input() gridClass = 'grid grid-cols-1 md:grid-cols-2 gap-4'; @@ -52,8 +55,8 @@ export class TeamListComponent { @Output() assignOneUnassigned = new EventEmitter(); @Output() toggleLock = new EventEmitter(); - @Output() changeRole = new EventEmitter(); - @Output() moveToTeam = new EventEmitter(); + @Output() changeRole = new EventEmitter<{player: Player, role: string}>(); + @Output() moveToTeam = new EventEmitter<{player: Player, team: Team}>(); @Output() removeFromTeam = new EventEmitter(); @Output() removeFromGame = new EventEmitter(); diff --git a/apps/pac-shield/src/app/features/lobby/teams-tab/teams-tab.component.ts b/apps/pac-shield/src/app/features/lobby/teams-tab/teams-tab.component.ts index 05e3212..9f3e9c2 100644 --- a/apps/pac-shield/src/app/features/lobby/teams-tab/teams-tab.component.ts +++ b/apps/pac-shield/src/app/features/lobby/teams-tab/teams-tab.component.ts @@ -46,6 +46,7 @@ import { Team, Player } from '../../../generated'; [showGMTools]="showGMTools" [dense]="filters.dense" [unassignedCount]="unassignedCount" + [playerRoles]="playerRoles" [gridClass]="'grid grid-cols-1 lg:grid-cols-2 gap-4'" [getTeamTypeInfo]="getTeamTypeInfo" [groupPlayersByRole]="groupPlayersByRole" @@ -77,6 +78,7 @@ import { Team, Player } from '../../../generated'; [showGMTools]="showGMTools" [dense]="filters.dense" [unassignedCount]="unassignedCount" + [playerRoles]="playerRoles" [gridClass]="'grid grid-cols-1 md:grid-cols-2 gap-4'" [getTeamTypeInfo]="getTeamTypeInfo" [groupPlayersByRole]="groupPlayersByRole" @@ -108,6 +110,7 @@ import { Team, Player } from '../../../generated'; [showGMTools]="showGMTools" [dense]="filters.dense" [unassignedCount]="unassignedCount" + [playerRoles]="playerRoles" [gridClass]="'grid grid-cols-1 md:grid-cols-2 gap-4'" [getTeamTypeInfo]="getTeamTypeInfo" [groupPlayersByRole]="groupPlayersByRole" @@ -130,6 +133,7 @@ export class TeamsTabComponent { @Input() currentPlayer?: Player; @Input() showGMTools = false; @Input() unassignedCount = 0; + @Input() playerRoles: string[] = ['GM', 'COMMANDER', 'DEPUTY', 'LNO', 'PLAYER']; @Input() filters: FilterOptions = { searchTerm: '', filterTeamType: 'ALL', @@ -145,8 +149,8 @@ export class TeamsTabComponent { @Output() joinTeam = new EventEmitter(); @Output() assignOneUnassigned = new EventEmitter(); @Output() toggleTeamLock = new EventEmitter(); - @Output() changeRole = new EventEmitter(); - @Output() moveToTeam = new EventEmitter(); + @Output() changeRole = new EventEmitter<{player: Player, role: string}>(); + @Output() moveToTeam = new EventEmitter<{player: Player, team: Team}>(); @Output() removeFromTeam = new EventEmitter(); @Output() removeFromGame = new EventEmitter(); diff --git a/apps/pac-shield/src/app/features/lobby/unassigned-tab/unassigned-tab.component.ts b/apps/pac-shield/src/app/features/lobby/unassigned-tab/unassigned-tab.component.ts index 3dd5659..38593bd 100644 --- a/apps/pac-shield/src/app/features/lobby/unassigned-tab/unassigned-tab.component.ts +++ b/apps/pac-shield/src/app/features/lobby/unassigned-tab/unassigned-tab.component.ts @@ -37,6 +37,8 @@ import { Player, Team } from '../../../generated'; variant="simple" [showGMMenu]="showGMTools" [allTeams]="allTeams" + [playerRoles]="playerRoles" + [getTeamTypeInfo]="getTeamTypeInfo" (changeRole)="changeRole.emit($event)" (moveToTeam)="moveToTeam.emit($event)" (removeFromTeam)="removeFromTeam.emit($event)" @@ -66,6 +68,8 @@ export class UnassignedTabComponent { @Input() filteredUnassignedPlayers: Player[] = []; @Input() allTeams: Team[] = []; @Input() showGMTools = false; + @Input() playerRoles: string[] = ['GM', 'COMMANDER', 'DEPUTY', 'LNO', 'PLAYER']; + @Input() getTeamTypeInfo!: (team: Team) => { icon: string; color: string }; @Input() filters: FilterOptions = { searchTerm: '', filterTeamType: 'ALL', @@ -76,8 +80,8 @@ export class UnassignedTabComponent { }; @Output() filtersChange = new EventEmitter(); - @Output() changeRole = new EventEmitter(); - @Output() moveToTeam = new EventEmitter(); + @Output() changeRole = new EventEmitter<{player: Player, role: string}>(); + @Output() moveToTeam = new EventEmitter<{player: Player, team: Team}>(); @Output() removeFromTeam = new EventEmitter(); @Output() removeFromGame = new EventEmitter(); From 69c7c85acd63b9e5d1ed1cc48d55220195113986 Mon Sep 17 00:00:00 2001 From: Yura Sim Date: Wed, 8 Oct 2025 04:11:04 -0500 Subject: [PATCH 22/24] chore(claude): Add new jest commands to Claude settings --- .claude/settings.local.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e843086..1b5e8ea 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -40,7 +40,12 @@ "mcp__playwright__browser_navigate", "mcp__playwright__browser_install", "Bash(npx playwright:*)", - "Bash(cat:*)" + "Bash(cat:*)", + "Bash(BASE_URL=http://localhost:3001 npx jest aircraft-allocation.spec.ts --runInBand --verbose)", + "Bash(PORT=3001 npx jest aircraft-allocation.spec.ts --runInBand)", + "Bash(set PORT=3001)", + "Bash(PORT=3001 npx jest aircraft-allocation.spec.ts --testNamePattern=\"should spawn C-130\" --runInBand --verbose)", + "Bash(npx ng build:*)" ], "deny": [], "ask": [] From 8876e2da6d7aaca74fa59cc402d891b7a27fee99 Mon Sep 17 00:00:00 2001 From: Yura Sim Date: Wed, 8 Oct 2025 04:11:04 -0500 Subject: [PATCH 23/24] docs(e2e): Update CLAUDE.md with E2E testing notes --- CLAUDE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 807cbaf..cd53a0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,16 @@ npx nx prisma-db-push pac-shield-api # Testing & Quality npx nx test pac-shield npx nx lint pac-shield + +# E2E Testing +npx nx e2e pac-shield-e2e # Playwright E2E tests +cd apps/pac-shield-api-e2e && npx jest # API E2E tests (requires pac-shield-api on port 3000) +``` + +### ๐Ÿงช E2E Testing Notes +- **API E2E tests** (`apps/pac-shield-api-e2e`) assume `pac-shield-api` is running on **port 3000** +- **NEVER kill port 3000** during API E2E test runs - tests expect the server to be running +- Run `npx nx serve pac-shield-api` in a separate terminal before running API E2E tests ``` ## ๐Ÿšจ CRITICAL RULES From aa88123a79bb8b9cc5988628bc4c49f29060b91b Mon Sep 17 00:00:00 2001 From: Yura Sim Date: Wed, 8 Oct 2025 04:11:04 -0500 Subject: [PATCH 24/24] chore(e2e): Update global-setup message for API E2E tests --- apps/pac-shield-api-e2e/src/support/global-setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/pac-shield-api-e2e/src/support/global-setup.ts b/apps/pac-shield-api-e2e/src/support/global-setup.ts index 8a49540..e7273c2 100644 --- a/apps/pac-shield-api-e2e/src/support/global-setup.ts +++ b/apps/pac-shield-api-e2e/src/support/global-setup.ts @@ -58,7 +58,7 @@ module.exports = async function () { // If port is already in use, assume API is running and skip any startup. const inUse = await isPortInUse(port, host, 1000); if (inUse) { - console.log(`[api-e2e] Port ${port} is in use; skipping server startup.`); + console.log(`[api-e2e] skipping ${port} server startup, alrready up.`); // Teardown remains safe: we did not start anything, so nothing to kill. globalThis.__TEARDOWN_MESSAGE__ = '\nTearing down...\n'; return;