diff --git a/apps/web/src/app/games/complement-race/hooks/__tests__/useSteamJourney.boarding.test.ts b/apps/web/src/app/games/complement-race/hooks/__tests__/useSteamJourney.boarding.test.ts new file mode 100644 index 00000000..0863efcc --- /dev/null +++ b/apps/web/src/app/games/complement-race/hooks/__tests__/useSteamJourney.boarding.test.ts @@ -0,0 +1,350 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest' + +// Mock sound effects +vi.mock('../useSoundEffects', () => ({ + useSoundEffects: () => ({ + playSound: vi.fn() + }) +})) + +/** + * Boarding Logic Tests + * + * These tests simulate the game loop's boarding logic to find edge cases + * where passengers get left behind at stations. + */ + +interface Passenger { + id: string + name: string + avatar: string + originStationId: string + destinationStationId: string + isBoarded: boolean + isDelivered: boolean + isUrgent: boolean +} + +interface Station { + id: string + name: string + icon: string + position: number +} + +describe('useSteamJourney - Boarding Logic', () => { + const CAR_SPACING = 7 + let stations: Station[] + let passengers: Passenger[] + + beforeEach(() => { + stations = [ + { id: 's1', name: 'Station 1', icon: '🏠', position: 20 }, + { id: 's2', name: 'Station 2', icon: '🏢', position: 50 }, + { id: 's3', name: 'Station 3', icon: '🏪', position: 80 }, + ] + + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + /** + * Simulate the boarding logic from useSteamJourney (with fix) + */ + function simulateBoardingAtPosition( + trainPosition: number, + passengers: Passenger[], + stations: Station[], + maxCars: number + ): Passenger[] { + const updatedPassengers = [...passengers] + const currentBoardedPassengers = updatedPassengers.filter(p => p.isBoarded && !p.isDelivered) + + // Track which cars are assigned in THIS frame to prevent double-boarding + const carsAssignedThisFrame = new Set() + + // Simulate the boarding logic + updatedPassengers.forEach((passenger, passengerIndex) => { + if (passenger.isBoarded || passenger.isDelivered) return + + const station = stations.find(s => s.id === passenger.originStationId) + if (!station) return + + // Check if any empty car is at this station + for (let carIndex = 0; carIndex < maxCars; carIndex++) { + // Skip if this car already has a passenger OR was assigned this frame + if (currentBoardedPassengers[carIndex] || carsAssignedThisFrame.has(carIndex)) continue + + const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING) + const distance = Math.abs(carPosition - station.position) + + // If car is at station (within 3% tolerance), board this passenger + if (distance < 3) { + updatedPassengers[passengerIndex] = { ...passenger, isBoarded: true } + // Mark this car as assigned in this frame + carsAssignedThisFrame.add(carIndex) + return // Board this passenger and move on + } + } + }) + + return updatedPassengers + } + + test('single passenger at station boards when car arrives', () => { + passengers = [ + { + id: 'p1', + name: 'Alice', + avatar: '👩', + originStationId: 's1', + destinationStationId: 's2', + isBoarded: false, + isDelivered: false, + isUrgent: false + } + ] + + // Train at position 27%, first car at position 20% (station 1) + let result = simulateBoardingAtPosition(27, passengers, stations, 1) + + expect(result[0].isBoarded).toBe(true) + }) + + test('EDGE CASE: multiple passengers at same station with enough cars', () => { + passengers = [ + { + id: 'p1', + name: 'Alice', + avatar: '👩', + originStationId: 's1', + destinationStationId: 's2', + isBoarded: false, + isDelivered: false, + isUrgent: false + }, + { + id: 'p2', + name: 'Bob', + avatar: '👨', + originStationId: 's1', + destinationStationId: 's2', + isBoarded: false, + isDelivered: false, + isUrgent: false + }, + { + id: 'p3', + name: 'Charlie', + avatar: '👴', + originStationId: 's1', + destinationStationId: 's2', + isBoarded: false, + isDelivered: false, + isUrgent: false + } + ] + + // Train at position 34%, cars at: 27%, 20%, 13% + // Car 1 (27%): 7% away from station (too far) + // Car 2 (20%): 0% away from station (at station!) + // Car 3 (13%): 7% away from station (too far) + let result = simulateBoardingAtPosition(34, passengers, stations, 3) + + // First iteration: car 2 is at station, should board first passenger + expect(result[0].isBoarded).toBe(true) + + // But what about the other passengers? They should board on subsequent frames + // Let's simulate the train advancing slightly + result = simulateBoardingAtPosition(35, result, stations, 3) + + // Now car 1 is at 28% (still too far), car 2 at 21% (still close), car 3 at 14% (too far) + // Passenger 2 should still not board yet + + // Advance more - when does car 1 reach the station? + result = simulateBoardingAtPosition(27, result, stations, 3) + // Car 1 at 20% (at station!) + expect(result[1].isBoarded).toBe(true) + + // What about passenger 3? Need car 3 to reach station + // Car 3 position = trainPosition - (3 * 7) = trainPosition - 21 + // For car 3 to be at 20%, need trainPosition = 41 + result = simulateBoardingAtPosition(41, result, stations, 3) + // Car 3 at 20% (at station!) + expect(result[2].isBoarded).toBe(true) + }) + + test('EDGE CASE: passengers left behind when train moves too fast', () => { + passengers = [ + { + id: 'p1', + name: 'Alice', + avatar: '👩', + originStationId: 's1', + destinationStationId: 's2', + isBoarded: false, + isDelivered: false, + isUrgent: false + }, + { + id: 'p2', + name: 'Bob', + avatar: '👨', + originStationId: 's1', + destinationStationId: 's2', + isBoarded: false, + isDelivered: false, + isUrgent: false + } + ] + + // Simulate train speeding through station + // Only 2 cars, but 2 passengers at same station + + // Frame 1: Train at 27%, car 1 at 20%, car 2 at 13% + let result = simulateBoardingAtPosition(27, passengers, stations, 2) + expect(result[0].isBoarded).toBe(true) + expect(result[1].isBoarded).toBe(false) + + // Frame 2: Train jumps to 35% (high momentum) + // Car 1 at 28%, car 2 at 21% + result = simulateBoardingAtPosition(35, result, stations, 2) + // Car 2 is at 21%, within 1% of station at 20% + expect(result[1].isBoarded).toBe(true) + + // Frame 3: Train at 45% - both cars past station + result = simulateBoardingAtPosition(45, result, stations, 2) + // Car 1 at 38%, car 2 at 31% - both way past 20% + + // All passengers should have boarded + expect(result.every(p => p.isBoarded)).toBe(true) + }) + + test('EDGE CASE: passenger left behind when boarding window is missed', () => { + passengers = [ + { + id: 'p1', + name: 'Alice', + avatar: '👩', + originStationId: 's1', + destinationStationId: 's2', + isBoarded: false, + isDelivered: false, + isUrgent: false + }, + { + id: 'p2', + name: 'Bob', + avatar: '👨', + originStationId: 's1', + destinationStationId: 's2', + isBoarded: false, + isDelivered: false, + isUrgent: false + } + ] + + // Only 1 car, 2 passengers + // Frame 1: Train at 27%, car at 20% + let result = simulateBoardingAtPosition(27, passengers, stations, 1) + expect(result[0].isBoarded).toBe(true) + expect(result[1].isBoarded).toBe(false) // Second passenger waiting + + // Frame 2: Train jumps way past (very high momentum) + result = simulateBoardingAtPosition(50, result, stations, 1) + // Car at 43% - way past station at 20% + + // Second passenger SHOULD BE LEFT BEHIND! + expect(result[1].isBoarded).toBe(false) + }) + + test('EDGE CASE: only one passenger boards per car per frame', () => { + passengers = [ + { + id: 'p1', + name: 'Alice', + avatar: '👩', + originStationId: 's1', + destinationStationId: 's2', + isBoarded: false, + isDelivered: false, + isUrgent: false + }, + { + id: 'p2', + name: 'Bob', + avatar: '👨', + originStationId: 's1', + destinationStationId: 's2', + isBoarded: false, + isDelivered: false, + isUrgent: false + } + ] + + // Only 1 car, both passengers at same station + // With the fix, only first passenger should board in this frame + const result = simulateBoardingAtPosition(27, passengers, stations, 1) + + // First passenger boards + expect(result[0].isBoarded).toBe(true) + // Second passenger does NOT board (car already assigned this frame) + expect(result[1].isBoarded).toBe(false) + }) + + test('all passengers board before train completely passes station', () => { + passengers = [ + { + id: 'p1', + name: 'Alice', + avatar: '👩', + originStationId: 's1', + destinationStationId: 's2', + isBoarded: false, + isDelivered: false, + isUrgent: false + }, + { + id: 'p2', + name: 'Bob', + avatar: '👨', + originStationId: 's1', + destinationStationId: 's2', + isBoarded: false, + isDelivered: false, + isUrgent: false + }, + { + id: 'p3', + name: 'Charlie', + avatar: '👴', + originStationId: 's1', + destinationStationId: 's2', + isBoarded: false, + isDelivered: false, + isUrgent: false + } + ] + + // 3 passengers, 3 cars + // Simulate train moving through station frame by frame + let result = passengers + + // Train approaching station + for (let pos = 13; pos <= 40; pos += 1) { + result = simulateBoardingAtPosition(pos, result, stations, 3) + } + + // All passengers should have boarded by the time last car passes + const allBoarded = result.every(p => p.isBoarded) + const leftBehind = result.filter(p => !p.isBoarded) + + expect(allBoarded).toBe(true) + if (!allBoarded) { + console.log('Passengers left behind:', leftBehind.map(p => p.name)) + } + }) +}) diff --git a/apps/web/src/app/games/complement-race/hooks/useSteamJourney.ts b/apps/web/src/app/games/complement-race/hooks/useSteamJourney.ts index 94b5afac..2dcd8924 100644 --- a/apps/web/src/app/games/complement-race/hooks/useSteamJourney.ts +++ b/apps/web/src/app/games/complement-race/hooks/useSteamJourney.ts @@ -114,6 +114,9 @@ export function useSteamJourney() { const maxCars = Math.max(1, maxPassengers) const currentBoardedPassengers = state.passengers.filter(p => p.isBoarded && !p.isDelivered) + // Track which cars are assigned in THIS frame to prevent double-boarding + const carsAssignedThisFrame = new Set() + // Find waiting passengers whose origin station has an empty car nearby state.passengers.forEach(passenger => { if (passenger.isBoarded || passenger.isDelivered) return @@ -124,8 +127,8 @@ export function useSteamJourney() { // Check if any empty car is at this station // Cars are at positions: trainPosition - 7, trainPosition - 14, etc. for (let carIndex = 0; carIndex < maxCars; carIndex++) { - // Skip if this car already has a passenger - if (currentBoardedPassengers[carIndex]) continue + // Skip if this car already has a passenger OR was assigned this frame + if (currentBoardedPassengers[carIndex] || carsAssignedThisFrame.has(carIndex)) continue const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING) const distance = Math.abs(carPosition - station.position) @@ -136,6 +139,8 @@ export function useSteamJourney() { type: 'BOARD_PASSENGER', passengerId: passenger.id }) + // Mark this car as assigned in this frame + carsAssignedThisFrame.add(carIndex) return // Board this passenger and move on } }