feat: add passenger boarding system with station-based pickup

- Add BOARD_PASSENGER action to GameAction types
- Implement BOARD_PASSENGER reducer to mark passengers as boarded
- Add findBoardablePassengers helper to identify passengers ready to board
- Update useSteamJourney hook to automatically board passengers when train reaches their origin station
- Modify findDeliverablePassengers to only check boarded passengers for delivery
- Passengers now transition through complete lifecycle: waiting → boarding → aboard → delivery

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-09-30 12:05:45 -05:00
parent 582bce411f
commit 23a9016245
4 changed files with 149 additions and 17 deletions

View File

@@ -351,6 +351,14 @@ function gameReducer(state: GameState, action: GameAction): GameState {
passengers: action.passengers
}
case 'BOARD_PASSENGER':
return {
...state,
passengers: state.passengers.map(p =>
p.id === action.passengerId ? { ...p, isBoarded: true } : p
)
}
case 'DELIVER_PASSENGER':
return {
...state,

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react'
import { useComplementRace } from '../context/ComplementRaceContext'
import { generatePassengers, findDeliverablePassengers } from '../lib/passengerGenerator'
import { generatePassengers, findBoardablePassengers, findDeliverablePassengers } from '../lib/passengerGenerator'
/**
* Steam Sprint momentum system
@@ -103,6 +103,21 @@ export function useSteamJourney() {
elapsedTime: elapsed
})
// Check for passengers that should board
const boardable = findBoardablePassengers(
state.passengers,
state.stations,
trainPosition
)
// Board passengers at their origin station
boardable.forEach(passenger => {
dispatch({
type: 'BOARD_PASSENGER',
passengerId: passenger.id
})
})
// Check for deliverable passengers
const deliverable = findDeliverablePassengers(
state.passengers,

View File

@@ -48,8 +48,11 @@ export interface Station {
export interface Passenger {
id: string
name: string
avatar: string
originStationId: string
destinationStationId: string
isUrgent: boolean
isBoarded: boolean
isDelivered: boolean
}
@@ -142,6 +145,7 @@ export type GameAction =
| { type: 'SHOW_RESULTS' }
| { type: 'RESET_GAME' }
| { type: 'GENERATE_PASSENGERS'; passengers: Passenger[] }
| { type: 'BOARD_PASSENGER'; passengerId: string }
| { type: 'DELIVER_PASSENGER'; passengerId: string; points: number }
| { type: 'START_NEW_ROUTE'; routeNumber: number; stations: Station[] }
| { type: 'COMPLETE_ROUTE' }

View File

@@ -1,11 +1,44 @@
import type { Passenger, Station } from './gameTypes'
// Passenger name pool (mix of diverse names)
const PASSENGER_NAMES = [
'Alice', 'Bob', 'Charlie', 'Diana', 'Ethan', 'Fiona', 'George', 'Hannah',
'Ian', 'Julia', 'Kevin', 'Laura', 'Marcus', 'Nina', 'Oliver', 'Petra',
'Quinn', 'Rosa', 'Sam', 'Tessa', 'Uma', 'Victor', 'Wendy', 'Xavier',
'Yuki', 'Zara', 'Ahmed', 'Bella', 'Carlos', 'Devi', 'Elias', 'Fatima'
// Names and avatars organized by gender presentation
const MASCULINE_NAMES = [
'Ahmed', 'Bob', 'Carlos', 'Elias', 'Ethan', 'George', 'Ian', 'Kevin',
'Marcus', 'Oliver', 'Victor', 'Xavier', 'Raj', 'David', 'Miguel', 'Jin'
]
const FEMININE_NAMES = [
'Alice', 'Bella', 'Diana', 'Devi', 'Fatima', 'Fiona', 'Hannah', 'Julia',
'Laura', 'Nina', 'Petra', 'Rosa', 'Tessa', 'Uma', 'Wendy', 'Zara', 'Yuki'
]
const GENDER_NEUTRAL_NAMES = [
'Alex', 'Charlie', 'Jordan', 'Morgan', 'Quinn', 'Riley', 'Sam', 'Taylor'
]
// Masculine-presenting avatars
const MASCULINE_AVATARS = [
'👨', '👨🏻', '👨🏼', '👨🏽', '👨🏾', '👨🏿',
'👴', '👴🏻', '👴🏼', '👴🏽', '👴🏾', '👴🏿',
'👦', '👦🏻', '👦🏼', '👦🏽', '👦🏾', '👦🏿',
'🧔', '🧔🏻', '🧔🏼', '🧔🏽', '🧔🏾', '🧔🏿',
'👨‍🦱', '👨🏻‍🦱', '👨🏼‍🦱', '👨🏽‍🦱', '👨🏾‍🦱', '👨🏿‍🦱',
'👨‍🦰', '👨🏻‍🦰', '👨🏼‍🦰', '👨🏽‍🦰', '👨🏾‍🦰', '👨🏿‍🦰',
'👱', '👱🏻', '👱🏼', '👱🏽', '👱🏾', '👱🏿'
]
// Feminine-presenting avatars
const FEMININE_AVATARS = [
'👩', '👩🏻', '👩🏼', '👩🏽', '👩🏾', '👩🏿',
'👵', '👵🏻', '👵🏼', '👵🏽', '👵🏾', '👵🏿',
'👧', '👧🏻', '👧🏼', '👧🏽', '👧🏾', '👧🏿',
'👩‍🦱', '👩🏻‍🦱', '👩🏼‍🦱', '👩🏽‍🦱', '👩🏾‍🦱', '👩🏿‍🦱',
'👩‍🦰', '👩🏻‍🦰', '👩🏼‍🦰', '👩🏽‍🦰', '👩🏾‍🦰', '👩🏿‍🦰',
'👱‍♀️', '👱🏻‍♀️', '👱🏼‍♀️', '👱🏽‍♀️', '👱🏾‍♀️', '👱🏿‍♀️'
]
// Gender-neutral avatars
const NEUTRAL_AVATARS = [
'🧑', '🧑🏻', '🧑🏼', '🧑🏽', '🧑🏾', '🧑🏿'
]
/**
@@ -15,19 +48,62 @@ const PASSENGER_NAMES = [
export function generatePassengers(stations: Station[]): Passenger[] {
const count = Math.floor(Math.random() * 3) + 3 // 3-5 passengers
const passengers: Passenger[] = []
const usedNames = new Set<string>()
const usedCombos = new Set<string>()
for (let i = 0; i < count; i++) {
// Pick a unique name
let name: string
do {
name = PASSENGER_NAMES[Math.floor(Math.random() * PASSENGER_NAMES.length)]
} while (usedNames.has(name) && usedNames.size < PASSENGER_NAMES.length)
usedNames.add(name)
let avatar: string
let comboKey: string
// Pick a random destination (exclude first station - Depot)
const destinationStations = stations.slice(1) // Exclude starting depot
const destination = destinationStations[Math.floor(Math.random() * destinationStations.length)]
// Keep trying until we get a unique name/avatar combo
do {
// Randomly choose a gender category
const genderRoll = Math.random()
let namePool: string[]
let avatarPool: string[]
if (genderRoll < 0.45) {
// 45% masculine
namePool = MASCULINE_NAMES
avatarPool = MASCULINE_AVATARS
} else if (genderRoll < 0.9) {
// 45% feminine
namePool = FEMININE_NAMES
avatarPool = FEMININE_AVATARS
} else {
// 10% neutral
namePool = GENDER_NEUTRAL_NAMES
avatarPool = NEUTRAL_AVATARS
}
// Pick from the chosen category
name = namePool[Math.floor(Math.random() * namePool.length)]
avatar = avatarPool[Math.floor(Math.random() * avatarPool.length)]
comboKey = `${name}-${avatar}`
} while (usedCombos.has(comboKey) && usedCombos.size < 100) // Prevent infinite loop
usedCombos.add(comboKey)
// Pick random origin and destination stations (must be different)
// 40% chance to start at depot, 60% chance to start at other stations
let originStation: Station
let destination: Station
if (Math.random() < 0.4 || stations.length < 3) {
// Start at depot
originStation = stations[0]
// Pick any other station as destination
const otherStations = stations.slice(1)
destination = otherStations[Math.floor(Math.random() * otherStations.length)]
} else {
// Start at a random non-depot station
const nonDepotStations = stations.slice(1)
originStation = nonDepotStations[Math.floor(Math.random() * nonDepotStations.length)]
// Pick a different station as destination (can include depot)
const possibleDestinations = stations.filter(s => s.id !== originStation.id)
destination = possibleDestinations[Math.floor(Math.random() * possibleDestinations.length)]
}
// 30% chance of urgent
const isUrgent = Math.random() < 0.3
@@ -35,8 +111,11 @@ export function generatePassengers(stations: Station[]): Passenger[] {
passengers.push({
id: `passenger-${Date.now()}-${i}`,
name,
avatar,
originStationId: originStation.id,
destinationStationId: destination.id,
isUrgent,
isBoarded: false,
isDelivered: false
})
}
@@ -51,6 +130,31 @@ export function isTrainAtStation(trainPosition: number, stationPosition: number)
return Math.abs(trainPosition - stationPosition) < 3
}
/**
* Find passengers that should board at current position
*/
export function findBoardablePassengers(
passengers: Passenger[],
stations: Station[],
trainPosition: number
): Passenger[] {
const boardable: Passenger[] = []
for (const passenger of passengers) {
// Skip if already boarded or delivered
if (passenger.isBoarded || passenger.isDelivered) continue
const station = stations.find(s => s.id === passenger.originStationId)
if (!station) continue
if (isTrainAtStation(trainPosition, station.position)) {
boardable.push(passenger)
}
}
return boardable
}
/**
* Find passengers that should be delivered at current position
*/
@@ -62,7 +166,8 @@ export function findDeliverablePassengers(
const deliverable: Array<{ passenger: Passenger; station: Station; points: number }> = []
for (const passenger of passengers) {
if (passenger.isDelivered) continue
// Only check boarded passengers
if (!passenger.isBoarded || passenger.isDelivered) continue
const station = stations.find(s => s.id === passenger.destinationStationId)
if (!station) continue