feat: dynamically calculate train cars based on max concurrent passengers

Instead of hard-coding 5 train cars, now calculate the number of cars needed
based on the maximum number of passengers that will be on the train at any
given moment during the route.

For example: if 8 total passengers exist, but only 4 board at the start, all
get off halfway, then the remaining 4 board and ride to the end, we only need
4 cars (not 8).

Added calculateMaxConcurrentPassengers() utility that:
- Tracks boarding/delivery events by station position
- Sorts events to handle same-station boarding/delivery correctly
- Returns the peak concurrent passenger count

Updated SteamTrainJourney to calculate maxCars dynamically using this utility.
Updated all hooks and tests to use required maxCars/carSpacing parameters.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-10-01 11:26:59 -05:00
parent 65dafc9215
commit 9ea15535d1
5 changed files with 114 additions and 33 deletions

View File

@@ -9,6 +9,7 @@ import { useTrackManagement } from '../../hooks/useTrackManagement'
import { useComplementRace } from '../../context/ComplementRaceContext'
import { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
import { getRouteTheme } from '../../lib/routeThemes'
import { calculateMaxConcurrentPassengers } from '../../lib/passengerGenerator'
import { useGameMode } from '@/contexts/GameModeContext'
import { useUserProfile } from '@/contexts/UserProfileContext'
import { TrainTerrainBackground } from './TrainTerrainBackground'
@@ -98,11 +99,22 @@ export function SteamTrainJourney({ momentum, trainPosition, pressure, elapsedTi
const pathRef = useRef<SVGPathElement>(null)
const [trackGenerator] = useState(() => new RailroadTrackGenerator(800, 600))
// Train transforms (extracted to hook) - called first to get maxCars and carSpacing
const { trainTransform, trainCars, locomotiveOpacity, maxCars, carSpacing } = useTrainTransforms({
// Calculate the number of train cars dynamically based on max concurrent passengers
const maxCars = useMemo(() => {
const maxPassengers = calculateMaxConcurrentPassengers(state.passengers, state.stations)
// Ensure at least 1 car, even if no passengers
return Math.max(1, maxPassengers)
}, [state.passengers, state.stations])
const carSpacing = 7 // Distance between cars (in % of track)
// Train transforms (extracted to hook)
const { trainTransform, trainCars, locomotiveOpacity } = useTrainTransforms({
trainPosition,
trackGenerator,
pathRef
pathRef,
maxCars,
carSpacing
})
// Track management (extracted to hook)

View File

@@ -31,13 +31,14 @@ describe('useTrainTransforms', () => {
useTrainTransforms({
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: nullPathRef
pathRef: nullPathRef,
maxCars: 5,
carSpacing: 7
})
)
expect(result.current.trainTransform).toEqual({ x: 50, y: 300, rotation: 0 })
expect(result.current.maxCars).toBe(5)
expect(result.current.carSpacing).toBe(7)
expect(result.current.trainCars).toHaveLength(5)
})
test('calculates train transform at given position', () => {
@@ -45,7 +46,9 @@ describe('useTrainTransforms', () => {
useTrainTransforms({
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7
})
)
@@ -62,7 +65,9 @@ describe('useTrainTransforms', () => {
useTrainTransforms({
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7
}),
{ initialProps: { position: 20 } }
)
@@ -73,17 +78,18 @@ describe('useTrainTransforms', () => {
expect(result.current.trainTransform.x).toBe(600)
})
test('calculates correct number of train cars with default maxCars', () => {
test('calculates correct number of train cars', () => {
const { result } = renderHook(() =>
useTrainTransforms({
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7
})
)
expect(result.current.trainCars).toHaveLength(5)
expect(result.current.maxCars).toBe(5)
})
test('respects custom maxCars parameter', () => {
@@ -92,12 +98,12 @@ describe('useTrainTransforms', () => {
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 3
maxCars: 3,
carSpacing: 7
})
)
expect(result.current.trainCars).toHaveLength(3)
expect(result.current.maxCars).toBe(3)
})
test('respects custom carSpacing parameter', () => {
@@ -106,11 +112,11 @@ describe('useTrainTransforms', () => {
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 10
})
)
expect(result.current.carSpacing).toBe(10)
// First car should be at position 50 - 10 = 40
expect(result.current.trainCars[0].position).toBe(40)
})
@@ -137,7 +143,9 @@ describe('useTrainTransforms', () => {
useTrainTransforms({
trainPosition: 3,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7
})
)
expect(result1.current.locomotiveOpacity).toBe(0)
@@ -146,7 +154,9 @@ describe('useTrainTransforms', () => {
useTrainTransforms({
trainPosition: 5.5, // Midpoint between 3 and 8
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7
})
)
expect(result2.current.locomotiveOpacity).toBe(0.5)
@@ -155,7 +165,9 @@ describe('useTrainTransforms', () => {
useTrainTransforms({
trainPosition: 8,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7
})
)
expect(result3.current.locomotiveOpacity).toBe(1)
@@ -167,7 +179,9 @@ describe('useTrainTransforms', () => {
useTrainTransforms({
trainPosition: 92,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7
})
)
expect(result1.current.locomotiveOpacity).toBe(1)
@@ -176,7 +190,9 @@ describe('useTrainTransforms', () => {
useTrainTransforms({
trainPosition: 94.5, // Midpoint between 92 and 97
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7
})
)
expect(result2.current.locomotiveOpacity).toBe(0.5)
@@ -185,7 +201,9 @@ describe('useTrainTransforms', () => {
useTrainTransforms({
trainPosition: 97,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7
})
)
expect(result3.current.locomotiveOpacity).toBe(0)
@@ -196,7 +214,9 @@ describe('useTrainTransforms', () => {
useTrainTransforms({
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7
})
)
@@ -261,7 +281,9 @@ describe('useTrainTransforms', () => {
useTrainTransforms({
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7
})
)

View File

@@ -10,8 +10,8 @@ interface UseTrackManagementParams {
pathRef: React.RefObject<SVGPathElement>
stations: Station[]
passengers: Passenger[]
maxCars?: number
carSpacing?: number
maxCars: number
carSpacing: number
}
export function useTrackManagement({
@@ -21,8 +21,8 @@ export function useTrackManagement({
pathRef,
stations,
passengers,
maxCars = 5,
carSpacing = 7
maxCars,
carSpacing
}: UseTrackManagementParams) {
const [trackData, setTrackData] = useState<ReturnType<typeof trackGenerator.generateTrack> | null>(null)
const [tiesAndRails, setTiesAndRails] = useState<{

View File

@@ -16,16 +16,16 @@ interface UseTrainTransformsParams {
trainPosition: number
trackGenerator: RailroadTrackGenerator
pathRef: React.RefObject<SVGPathElement>
maxCars?: number
carSpacing?: number
maxCars: number
carSpacing: number
}
export function useTrainTransforms({
trainPosition,
trackGenerator,
pathRef,
maxCars = 5,
carSpacing = 7
maxCars,
carSpacing
}: UseTrainTransformsParams) {
const [trainTransform, setTrainTransform] = useState<TrainTransform>({ x: 50, y: 300, rotation: 0 })
@@ -102,8 +102,6 @@ export function useTrainTransforms({
return {
trainTransform,
trainCars,
locomotiveOpacity,
maxCars,
carSpacing
locomotiveOpacity
}
}

View File

@@ -180,4 +180,53 @@ export function findDeliverablePassengers(
}
return deliverable
}
/**
* Calculate the maximum number of passengers that will be on the train
* concurrently at any given moment during the route
*/
export function calculateMaxConcurrentPassengers(
passengers: Passenger[],
stations: Station[]
): number {
// Create events for boarding and delivery
interface StationEvent {
position: number
isBoarding: boolean // true = board, false = delivery
}
const events: StationEvent[] = []
for (const passenger of passengers) {
const originStation = stations.find(s => s.id === passenger.originStationId)
const destStation = stations.find(s => s.id === passenger.destinationStationId)
if (originStation && destStation) {
events.push({ position: originStation.position, isBoarding: true })
events.push({ position: destStation.position, isBoarding: false })
}
}
// Sort events by position, with deliveries before boardings at the same position
events.sort((a, b) => {
if (a.position !== b.position) return a.position - b.position
// At same position, deliveries happen before boarding
return a.isBoarding ? 1 : -1
})
// Track current passenger count and maximum
let currentCount = 0
let maxCount = 0
for (const event of events) {
if (event.isBoarding) {
currentCount++
maxCount = Math.max(maxCount, currentCount)
} else {
currentCount--
}
}
return maxCount
}