diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e8430868..1b5e8eac 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": [] diff --git a/CLAUDE.md b/CLAUDE.md index c6d951ef..cd53a0d1 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 @@ -49,6 +59,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` 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 baea44e2..2195a466 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); - }); - }); }); 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 8a495405..e7273c2c 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; 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 6386223a..7dbda181 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 6dd5ade9..8b564c74 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, diff --git a/apps/pac-shield-api/src/app/allocation/allocation.controller.ts b/apps/pac-shield-api/src/app/allocation/allocation.controller.ts index a485995d..3bf895d9 100644 --- a/apps/pac-shield-api/src/app/allocation/allocation.controller.ts +++ b/apps/pac-shield-api/src/app/allocation/allocation.controller.ts @@ -27,6 +27,8 @@ import { CreateAircraftRequestDto } from './dto/create-aircraft-request.dto'; import { UpdateAircraftRequestDto } from './dto/update-aircraft-request.dto'; import { ReviewAircraftRequestDto } from './dto/review-aircraft-request.dto'; import { CreateAircraftAllocationDto } from './dto/create-aircraft-allocation.dto'; +import { SpawnAircraftDto } from './dto/spawn-aircraft.dto'; +import { DirectAllocationDto } from './dto/direct-allocation.dto'; /** * Controller for CFACC aircraft allocation operations. @@ -308,4 +310,73 @@ export class AllocationController { ): Promise { return this.allocationService.getAllocationsForCycle(cycleId); } + + // ============================================= + // GM AIRCRAFT SPAWNING ENDPOINTS + // ============================================= + + /** + * Spawn a new aircraft instance (GM only) + * POST /allocation/aircraft/spawn + */ + @Post('aircraft/spawn') + async spawnAircraft( + @Body() dto: SpawnAircraftDto, + @Request() req: any + ): Promise { + return this.allocationService.spawnAircraft( + dto.gameId, + dto.type, + dto.subtype || null, + dto.teamId, + dto.rangeHexes, + dto.locationFosId, + dto.locationHex, + req.user + ); + } + + /** + * Delete an unallocated aircraft (GM only) + * DELETE /allocation/aircraft/:id + */ + @Delete('aircraft/:id') + async deleteAircraft( + @Param('id', ParseIntPipe) id: number, + @Request() req: any + ): Promise { + return this.allocationService.deleteUnallocatedAircraft(id, req.user); + } + + /** + * Get all aircraft for a game (GM view) + * GET /allocation/aircraft/game/:gameId + */ + @Get('aircraft/game/:gameId') + async getAllAircraft( + @Param('gameId', ParseIntPipe) gameId: number + ): Promise { + return this.allocationService.getAllAircraftForGame(gameId); + } + + // ============================================= + // DIRECT ALLOCATION ENDPOINT + // ============================================= + + /** + * Directly allocate an aircraft to a team (CFACC/GM only) + * POST /allocation/allocate + */ + @Post('allocate') + async directAllocate( + @Body() dto: DirectAllocationDto, + @Request() req: any + ): Promise { + return this.allocationService.directAllocateAircraft( + dto.aircraftInstanceId, + dto.allocatedToTeamId, + dto.allocationCycleId, + req.user + ); + } } 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 0c444883..9de9aa01 100644 --- a/apps/pac-shield-api/src/app/allocation/allocation.service.ts +++ b/apps/pac-shield-api/src/app/allocation/allocation.service.ts @@ -10,11 +10,14 @@ import { AircraftAllocationStatus, PlayerRole, TeamType, - AircraftType + AircraftType, + LocationType, + AircraftStatus } from '@prisma/client'; import { GameGateway } from '../../game/game.gateway'; import { AircraftPoolService } from './aircraft-pool.service'; import { AllocationNotificationService } from './allocation-notification.service'; +import { generateCallSign } from './utils/callsign-generator.util'; /** * Service for managing the CFACC aircraft allocation workflow. @@ -116,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 }, }); @@ -258,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 }, }); @@ -407,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 }, }); @@ -461,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 }, }); @@ -545,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 }, }); @@ -635,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 }, }); @@ -653,4 +656,280 @@ export class AllocationService { throw new ForbiddenException('Access denied to this team'); } } + + // ============================================= + // GM AIRCRAFT SPAWNING + // ============================================= + + /** + * Spawn a new aircraft instance (GM only) + */ + async spawnAircraft( + gameId: number, + type: AircraftType, + subtype: string | null, + teamId: number, + rangeHexes: number | undefined, + locationFosId?: string, + locationHex?: string, + user?: any + ): Promise { + // Verify GM permissions + if (user) { + const player = await this.prisma.player.findUnique({ + where: { sessionId: user.sessionId }, + }); + + if (!player || player.role !== PlayerRole.GM) { + throw new ForbiddenException('Only GMs can spawn aircraft'); + } + } + + // Verify game exists + const game = await this.prisma.game.findUnique({ + where: { id: gameId }, + }); + + if (!game) { + throw new NotFoundException('Game not found'); + } + + // Verify team exists + const team = await this.prisma.team.findUnique({ + where: { id: teamId }, + }); + + if (!team) { + throw new NotFoundException('Team not found'); + } + + // Get existing callsigns for this type/subtype + const existingAircraft = await this.prisma.aircraftInstance.findMany({ + where: { + type, + ...(type === AircraftType.C5 && subtype ? { subtype } : {}), + }, + select: { callSign: true }, + }); + + const existingCallSigns = existingAircraft.map(a => a.callSign); + + // Generate next available callsign + const callSign = generateCallSign(type, subtype, existingCallSigns); + + // Determine range based on aircraft type if not provided + const finalRange = rangeHexes ?? (type === AircraftType.C130 ? 3 : 4); // C130=3, C17/C5=4 + + // Determine location type + let locationType: LocationType; + if (locationFosId) { + locationType = LocationType.FOS; + } else if (locationHex) { + locationType = LocationType.MOB; // Using MOB for hex locations + } else { + throw new BadRequestException('Either locationFosId or locationHex must be provided'); + } + + // Create aircraft instance + const aircraft = await this.prisma.aircraftInstance.create({ + data: { + callSign, + type, + subtype, + rangeHexes: finalRange, + status: AircraftStatus.FMC, + locationType, + locationFosId, + locationHex, + teamId, + allocationStatus: AircraftAllocationStatus.AVAILABLE, + payloadPersonnelCount: 0, + }, + include: { + team: true, + }, + }); + + // Broadcast aircraft spawned event + this.gameGateway.broadcastAircraftSpawned(gameId.toString(), aircraft); + + // Update aircraft pool counts + await this.aircraftPoolService.refreshAircraftPool(gameId); + + return aircraft; + } + + /** + * Delete an unallocated aircraft (GM only) + */ + async deleteUnallocatedAircraft( + aircraftId: number, + user: any + ): Promise { + // Verify GM permissions + const player = await this.prisma.player.findUnique({ + where: { sessionId: user.sessionId }, + }); + + if (!player || player.role !== PlayerRole.GM) { + throw new ForbiddenException('Only GMs can delete aircraft'); + } + + // Verify aircraft exists and is not allocated + const aircraft = await this.prisma.aircraftInstance.findUnique({ + where: { id: aircraftId }, + include: { team: true }, + }); + + if (!aircraft) { + throw new NotFoundException('Aircraft not found'); + } + + if (aircraft.allocationStatus === AircraftAllocationStatus.ALLOCATED) { + throw new BadRequestException('Cannot delete allocated aircraft. Deallocate it first.'); + } + + // Delete aircraft + await this.prisma.aircraftInstance.delete({ + where: { id: aircraftId }, + }); + + // Broadcast aircraft removed event + this.gameGateway.broadcastAircraftRemoved(aircraft.team.gameId.toString(), aircraftId); + + // Update aircraft pool counts + await this.aircraftPoolService.refreshAircraftPool(aircraft.team.gameId); + } + + /** + * Get all aircraft for a game (GM view) + */ + async getAllAircraftForGame(gameId: number): Promise { + return this.prisma.aircraftInstance.findMany({ + where: { + team: { gameId }, + }, + include: { + team: true, + allocation: { + include: { + allocatedToTeam: true, + }, + }, + }, + orderBy: [ + { type: 'asc' }, + { callSign: 'asc' }, + ], + }); + } + + // ============================================= + // DIRECT ALLOCATION + // ============================================= + + /** + * Directly allocate an aircraft to a team (CFACC/GM only) + * Bypasses the request workflow + */ + async directAllocateAircraft( + aircraftInstanceId: number, + allocatedToTeamId: number, + allocationCycleId: number, + user: any + ): Promise { + // Verify CFACC/GM permissions + const player = await this.prisma.player.findUnique({ + where: { sessionId: user.sessionId }, + include: { team: true }, + }); + + if (!player) { + throw new NotFoundException('Player not found'); + } + + if (player.role !== PlayerRole.GM && player.team?.type !== TeamType.CAOC) { + throw new ForbiddenException('Only CFACC and GM can allocate aircraft'); + } + + // Verify aircraft exists and is available + const aircraft = await this.prisma.aircraftInstance.findUnique({ + where: { id: aircraftInstanceId }, + }); + + if (!aircraft) { + throw new NotFoundException('Aircraft not found'); + } + + if (aircraft.allocationStatus === AircraftAllocationStatus.ALLOCATED) { + throw new BadRequestException('Aircraft is already allocated'); + } + + // Verify allocation cycle exists + const cycle = await this.prisma.allocationCycle.findUnique({ + where: { id: allocationCycleId }, + }); + + if (!cycle) { + throw new NotFoundException('Allocation cycle not found'); + } + + // Verify team exists + const team = await this.prisma.team.findUnique({ + where: { id: allocatedToTeamId }, + }); + + if (!team) { + throw new NotFoundException('Team not found'); + } + + // Create a dummy request for the allocation (or make aircraftRequestId optional) + // For simplicity, we'll create a minimal request + const dummyRequest = await this.prisma.aircraftRequest.create({ + data: { + allocationCycleId, + teamId: allocatedToTeamId, + aircraftType: aircraft.type, + quantityRequested: 1, + missionJustification: 'Direct allocation by CFACC', + priority: 3, + rationale: 'Direct allocation', + status: AllocationRequestStatus.APPROVED, + quantityAllocated: 1, + }, + }); + + // Create allocation + const allocation = await this.prisma.aircraftAllocation.create({ + data: { + allocationCycleId, + aircraftRequestId: dummyRequest.id, + aircraftInstanceId, + allocatedToTeamId, + }, + include: { + aircraftInstance: true, + allocatedToTeam: true, + aircraftRequest: true, + allocationCycle: true, + }, + }); + + // Update aircraft allocation status + await this.prisma.aircraftInstance.update({ + where: { id: aircraftInstanceId }, + data: { allocationStatus: AircraftAllocationStatus.ALLOCATED }, + }); + + // Broadcast allocation event + this.gameGateway.broadcastAircraftAllocated(cycle.gameId.toString(), allocation); + + // Send notification to allocated team + await this.allocationNotificationService.notifyAircraftAllocated(allocation); + + // Update aircraft pool counts + await this.aircraftPoolService.refreshAircraftPool(cycle.gameId); + + return allocation; + } } diff --git a/apps/pac-shield-api/src/app/allocation/dto/direct-allocation.dto.ts b/apps/pac-shield-api/src/app/allocation/dto/direct-allocation.dto.ts new file mode 100644 index 00000000..d5cc9f5c --- /dev/null +++ b/apps/pac-shield-api/src/app/allocation/dto/direct-allocation.dto.ts @@ -0,0 +1,25 @@ +import { IsInt } from 'class-validator'; + +/** + * DTO for directly allocating an aircraft to a team (CFACC/GM only) + * Bypasses the request workflow for immediate allocation + */ +export class DirectAllocationDto { + /** + * Aircraft instance ID to allocate + */ + @IsInt() + aircraftInstanceId!: number; + + /** + * Team ID receiving the aircraft + */ + @IsInt() + allocatedToTeamId!: number; + + /** + * Allocation cycle ID + */ + @IsInt() + allocationCycleId!: number; +} diff --git a/apps/pac-shield-api/src/app/allocation/dto/spawn-aircraft.dto.ts b/apps/pac-shield-api/src/app/allocation/dto/spawn-aircraft.dto.ts new file mode 100644 index 00000000..6b8b65ee --- /dev/null +++ b/apps/pac-shield-api/src/app/allocation/dto/spawn-aircraft.dto.ts @@ -0,0 +1,55 @@ +import { IsInt, IsEnum, IsOptional, IsString, Min } from 'class-validator'; +import { AircraftType } from '@prisma/client'; + +/** + * DTO for spawning a new aircraft instance (GM only) + */ +export class SpawnAircraftDto { + /** + * Game ID where the aircraft will be spawned + */ + @IsInt() + gameId!: number; + + /** + * Aircraft type (C130, C17, C5, F16, F22) + */ + @IsEnum(['C130', 'C17', 'C5', 'F16', 'F22']) + type!: AircraftType; + + /** + * Aircraft subtype (BOBCAT or RHINO for C5 variants, null for others) + */ + @IsOptional() + @IsString() + subtype?: string | null; + + /** + * Team ID that will own this aircraft + */ + @IsInt() + teamId!: number; + + /** + * Range in hexes (optional - defaults based on aircraft type if not provided) + * C-130: 3 hexes, C-17: 4 hexes, C-5: 4 hexes + */ + @IsOptional() + @IsInt() + @Min(1) + rangeHexes?: number; + + /** + * Optional FOS location ID where aircraft starts + */ + @IsOptional() + @IsString() + locationFosId?: string; + + /** + * Optional hex location where aircraft starts + */ + @IsOptional() + @IsString() + locationHex?: string; +} diff --git a/apps/pac-shield-api/src/app/allocation/utils/callsign-generator.util.spec.ts b/apps/pac-shield-api/src/app/allocation/utils/callsign-generator.util.spec.ts new file mode 100644 index 00000000..1442f0c0 --- /dev/null +++ b/apps/pac-shield-api/src/app/allocation/utils/callsign-generator.util.spec.ts @@ -0,0 +1,164 @@ +import { generateCallSign, validateCallSign, parseCallSign } from './callsign-generator.util'; +import { AircraftType } from '@prisma/client'; + +describe('CallsignGeneratorUtil', () => { + describe('generateCallSign', () => { + it('should generate AW01 for first C130', () => { + const callsign = generateCallSign(AircraftType.C130, null, []); + expect(callsign).toBe('AW01'); + }); + + it('should generate ME01 for first C17', () => { + const callsign = generateCallSign(AircraftType.C17, null, []); + expect(callsign).toBe('ME01'); + }); + + it('should generate BO01 for first C5 Bobcat', () => { + const callsign = generateCallSign(AircraftType.C5, 'BOBCAT', []); + expect(callsign).toBe('BO01'); + }); + + it('should generate RH01 for first C5 Rhino', () => { + const callsign = generateCallSign(AircraftType.C5, 'RHINO', []); + expect(callsign).toBe('RH01'); + }); + + it('should generate VIP01 for first F16', () => { + const callsign = generateCallSign(AircraftType.F16, null, []); + expect(callsign).toBe('VIP01'); + }); + + it('should generate RPT01 for first F22', () => { + const callsign = generateCallSign(AircraftType.F22, null, []); + expect(callsign).toBe('RPT01'); + }); + + it('should generate next sequential callsign', () => { + const existing = ['AW01', 'AW02', 'AW03']; + const callsign = generateCallSign(AircraftType.C130, null, existing); + expect(callsign).toBe('AW04'); + }); + + it('should handle non-sequential existing callsigns', () => { + const existing = ['AW01', 'AW05', 'AW03']; + const callsign = generateCallSign(AircraftType.C130, null, existing); + expect(callsign).toBe('AW06'); // Should use max + 1 + }); + + it('should zero-pad single digit numbers', () => { + const existing = ['ME01', 'ME02', 'ME03', 'ME04', 'ME05', 'ME06', 'ME07', 'ME08']; + const callsign = generateCallSign(AircraftType.C17, null, existing); + expect(callsign).toBe('ME09'); + }); + + it('should handle double digit numbers', () => { + const existing = ['ME09']; + const callsign = generateCallSign(AircraftType.C17, null, existing); + expect(callsign).toBe('ME10'); + }); + + it('should ignore callsigns from other aircraft types', () => { + const existing = ['AW01', 'ME01', 'BO01', 'RH01']; + const callsign = generateCallSign(AircraftType.C130, null, existing); + expect(callsign).toBe('AW02'); // Should only count AW01 + }); + + it('should differentiate between C5 Bobcat and Rhino', () => { + const existing = ['BO01', 'BO02', 'RH01']; + const callsignBobcat = generateCallSign(AircraftType.C5, 'BOBCAT', existing); + const callsignRhino = generateCallSign(AircraftType.C5, 'RHINO', existing); + + expect(callsignBobcat).toBe('BO03'); + expect(callsignRhino).toBe('RH02'); + }); + + it('should throw error for unknown aircraft type', () => { + expect(() => { + generateCallSign('UNKNOWN' as AircraftType, null, []); + }).toThrow(); + }); + + it('should throw error for C5 without subtype', () => { + expect(() => { + generateCallSign(AircraftType.C5, null, []); + }).toThrow(); + }); + }); + + describe('validateCallSign', () => { + it('should validate correct C130 callsign', () => { + expect(validateCallSign('AW01', AircraftType.C130, null)).toBe(true); + expect(validateCallSign('AW99', AircraftType.C130, null)).toBe(true); + expect(validateCallSign('AW123', AircraftType.C130, null)).toBe(true); + }); + + it('should validate correct C17 callsign', () => { + expect(validateCallSign('ME01', AircraftType.C17, null)).toBe(true); + expect(validateCallSign('ME42', AircraftType.C17, null)).toBe(true); + }); + + it('should validate correct C5 Bobcat callsign', () => { + expect(validateCallSign('BO01', AircraftType.C5, 'BOBCAT')).toBe(true); + expect(validateCallSign('BO99', AircraftType.C5, 'BOBCAT')).toBe(true); + }); + + it('should validate correct C5 Rhino callsign', () => { + expect(validateCallSign('RH01', AircraftType.C5, 'RHINO')).toBe(true); + expect(validateCallSign('RH42', AircraftType.C5, 'RHINO')).toBe(true); + }); + + it('should reject incorrect prefix', () => { + expect(validateCallSign('XX01', AircraftType.C130, null)).toBe(false); + expect(validateCallSign('AW01', AircraftType.C17, null)).toBe(false); + }); + + it('should reject incorrect format', () => { + expect(validateCallSign('AW1', AircraftType.C130, null)).toBe(false); // Need 2+ digits + expect(validateCallSign('AWXX', AircraftType.C130, null)).toBe(false); + expect(validateCallSign('AW', AircraftType.C130, null)).toBe(false); + }); + + it('should reject wrong subtype for C5', () => { + expect(validateCallSign('BO01', AircraftType.C5, 'RHINO')).toBe(false); + expect(validateCallSign('RH01', AircraftType.C5, 'BOBCAT')).toBe(false); + }); + }); + + describe('parseCallSign', () => { + it('should parse C130 callsign', () => { + const result = parseCallSign('AW42'); + expect(result).toEqual({ type: AircraftType.C130, subtype: null }); + }); + + it('should parse C17 callsign', () => { + const result = parseCallSign('ME15'); + expect(result).toEqual({ type: AircraftType.C17, subtype: null }); + }); + + it('should parse C5 Bobcat callsign', () => { + const result = parseCallSign('BO03'); + expect(result).toEqual({ type: AircraftType.C5, subtype: 'BOBCAT' }); + }); + + it('should parse C5 Rhino callsign', () => { + const result = parseCallSign('RH07'); + expect(result).toEqual({ type: AircraftType.C5, subtype: 'RHINO' }); + }); + + it('should parse F16 callsign', () => { + const result = parseCallSign('VIP12'); + expect(result).toEqual({ type: AircraftType.F16, subtype: null }); + }); + + it('should parse F22 callsign', () => { + const result = parseCallSign('RPT05'); + expect(result).toEqual({ type: AircraftType.F22, subtype: null }); + }); + + it('should return null for invalid callsign', () => { + expect(parseCallSign('XX99')).toBeNull(); + expect(parseCallSign('INVALID')).toBeNull(); + expect(parseCallSign('')).toBeNull(); + }); + }); +}); diff --git a/apps/pac-shield-api/src/app/allocation/utils/callsign-generator.util.ts b/apps/pac-shield-api/src/app/allocation/utils/callsign-generator.util.ts new file mode 100644 index 00000000..5d5422dd --- /dev/null +++ b/apps/pac-shield-api/src/app/allocation/utils/callsign-generator.util.ts @@ -0,0 +1,99 @@ +import { AircraftType } from '@prisma/client'; + +/** + * Callsign prefix mapping for aircraft types and subtypes + * - C130: AW (Airlift Wing) + * - C17: ME (Mobility Express) + * - C5 Bobcat: BO + * - C5 Rhino: RH + */ +const CALLSIGN_PREFIXES: Record = { + 'C130': 'AW', + 'C17': 'ME', + 'C5_BOBCAT': 'BO', + 'C5_RHINO': 'RH', + 'F16': 'VIP', // Viper + 'F22': 'RPT', // Raptor +}; + +/** + * Generate the next available callsign for a given aircraft type/subtype + * @param type Aircraft type (C130, C17, C5, etc.) + * @param subtype Aircraft subtype (for C5: 'BOBCAT' or 'RHINO') + * @param existingCallSigns Array of currently used callsigns for this type/subtype + * @returns Next available callsign (e.g., 'AW01', 'ME12', 'BO03') + */ +export function generateCallSign( + type: AircraftType, + subtype: string | null, + existingCallSigns: string[] +): string { + // Determine the prefix + const key = type === 'C5' && subtype ? `${type}_${subtype}` : type; + const prefix = CALLSIGN_PREFIXES[key]; + + if (!prefix) { + throw new Error(`No callsign prefix defined for aircraft type: ${type}${subtype ? ` (${subtype})` : ''}`); + } + + // Extract numbers from existing callsigns with this prefix + const existingNumbers = existingCallSigns + .filter(cs => cs.startsWith(prefix)) + .map(cs => { + const match = cs.match(/^[A-Z]+(\d+)$/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter(num => !isNaN(num)); + + // Find the next available number (start from 1) + let nextNumber = 1; + if (existingNumbers.length > 0) { + const maxNumber = Math.max(...existingNumbers); + nextNumber = maxNumber + 1; + } + + // Format with zero-padding (e.g., AW01, AW02, ... AW10, AW11) + return `${prefix}${nextNumber.toString().padStart(2, '0')}`; +} + +/** + * Validate if a callsign matches the expected format for a given aircraft type/subtype + * @param callSign Callsign to validate + * @param type Aircraft type + * @param subtype Aircraft subtype (for C5) + * @returns True if callsign format is valid + */ +export function validateCallSign( + callSign: string, + type: AircraftType, + subtype: string | null +): boolean { + const key = type === 'C5' && subtype ? `${type}_${subtype}` : type; + const expectedPrefix = CALLSIGN_PREFIXES[key]; + + if (!expectedPrefix) { + return false; + } + + // Check format: prefix followed by 2+ digits + const regex = new RegExp(`^${expectedPrefix}\\d{2,}$`); + return regex.test(callSign); +} + +/** + * Parse a callsign to extract its type and subtype + * @param callSign Callsign to parse + * @returns Object with type and subtype, or null if invalid + */ +export function parseCallSign(callSign: string): { type: AircraftType; subtype: string | null } | null { + for (const [key, prefix] of Object.entries(CALLSIGN_PREFIXES)) { + if (callSign.startsWith(prefix)) { + if (key.startsWith('C5_')) { + const subtype = key.split('_')[1]; + return { type: 'C5' as AircraftType, subtype }; + } + return { type: key as AircraftType, subtype: null }; + } + } + return null; +} diff --git a/apps/pac-shield-api/src/app/app.module.ts b/apps/pac-shield-api/src/app/app.module.ts index 44334f26..07fa4995 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 128bacb6..d5c847fb 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); } diff --git a/apps/pac-shield-api/src/game/game.gateway.ts b/apps/pac-shield-api/src/game/game.gateway.ts index 73b487d4..8f6b785c 100644 --- a/apps/pac-shield-api/src/game/game.gateway.ts +++ b/apps/pac-shield-api/src/game/game.gateway.ts @@ -8,7 +8,7 @@ import { import { Server, Socket } from 'socket.io'; import { Logger } from '@nestjs/common'; import { ATOLine } from '../app/generated/aTOLine/aTOLine.entity'; -import { AircraftRequest, AircraftAllocation, AllocationCycle } from '@prisma/client'; +import { AircraftRequest, AircraftAllocation, AllocationCycle, AircraftInstance } from '@prisma/client'; /** * WebSocket gateway for real-time, game-scoped events (namespace: /game). @@ -312,6 +312,30 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect { this.logger.log(`Aircraft deallocated broadcast to room ${gameId}: ${aircraftCallSign}`); } + /** + * Broadcast aircraft spawned event to all players in the game room (GM spawning) + */ + broadcastAircraftSpawned(gameId: string, aircraft: AircraftInstance): void { + this.server.to(gameId).emit('aircraftSpawned', { + type: 'aircraftSpawned', + payload: aircraft, + timestamp: new Date().toISOString(), + }); + this.logger.log(`Aircraft spawned broadcast to room ${gameId}: ${aircraft.callSign}`); + } + + /** + * Broadcast aircraft removed event to all players in the game room (GM deletion) + */ + broadcastAircraftRemoved(gameId: string, aircraftId: number): void { + this.server.to(gameId).emit('aircraftRemoved', { + type: 'aircraftRemoved', + payload: { aircraftId }, + timestamp: new Date().toISOString(), + }); + this.logger.log(`Aircraft removed broadcast to room ${gameId}: ${aircraftId}`); + } + /** * Broadcast aircraft pool updated event to all players in the game room */ diff --git a/apps/pac-shield-api/src/prisma/schema.prisma b/apps/pac-shield-api/src/prisma/schema.prisma index ad880d52..0558c291 100644 --- a/apps/pac-shield-api/src/prisma/schema.prisma +++ b/apps/pac-shield-api/src/prisma/schema.prisma @@ -232,7 +232,8 @@ model AircraftInstance { id Int @id @default(autoincrement()) callSign String @unique type AircraftType - strength Int + /// @DtoCreateOptional + subtype String? // 'BOBCAT' or 'RHINO' for C5 variants, null for other types rangeHexes Int status AircraftStatus @default(FMC) 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 00000000..7963b040 --- /dev/null +++ b/apps/pac-shield-e2e/src/aircraft-allocation.spec.ts @@ -0,0 +1,263 @@ +import { test, expect } from '@playwright/test'; + +/** + * PLACEHOLDER FILE FOR FUTURE PLAYWRIGHT UI E2E TESTS + * + * 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 - 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 + }); + + // 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 + }); + + // 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.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 + }); + + // 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.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 + }); + + // 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 + }); + + // 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.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 + }); + + // 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 + }); + + // 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('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 + }); + + // 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 + }); + + // 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 + }); + + // 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('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.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 + }); + + // 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.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 + }); + + // 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 + }); + }); +}); 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 64a2b683..d840f5f2 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(); diff --git a/apps/pac-shield/src/app/app.html b/apps/pac-shield/src/app/app.html index 24bff988..1e7df503 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 af9dbd7c..1dc91c79 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 dfbaf972..2f9cdeab 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/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 new file mode 100644 index 00000000..d06c5208 --- /dev/null +++ b/apps/pac-shield/src/app/features/game/dialogs/aircraft-spawn-dialog/aircraft-spawn-dialog.component.ts @@ -0,0 +1,427 @@ +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'; +import { MatFormFieldModule } from '@angular/material/form-field'; +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; + teams: Team[]; +} + +export interface AircraftSpawnResult { + type: AircraftType; + subtype?: string; + teamId: number; + strength: number; + rangeHexes: number; + locationFosId?: string; + 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 + */ +@Component({ + selector: 'app-aircraft-spawn-dialog', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatButtonModule, + MatIconModule, + MatAutocompleteModule, + ], + template: ` +

+ flight_takeoff + Spawn Aircraft +

+ + +
+ + + Aircraft Type + + C-130 Hercules (AW) + C-17 Globemaster (ME) + C-5 Galaxy + F-16 Fighting Falcon (VIP) + F-22 Raptor (RPT) + + Callsign prefix shown in parentheses + + + + @if (spawnForm.get('type')?.value === 'C5') { + + C-5 Variant + + Bobcat (BO) + Rhino (RH) + + + } + + + + Assign to Team + + @for (team of data.teams; track team.id) { + {{ team.type }} ({{ team.name }}) + } + + + + +
Starting Location
+ + + 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 + + + @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/MOB location 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 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], + 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 }); + } + + this.setupLocationAutocomplete(); + this.loadHexLocations(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * 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(); + } + + /** + * 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; + }; +} 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 6e5e4469..930bc410 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/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 5af474f6..fcc51b22 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 */ 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 33659a22..5aec0598 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-board.component.ts b/apps/pac-shield/src/app/features/game/game-board.component.ts index 70b93c00..6c7b709c 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/ato-table/ato-table.component.html b/apps/pac-shield/src/app/features/game/game-stats/ato-table/ato-table.component.html index a6720851..2cba5f20 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) { - @@ -24,7 +28,7 @@ {{ l.aircraftCallSign || 'โ€”' }} @if (l.riskTokenUsed) { - casino + casino } @@ -72,7 +76,7 @@ PPR Status
- + {{ getPprStatusIcon(l.pprStatus) }} {{ l.pprStatus || 'PENDING' }} 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 7a933d18..eb7e9ca3 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/caoc-dashboard/caoc-dashboard.component.html b/apps/pac-shield/src/app/features/game/game-stats/caoc-dashboard/caoc-dashboard.component.html index 54bb9781..586b95bb 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) { - - }
@@ -50,7 +39,7 @@ [value]="section.id" class="flex-1 min-h-[48px] px-2">
- {{ section.icon }} + {{ section.icon }} {{ section.shortLabel }}
@@ -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 - - -
- - - - -
-
-
-
-
-
- }
@@ -439,7 +300,7 @@

CFACC Decision

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

All aircraft allocated

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

CFACC Decision

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

CFACC Decision

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

CFACC Decision

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

    CFACC Decision

    @if (analytics$ | async; as analytics) {
    - info + info Current Status
    @@ -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 6a748321..2d99a1dc 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) || []; + } } 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 6bc72d47..a895ca33 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