fix(complement-race): track physical car indices to prevent boarding issues

Fixed critical bug where passengers were missing boarding opportunities
due to array index confusion after deliveries.

Problem: After a passenger was delivered, the currentBoardedPassengers
array would compact but physical car positions wouldn't change. The
occupiedCars map was using array indices instead of physical car numbers,
causing cars to incorrectly appear occupied.

Example bug scenario:
- Train has 4 cars (indices 0-3)
- Kate boards Car 0, Frank Car 1, Mia Car 2, Charlie Car 3
- Kate delivers from Car 0
- Array compacts: [Frank, Mia, Charlie] at indices [0, 1, 2]
- occupiedCars map now shows cars 0,1,2 occupied (WRONG!)
- Physical Car 0 is actually EMPTY, but Alice can't board

Solution: Added carIndex field to Passenger type to track physical car
number (0-N) independently from array position.

Changes:
- Added carIndex: number | null to Passenger type
- Updated Validator to store physical carIndex when boarding
- Updated Provider to pass carIndex in BOARD_PASSENGER moves
- Updated useSteamJourney to use passenger.carIndex for all car
  position calculations and occupancy tracking
- Reordered frame processing so DELIVERY moves dispatch before BOARDING
  moves to prevent race conditions
- Fixed configuration mismatch: client now uses server's authoritative
  maxConcurrentPassengers instead of calculating locally
- Updated visual display to use server's maxConcurrentPassengers

Also includes improved logging for debugging train configuration and
passenger movement.

🤖 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-17 17:36:00 -05:00
parent 511636400c
commit 53bbae84af
14 changed files with 445 additions and 374 deletions

View File

@@ -1,7 +1,7 @@
'use client'
import { memo } from 'react'
import type { Passenger, Station } from '../lib/gameTypes'
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
interface PassengerCardProps {
passenger: Passenger
@@ -17,24 +17,27 @@ export const PassengerCard = memo(function PassengerCard({
if (!destinationStation || !originStation) return null
// Vintage train station colors
const bgColor = passenger.isDelivered
// Arcade room multiplayer uses claimedBy/deliveredBy instead of isBoarded/isDelivered
const isBoarded = passenger.claimedBy !== null
const isDelivered = passenger.deliveredBy !== null
const bgColor = isDelivered
? '#1a3a1a' // Dark green for delivered
: !passenger.isBoarded
: !isBoarded
? '#2a2419' // Dark brown/sepia for waiting
: passenger.isUrgent
? '#3a2419' // Dark red-brown for urgent
: '#1a2a3a' // Dark blue for aboard
const accentColor = passenger.isDelivered
const accentColor = isDelivered
? '#4ade80' // Green
: !passenger.isBoarded
: !isBoarded
? '#d4af37' // Gold for waiting
: passenger.isUrgent
? '#ff6b35' // Orange-red for urgent
: '#60a5fa' // Blue for aboard
const borderColor =
passenger.isUrgent && passenger.isBoarded && !passenger.isDelivered ? '#ff6b35' : '#d4af37'
const borderColor = passenger.isUrgent && isBoarded && !isDelivered ? '#ff6b35' : '#d4af37'
return (
<div
@@ -46,13 +49,13 @@ export const PassengerCard = memo(function PassengerCard({
minWidth: '220px',
maxWidth: '280px',
boxShadow:
passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded
passenger.isUrgent && !isDelivered && isBoarded
? '0 0 16px rgba(255, 107, 53, 0.5)'
: '0 4px 12px rgba(0, 0, 0, 0.4)',
position: 'relative',
fontFamily: '"Courier New", Courier, monospace',
animation:
passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded
passenger.isUrgent && !isDelivered && isBoarded
? 'urgentFlicker 1.5s ease-in-out infinite'
: 'none',
transition: 'all 0.3s ease',
@@ -79,7 +82,7 @@ export const PassengerCard = memo(function PassengerCard({
}}
>
<div style={{ fontSize: '20px', lineHeight: '1' }}>
{passenger.isDelivered ? '✅' : passenger.avatar}
{isDelivered ? '✅' : passenger.avatar}
</div>
<div
style={{
@@ -109,7 +112,7 @@ export const PassengerCard = memo(function PassengerCard({
marginTop: '0',
}}
>
{passenger.isDelivered ? 'DLVRD' : passenger.isBoarded ? 'BOARD' : 'WAIT'}
{isDelivered ? 'DLVRD' : isBoarded ? 'BOARD' : 'WAIT'}
</div>
</div>
@@ -187,7 +190,7 @@ export const PassengerCard = memo(function PassengerCard({
</div>
{/* Points badge */}
{!passenger.isDelivered && (
{!isDelivered && (
<div
style={{
position: 'absolute',
@@ -208,7 +211,7 @@ export const PassengerCard = memo(function PassengerCard({
)}
{/* Urgent indicator */}
{passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded && (
{passenger.isUrgent && !isDelivered && isBoarded && (
<div
style={{
position: 'absolute',

View File

@@ -1,7 +1,8 @@
'use client'
import { memo } from 'react'
import type { ComplementQuestion, Passenger, Station } from '../../lib/gameTypes'
import type { ComplementQuestion } from '../../lib/gameTypes'
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
import { AbacusTarget } from '../AbacusTarget'
import { PassengerCard } from '../PassengerCard'
import { PressureGauge } from '../PressureGauge'

View File

@@ -1,7 +1,7 @@
'use client'
import { memo } from 'react'
import type { Passenger, Station } from '../../lib/gameTypes'
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
import type { Landmark } from '../../lib/landmarks'
interface RailroadTrackPathProps {
@@ -100,18 +100,19 @@ export const RailroadTrackPath = memo(
{stationPositions.map((pos, index) => {
const station = stations[index]
// Find passengers waiting at this station (exclude currently boarding)
// Arcade room multiplayer uses claimedBy/deliveredBy instead of isBoarded/isDelivered
const waitingPassengers = passengers.filter(
(p) =>
p.originStationId === station?.id &&
!p.isBoarded &&
!p.isDelivered &&
p.claimedBy === null &&
p.deliveredBy === null &&
!boardingAnimations.has(p.id)
)
// Find passengers delivered at this station (exclude currently disembarking)
const deliveredPassengers = passengers.filter(
(p) =>
p.destinationStationId === station?.id &&
p.isDelivered &&
p.deliveredBy !== null &&
!disembarkingAnimations.has(p.id)
)

View File

@@ -14,7 +14,6 @@ import type { ComplementQuestion } from '../../lib/gameTypes'
import { useSteamJourney } from '../../hooks/useSteamJourney'
import { useTrackManagement } from '../../hooks/useTrackManagement'
import { useTrainTransforms } from '../../hooks/useTrainTransforms'
import { calculateMaxConcurrentPassengers } from '../../lib/passengerGenerator'
import { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
import { getRouteTheme } from '../../lib/routeThemes'
import { GameHUD } from './GameHUD'
@@ -94,9 +93,6 @@ export function SteamTrainJourney({
currentInput,
}: SteamTrainJourneyProps) {
const { state } = useComplementRace()
console.log(
`🚂 Train: mom=${momentum} pos=${trainPosition} stations=${state.stations.length} passengers=${state.passengers.length}`
)
const { getSkyGradient, getTimeOfDayPeriod } = useSteamJourney()
const _skyGradient = getSkyGradient()
@@ -113,12 +109,9 @@ export function SteamTrainJourney({
const pathRef = useRef<SVGPathElement>(null)
const [trackGenerator] = useState(() => new RailroadTrackGenerator(800, 600))
// 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])
// Use server's authoritative maxConcurrentPassengers calculation
// This ensures visual display matches game logic and console logs
const maxCars = Math.max(1, state.maxConcurrentPassengers || 3)
const carSpacing = 7 // Distance between cars (in % of track)
@@ -170,13 +163,14 @@ export function SteamTrainJourney({
const routeTheme = getRouteTheme(state.currentRoute)
// Memoize filtered passenger lists to avoid recalculating on every render
// Arcade room multiplayer uses claimedBy/deliveredBy instead of isBoarded/isDelivered
const boardedPassengers = useMemo(
() => displayPassengers.filter((p) => p.isBoarded && !p.isDelivered),
() => displayPassengers.filter((p) => p.claimedBy !== null && p.deliveredBy === null),
[displayPassengers]
)
const nonDeliveredPassengers = useMemo(
() => displayPassengers.filter((p) => !p.isDelivered),
() => displayPassengers.filter((p) => p.deliveredBy === null),
[displayPassengers]
)

View File

@@ -2,7 +2,7 @@
import { memo } from 'react'
import type { BoardingAnimation, DisembarkingAnimation } from '../../hooks/usePassengerAnimations'
import type { Passenger } from '../../lib/gameTypes'
import type { Passenger } from '@/arcade-games/complement-race/types'
interface TrainCarTransform {
x: number

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef } from 'react'
import { useComplementRace } from '@/arcade-games/complement-race/Provider'
import { calculateMaxConcurrentPassengers, generatePassengers } from '../lib/passengerGenerator'
import { useSoundEffects } from './useSoundEffects'
/**
@@ -44,47 +43,42 @@ export function useSteamJourney() {
const gameStartTimeRef = useRef<number>(0)
const lastUpdateRef = useRef<number>(0)
const routeExitThresholdRef = useRef<number>(107) // Default for 1 car: 100 + 7
const missedPassengersRef = useRef<Set<string>>(new Set()) // Track which passengers have been logged as missed
const pendingBoardingRef = useRef<Set<string>>(new Set()) // Track passengers with pending boarding requests across frames
// Initialize game start time and generate initial passengers
// Initialize game start time
useEffect(() => {
if (state.isGameActive && state.style === 'sprint' && gameStartTimeRef.current === 0) {
gameStartTimeRef.current = Date.now()
lastUpdateRef.current = Date.now()
// Generate initial passengers if none exist
if (state.passengers.length === 0) {
const newPassengers = generatePassengers(state.stations)
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
// Calculate and store exit threshold for this route
const CAR_SPACING = 7
const maxPassengers = calculateMaxConcurrentPassengers(newPassengers, state.stations)
const maxCars = Math.max(1, maxPassengers)
routeExitThresholdRef.current = 100 + maxCars * CAR_SPACING
}
// Log initial game state once at start
console.log('🚂 GAME START - Sprint Mode initialized')
console.log(
`Stations: ${state.stations.map((s) => `${s.emoji} ${s.name} (${s.position}%)`).join(', ')}`
)
console.log(`Passengers: ${state.passengers.length}`)
state.passengers.forEach((p) => {
const origin = state.stations.find((s) => s.id === p.originStationId)
const dest = state.stations.find((s) => s.id === p.destinationStationId)
console.log(
` - ${p.name}: ${origin?.emoji} ${origin?.name}${dest?.emoji} ${dest?.name}${p.isUrgent ? ' [URGENT]' : ''}`
)
})
}
}, [
state.isGameActive,
state.style,
state.stations,
state.passengers.length,
dispatch,
state.passengers,
])
}, [state.isGameActive, state.style, state.stations, state.passengers])
// Calculate exit threshold when route changes or config updates
useEffect(() => {
if (state.passengers.length > 0 && state.stations.length > 0) {
const CAR_SPACING = 7
// Use server-calculated maxConcurrentPassengers
const maxCars = Math.max(1, state.maxConcurrentPassengers || 3)
routeExitThresholdRef.current = 100 + maxCars * CAR_SPACING
}
}, [state.currentRoute, state.passengers, state.stations, state.maxConcurrentPassengers])
// Clean up pendingBoardingRef when passengers are claimed/delivered or route changes
useEffect(() => {
// Remove passengers from pending set if they've been claimed or delivered
state.passengers.forEach((passenger) => {
if (passenger.claimedBy !== null || passenger.deliveredBy !== null) {
pendingBoardingRef.current.delete(passenger.id)
}
})
}, [state.passengers])
// Clear all pending boarding requests when route changes
useEffect(() => {
pendingBoardingRef.current.clear()
missedPassengersRef.current.clear()
}, [state.currentRoute])
// Momentum decay and position update loop
useEffect(() => {
@@ -133,22 +127,41 @@ export function useSteamJourney() {
// Check for passengers that should board
// Passengers board when an EMPTY car reaches their station
const CAR_SPACING = 7 // Must match SteamTrainJourney component
const maxPassengers = calculateMaxConcurrentPassengers(state.passengers, state.stations)
const maxCars = Math.max(1, maxPassengers)
// Use server-calculated maxConcurrentPassengers (updates per route based on passenger layout)
const maxCars = Math.max(1, state.maxConcurrentPassengers || 3)
// Debug: Log train configuration at start (only once per route)
if (trainPosition < 1 && state.passengers.length > 0) {
const lastLoggedRoute = (window as any).__lastLoggedRoute || 0
if (lastLoggedRoute !== state.currentRoute) {
console.log(
`\n🚆 ROUTE ${state.currentRoute} START - Train has ${maxCars} cars (server maxConcurrentPassengers: ${state.maxConcurrentPassengers}) for ${state.passengers.length} passengers`
)
state.passengers.forEach((p) => {
const origin = state.stations.find((s) => s.id === p.originStationId)
const dest = state.stations.find((s) => s.id === p.destinationStationId)
console.log(
` 📍 ${p.name}: ${origin?.emoji} ${origin?.name} (${origin?.position}) → ${dest?.emoji} ${dest?.name} (${dest?.position}) ${p.isUrgent ? '⚡' : ''}`
)
})
console.log('') // Blank line for readability
;(window as any).__lastLoggedRoute = state.currentRoute
}
}
const currentBoardedPassengers = state.passengers.filter(
(p) => p.claimedBy !== null && p.deliveredBy === null
)
// FIRST: Identify which passengers will be delivered in this frame
const passengersToDeliver = new Set<string>()
currentBoardedPassengers.forEach((passenger, carIndex) => {
if (!passenger || passenger.deliveredBy !== null) return
currentBoardedPassengers.forEach((passenger) => {
if (!passenger || passenger.deliveredBy !== null || passenger.carIndex === null) return
const station = state.stations.find((s) => s.id === passenger.destinationStationId)
if (!station) return
// Calculate this passenger's car position
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
// Calculate this passenger's car position using PHYSICAL carIndex
const carPosition = Math.max(0, trainPosition - (passenger.carIndex + 1) * CAR_SPACING)
const distance = Math.abs(carPosition - station.position)
// If this car is at the destination station (within 5% tolerance), mark for delivery
@@ -157,67 +170,33 @@ export function useSteamJourney() {
}
})
// Build a map of which cars are occupied (excluding passengers being delivered this frame)
// Build a map of which cars are occupied (using PHYSICAL car index, not array index!)
// This is critical: passenger.carIndex stores the physical car (0-N) they're seated in
const occupiedCars = new Map<number, (typeof currentBoardedPassengers)[0]>()
currentBoardedPassengers.forEach((passenger, arrayIndex) => {
currentBoardedPassengers.forEach((passenger) => {
// Don't count a car as occupied if its passenger is being delivered this frame
if (!passengersToDeliver.has(passenger.id)) {
occupiedCars.set(arrayIndex, passenger)
if (!passengersToDeliver.has(passenger.id) && passenger.carIndex !== null) {
occupiedCars.set(passenger.carIndex, passenger) // Use physical carIndex, NOT array index!
}
})
// Track which cars are assigned in THIS frame to prevent double-boarding
const carsAssignedThisFrame = new Set<number>()
// Find waiting passengers whose origin station has an empty car nearby
state.passengers.forEach((passenger) => {
if (passenger.claimedBy !== null || passenger.deliveredBy !== null) return
const station = state.stations.find((s) => s.id === passenger.originStationId)
if (!station) return
// 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++) {
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
const distance = Math.abs(carPosition - station.position)
// Skip if this car already has a passenger OR was assigned this frame
if (occupiedCars.has(carIndex) || carsAssignedThisFrame.has(carIndex)) continue
// If car is at or near station (within 5% tolerance for fast trains), board this passenger
if (distance < 5) {
console.log(
`🚂 BOARDING: ${passenger.name} boarding Car ${carIndex} at ${station.emoji} ${station.name} (trainPos=${trainPosition.toFixed(1)}, carPos=${carPosition.toFixed(1)}, stationPos=${station.position})`
)
dispatch({
type: 'BOARD_PASSENGER',
passengerId: passenger.id,
})
// Mark this car as assigned in this frame
carsAssignedThisFrame.add(carIndex)
return // Board this passenger and move on
}
}
})
// Check for deliverable passengers
// Passengers disembark when THEIR car reaches their destination
currentBoardedPassengers.forEach((passenger, carIndex) => {
if (!passenger || passenger.deliveredBy !== null) return
// PRIORITY 1: Process deliveries FIRST (dispatch DELIVER moves before BOARD moves)
// This ensures the server frees up cars before processing new boarding requests
currentBoardedPassengers.forEach((passenger) => {
if (!passenger || passenger.deliveredBy !== null || passenger.carIndex === null) return
const station = state.stations.find((s) => s.id === passenger.destinationStationId)
if (!station) return
// Calculate this passenger's car position
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
// Calculate this passenger's car position using PHYSICAL carIndex
const carPosition = Math.max(0, trainPosition - (passenger.carIndex + 1) * CAR_SPACING)
const distance = Math.abs(carPosition - station.position)
// If this car is at the destination station (within 5% tolerance), deliver
if (distance < 5) {
const points = passenger.isUrgent ? 20 : 10
console.log(
`🎯 DELIVERY: ${passenger.name} delivered from Car ${carIndex} to ${station.emoji} ${station.name} (+${points} pts) (trainPos=${trainPosition.toFixed(1)}, carPos=${carPosition.toFixed(1)}, stationPos=${station.position})`
`🎯 DELIVERY: ${passenger.name} delivered from Car ${passenger.carIndex} to ${station.emoji} ${station.name} (+${points} pts) (trainPos=${trainPosition.toFixed(1)}, carPos=${carPosition.toFixed(1)}, stationPos=${station.position})`
)
dispatch({
type: 'DELIVER_PASSENGER',
@@ -227,8 +206,119 @@ export function useSteamJourney() {
}
})
// Debug: Log car states periodically at stations
const isAtStation = state.stations.some((s) => Math.abs(trainPosition - s.position) < 3)
if (isAtStation && Math.floor(trainPosition) !== Math.floor(state.trainPosition)) {
const nearStation = state.stations.find((s) => Math.abs(trainPosition - s.position) < 3)
console.log(
`\n🚃 Train arriving at ${nearStation?.emoji} ${nearStation?.name} (trainPos=${trainPosition.toFixed(1)}) - ${maxCars} cars total:`
)
for (let i = 0; i < maxCars; i++) {
const carPos = Math.max(0, trainPosition - (i + 1) * CAR_SPACING)
const occupant = occupiedCars.get(i)
if (occupant) {
const dest = state.stations.find((s) => s.id === occupant.destinationStationId)
console.log(
` Car ${i}: @ ${carPos.toFixed(1)}% - ${occupant.name}${dest?.emoji} ${dest?.name}`
)
} else {
console.log(` Car ${i}: @ ${carPos.toFixed(1)}% - EMPTY`)
}
}
}
// Track which cars are assigned in THIS frame to prevent double-boarding
const carsAssignedThisFrame = new Set<number>()
// Track which passengers are assigned in THIS frame to prevent same passenger boarding multiple cars
const passengersAssignedThisFrame = new Set<string>()
// PRIORITY 2: Process boardings AFTER deliveries
// Find waiting passengers whose origin station has an empty car nearby
state.passengers.forEach((passenger) => {
// Skip if already claimed or delivered (optimistic update marks immediately)
if (passenger.claimedBy !== null || passenger.deliveredBy !== null) return
// Skip if already assigned in this frame OR has a pending boarding request from previous frames
if (
passengersAssignedThisFrame.has(passenger.id) ||
pendingBoardingRef.current.has(passenger.id)
)
return
const station = state.stations.find((s) => s.id === passenger.originStationId)
if (!station) return
// Don't allow boarding if locomotive has passed too far beyond this station
// Station stays open until the LAST car has passed (accounting for train length)
const STATION_CLOSURE_BUFFER = 10 // Extra buffer beyond the last car
const lastCarOffset = maxCars * CAR_SPACING // Distance from locomotive to last car
const stationClosureThreshold = lastCarOffset + STATION_CLOSURE_BUFFER
if (trainPosition > station.position + stationClosureThreshold) {
console.log(
`❌ MISSED: ${passenger.name} at ${station.emoji} ${station.name} - train too far past (trainPos=${trainPosition.toFixed(1)}, station=${station.position}, threshold=${stationClosureThreshold})`
)
return
}
// Check if any empty car is at this station
// Cars are at positions: trainPosition - 7, trainPosition - 14, etc.
let closestCarDistance = 999
let closestCarReason = ''
for (let carIndex = 0; carIndex < maxCars; carIndex++) {
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
const distance = Math.abs(carPosition - station.position)
if (distance < closestCarDistance) {
closestCarDistance = distance
if (occupiedCars.has(carIndex)) {
const occupant = occupiedCars.get(carIndex)
closestCarReason = `Car ${carIndex} occupied by ${occupant?.name}`
} else if (carsAssignedThisFrame.has(carIndex)) {
closestCarReason = `Car ${carIndex} just assigned`
} else if (distance >= 5) {
closestCarReason = `Car ${carIndex} too far (dist=${distance.toFixed(1)})`
} else {
closestCarReason = 'available'
}
}
// Skip if this car already has a passenger OR was assigned this frame
if (occupiedCars.has(carIndex) || carsAssignedThisFrame.has(carIndex)) continue
// If car is at or near station (within 5% tolerance for fast trains), board this passenger
if (distance < 5) {
console.log(
`🚂 BOARDING: ${passenger.name} boarding Car ${carIndex} at ${station.emoji} ${station.name} (trainPos=${trainPosition.toFixed(1)}, carPos=${carPosition.toFixed(1)}, stationPos=${station.position})`
)
// Mark as pending BEFORE dispatch to prevent duplicate boarding attempts across frames
pendingBoardingRef.current.add(passenger.id)
dispatch({
type: 'BOARD_PASSENGER',
passengerId: passenger.id,
carIndex, // Pass physical car index to server
})
// Mark this car and passenger as assigned in this frame
carsAssignedThisFrame.add(carIndex)
passengersAssignedThisFrame.add(passenger.id)
return // Board this passenger and move on
}
}
// If we get here, passenger wasn't boarded - log why
if (closestCarDistance < 10) {
// Only log if train is somewhat near
console.log(
`⏸️ WAITING: ${passenger.name} at ${station.emoji} ${station.name} - ${closestCarReason} (trainPos=${trainPosition.toFixed(1)}, maxCars=${maxCars})`
)
}
})
// Check for route completion (entire train exits tunnel)
// Use stored threshold (stable for entire route)
const ENTIRE_TRAIN_EXIT_THRESHOLD = routeExitThresholdRef.current
if (
@@ -249,47 +339,12 @@ export function useSteamJourney() {
stations: state.stations,
})
// Generate new passengers
const newPassengers = generatePassengers(state.stations)
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
// Calculate and store new exit threshold for next route
const newMaxPassengers = calculateMaxConcurrentPassengers(newPassengers, state.stations)
const newMaxCars = Math.max(1, newMaxPassengers)
routeExitThresholdRef.current = 100 + newMaxCars * CAR_SPACING
// Note: New passengers will be generated by the server when it handles START_NEW_ROUTE
}
}, UPDATE_INTERVAL)
return () => clearInterval(interval)
}, [
state.isGameActive,
state.style,
state.momentum,
state.trainPosition,
state.timeoutSetting,
state.passengers,
state.stations,
state.currentRoute,
dispatch,
playSound,
])
// Auto-regenerate passengers when all are delivered
useEffect(() => {
if (!state.isGameActive || state.style !== 'sprint') return
// Check if all passengers are delivered
const allDelivered =
state.passengers.length > 0 && state.passengers.every((p) => p.deliveredBy !== null)
if (allDelivered) {
// Generate new passengers after a short delay
setTimeout(() => {
const newPassengers = generatePassengers(state.stations)
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
}, 1000)
}
}, [state.isGameActive, state.style, state.passengers, state.stations, dispatch])
}, [state.isGameActive, state.style, state.timeoutSetting, dispatch, playSound])
// Add momentum on correct answer
useEffect(() => {

View File

@@ -38,6 +38,7 @@ interface CompatibleGameState {
style: string
timeoutSetting: string
complementDisplay: string
maxConcurrentPassengers: number
// Current question (extracted from currentQuestions[localPlayerId])
currentQuestion: any | null
@@ -102,7 +103,7 @@ interface ComplementRaceContextValue {
lastError: string | null
startGame: () => void
submitAnswer: (answer: number, responseTime: number) => void
claimPassenger: (passengerId: string) => void
claimPassenger: (passengerId: string, carIndex: number) => void
deliverPassenger: (passengerId: string) => void
nextQuestion: () => void
endGame: () => void
@@ -129,12 +130,71 @@ export function useComplementRace() {
/**
* Optimistic move application (client-side prediction)
* For now, just return current state - server will validate and send back authoritative state
* Apply moves immediately on client for responsive UI, server will confirm or reject
*/
function applyMoveOptimistically(state: ComplementRaceState, move: GameMove): ComplementRaceState {
// Simple optimistic updates can be added here later
// For now, rely on server validation
return state
const typedMove = move as ComplementRaceMove
switch (typedMove.type) {
case 'CLAIM_PASSENGER': {
// Optimistically mark passenger as claimed and assign to car
const passengerId = typedMove.data.passengerId
const carIndex = typedMove.data.carIndex
const updatedPassengers = state.passengers.map((p) =>
p.id === passengerId ? { ...p, claimedBy: typedMove.playerId, carIndex } : p
)
// Optimistically add to player's passenger list
const updatedPlayers = { ...state.players }
const player = updatedPlayers[typedMove.playerId]
if (player) {
updatedPlayers[typedMove.playerId] = {
...player,
passengers: [...player.passengers, passengerId],
}
}
return {
...state,
passengers: updatedPassengers,
players: updatedPlayers,
}
}
case 'DELIVER_PASSENGER': {
// Optimistically mark passenger as delivered and award points
const passengerId = typedMove.data.passengerId
const passenger = state.passengers.find((p) => p.id === passengerId)
if (!passenger) return state
const points = passenger.isUrgent ? 20 : 10
const updatedPassengers = state.passengers.map((p) =>
p.id === passengerId ? { ...p, deliveredBy: typedMove.playerId } : p
)
// Optimistically remove from player's passenger list and update score
const updatedPlayers = { ...state.players }
const player = updatedPlayers[typedMove.playerId]
if (player) {
updatedPlayers[typedMove.playerId] = {
...player,
passengers: player.passengers.filter((id) => id !== passengerId),
deliveredPassengers: player.deliveredPassengers + 1,
score: player.score + points,
}
}
return {
...state,
passengers: updatedPassengers,
players: updatedPlayers,
}
}
default:
// For other moves, rely on server validation
return state
}
}
/**
@@ -300,6 +360,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
style: multiplayerState.config.style,
timeoutSetting: multiplayerState.config.timeoutSetting,
complementDisplay: multiplayerState.config.complementDisplay,
maxConcurrentPassengers: multiplayerState.config.maxConcurrentPassengers,
// Current question
currentQuestion: localPlayerId
@@ -439,28 +500,8 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
}
}, [multiplayerState.currentRoute, compatibleState.style])
// Debug logging: only log on answer submission or significant events
useEffect(() => {
if (compatibleState.style === 'sprint' && compatibleState.isGameActive) {
const key = `${compatibleState.correctAnswers}`
// Only log on new answers (not every frame)
if (lastLogRef.key !== key) {
console.log(
`🚂 Answer #${compatibleState.correctAnswers}: momentum=${compatibleState.momentum} pos=${Math.floor(compatibleState.trainPosition)} pressure=${compatibleState.pressure} streak=${compatibleState.streak}`
)
lastLogRef.key = key
}
}
}, [
compatibleState.correctAnswers,
compatibleState.momentum,
compatibleState.trainPosition,
compatibleState.pressure,
compatibleState.streak,
compatibleState.style,
compatibleState.isGameActive,
])
// Keep lastLogRef for future debugging needs
// (removed debug logging)
// Action creators
const startGame = useCallback(() => {
@@ -506,7 +547,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
)
const claimPassenger = useCallback(
(passengerId: string) => {
(passengerId: string, carIndex: number) => {
const currentPlayerId = activePlayers.find((id) => {
const player = players.get(id)
return player?.isLocal
@@ -518,7 +559,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
type: 'CLAIM_PASSENGER',
playerId: currentPlayerId,
userId: viewerId || '',
data: { passengerId },
data: { passengerId, carIndex },
} as ComplementRaceMove)
},
[activePlayers, players, viewerId, sendMove]
@@ -660,8 +701,8 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
break
case 'BOARD_PASSENGER':
case 'CLAIM_PASSENGER':
if (action.passengerId !== undefined) {
claimPassenger(action.passengerId)
if (action.passengerId !== undefined && action.carIndex !== undefined) {
claimPassenger(action.passengerId, action.carIndex)
}
break
case 'DELIVER_PASSENGER':
@@ -669,6 +710,17 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
deliverPassenger(action.passengerId)
}
break
case 'START_NEW_ROUTE':
// Send route progression to server
if (action.routeNumber !== undefined) {
sendMove({
type: 'START_NEW_ROUTE',
playerId: activePlayers[0] || '',
userId: viewerId || '',
data: { routeNumber: action.routeNumber },
} as ComplementRaceMove)
}
break
// Local UI state actions
case 'UPDATE_INPUT':
setLocalUIState((prev) => ({ ...prev, currentInput: action.input || '' }))
@@ -708,8 +760,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
case 'UPDATE_STEAM_JOURNEY':
case 'UPDATE_DIFFICULTY_TRACKER':
case 'UPDATE_AI_SPEEDS':
case 'GENERATE_PASSENGERS':
case 'START_NEW_ROUTE':
case 'GENERATE_PASSENGERS': // Passengers generated server-side when route starts
case 'COMPLETE_ROUTE':
case 'HIDE_ROUTE_CELEBRATION':
case 'COMPLETE_LAP':
@@ -729,6 +780,9 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
claimPassenger,
deliverPassenger,
multiplayerState.questionStartTime,
sendMove,
activePlayers,
viewerId,
]
)

View File

@@ -76,12 +76,6 @@ export class ComplementRaceValidator
implements GameValidator<ComplementRaceState, ComplementRaceMove>
{
validateMove(state: ComplementRaceState, move: ComplementRaceMove): ValidationResult {
console.log('[ComplementRace] Validating move:', {
type: move.type,
playerId: move.playerId,
gamePhase: state.gamePhase,
})
switch (move.type) {
case 'START_GAME':
return this.validateStartGame(state, move.data.activePlayers, move.data.playerMetadata)
@@ -104,7 +98,12 @@ export class ComplementRaceValidator
return this.validateUpdateInput(state, move.playerId, move.data.input)
case 'CLAIM_PASSENGER':
return this.validateClaimPassenger(state, move.playerId, move.data.passengerId)
return this.validateClaimPassenger(
state,
move.playerId,
move.data.passengerId,
move.data.carIndex
)
case 'DELIVER_PASSENGER':
return this.validateDeliverPassenger(state, move.playerId, move.data.passengerId)
@@ -190,8 +189,25 @@ export class ComplementRaceValidator
? this.generatePassengers(state.config.passengerCount, state.stations)
: []
// Calculate maxConcurrentPassengers based on initial passenger layout (sprint mode only)
let updatedConfig = state.config
if (state.config.style === 'sprint' && passengers.length > 0) {
const maxConcurrentPassengers = Math.max(
1,
this.calculateMaxConcurrentPassengers(passengers, state.stations)
)
console.log(
`[Game Start] Calculated maxConcurrentPassengers: ${maxConcurrentPassengers} for ${passengers.length} passengers`
)
updatedConfig = {
...state.config,
maxConcurrentPassengers,
}
}
const newState: ComplementRaceState = {
...state,
config: updatedConfig,
gamePhase: 'playing', // Go directly to playing (countdown can be added later)
activePlayers,
playerMetadata: playerMetadata as typeof state.playerMetadata,
@@ -387,7 +403,8 @@ export class ComplementRaceValidator
private validateClaimPassenger(
state: ComplementRaceState,
playerId: string,
passengerId: string
passengerId: string,
carIndex: number
): ValidationResult {
if (state.config.style !== 'sprint') {
return { valid: false, error: 'Passengers only available in sprint mode' }
@@ -429,11 +446,12 @@ export class ComplementRaceValidator
}
}
// Claim passenger
// Claim passenger and assign to physical car
const updatedPassengers = [...state.passengers]
updatedPassengers[passengerIndex] = {
...passenger,
claimedBy: playerId,
carIndex, // Store which physical car (0-N) the passenger is seated in
}
const newState: ComplementRaceState = {
@@ -539,12 +557,26 @@ export class ComplementRaceValidator
// Generate new passengers
const newPassengers = this.generatePassengers(state.config.passengerCount, state.stations)
// Calculate maxConcurrentPassengers based on the new route's passenger layout
const maxConcurrentPassengers = Math.max(
1,
this.calculateMaxConcurrentPassengers(newPassengers, state.stations)
)
console.log(
`[Route ${routeNumber}] Calculated maxConcurrentPassengers: ${maxConcurrentPassengers} for ${newPassengers.length} passengers`
)
const newState: ComplementRaceState = {
...state,
currentRoute: routeNumber,
routeStartTime: Date.now(),
players: resetPlayers,
passengers: newPassengers,
config: {
...state.config,
maxConcurrentPassengers, // Update config with calculated value
},
}
return { valid: true, newState }
@@ -665,34 +697,118 @@ export class ComplementRaceValidator
private generatePassengers(count: number, stations: Station[]): Passenger[] {
const passengers: Passenger[] = []
const usedCombos = new Set<string>()
for (let i = 0; i < count; i++) {
// Pick random origin and destination (must be different)
const originIndex = Math.floor(Math.random() * stations.length)
let destIndex = Math.floor(Math.random() * stations.length)
while (destIndex === originIndex) {
destIndex = Math.floor(Math.random() * stations.length)
let name: string
let avatar: string
let comboKey: string
// Keep trying until we get a unique name/avatar combo
do {
const nameIndex = Math.floor(Math.random() * PASSENGER_NAMES.length)
const avatarIndex = Math.floor(Math.random() * PASSENGER_AVATARS.length)
name = PASSENGER_NAMES[nameIndex]
avatar = PASSENGER_AVATARS[avatarIndex]
comboKey = `${name}-${avatar}`
} while (usedCombos.has(comboKey) && usedCombos.size < 100) // Prevent infinite loop
usedCombos.add(comboKey)
// Pick origin and destination stations
// KEY: Destination must be AHEAD of origin (higher position on track)
// This ensures passengers travel forward, creating better overlap
let originStation: Station
let destinationStation: Station
if (Math.random() < 0.4 || stations.length < 3) {
// 40% chance to start at depot (first station)
originStation = stations[0]
// Pick any station ahead as destination
const stationsAhead = stations.slice(1)
destinationStation = stationsAhead[Math.floor(Math.random() * stationsAhead.length)]
} else {
// Start at a random non-depot, non-final station
const nonDepotStations = stations.slice(1, -1) // Exclude depot and final station
originStation = nonDepotStations[Math.floor(Math.random() * nonDepotStations.length)]
// Pick a station ahead of origin (higher position)
const stationsAhead = stations.filter((s) => s.position > originStation.position)
destinationStation = stationsAhead[Math.floor(Math.random() * stationsAhead.length)]
}
const nameIndex = Math.floor(Math.random() * PASSENGER_NAMES.length)
const avatarIndex = Math.floor(Math.random() * PASSENGER_AVATARS.length)
// 30% chance of urgent
const isUrgent = Math.random() < 0.3
passengers.push({
const passenger = {
id: `p-${Date.now()}-${i}-${Math.random().toString(36).substr(2, 9)}`,
name: PASSENGER_NAMES[nameIndex],
avatar: PASSENGER_AVATARS[avatarIndex],
originStationId: stations[originIndex].id,
destinationStationId: stations[destIndex].id,
isUrgent: Math.random() < 0.3, // 30% chance of urgent
name,
avatar,
originStationId: originStation.id,
destinationStationId: destinationStation.id,
isUrgent,
claimedBy: null,
deliveredBy: null,
carIndex: null, // Not boarded yet
timestamp: Date.now(),
})
}
passengers.push(passenger)
console.log(
`[Passenger ${i + 1}/${count}] ${name} waiting at ${originStation.emoji} ${originStation.name} (pos ${originStation.position}) → ${destinationStation.emoji} ${destinationStation.name} (pos ${destinationStation.position}) ${isUrgent ? '⚡ URGENT' : ''}`
)
}
console.log(`[Generated ${passengers.length} passengers total]`)
return passengers
}
/**
* Calculate the maximum number of passengers that will be on the train
* concurrently at any given moment during the route
*/
private 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
}
private checkWinCondition(state: ComplementRaceState): string | null {
const { config, players } = state

View File

@@ -41,6 +41,7 @@ export interface Passenger {
isUrgent: boolean // Urgent passengers worth 2x points
claimedBy: string | null // playerId who picked up this passenger (null = unclaimed)
deliveredBy: string | null // playerId who delivered (null = not delivered yet)
carIndex: number | null // Physical car index (0-N) where passenger is seated (null = not boarded)
timestamp: number // When passenger spawned
}
@@ -142,7 +143,7 @@ export type ComplementRaceMove = BaseGameMove &
// Playing phase
| { type: 'SUBMIT_ANSWER'; data: { answer: number; responseTime: number } }
| { type: 'UPDATE_INPUT'; data: { input: string } } // Show "thinking" indicator
| { type: 'CLAIM_PASSENGER'; data: { passengerId: string } } // Sprint mode: pickup
| { type: 'CLAIM_PASSENGER'; data: { passengerId: string; carIndex: number } } // Sprint mode: pickup
| { type: 'DELIVER_PASSENGER'; data: { passengerId: string } } // Sprint mode: delivery
// Game flow

View File

@@ -107,32 +107,28 @@ export function useArcadeSession<TState>(
exitSession: socketExitSession,
} = useArcadeSocket({
onSessionState: (data) => {
console.log('[ArcadeSession] Syncing with server state')
optimistic.syncWithServer(data.gameState as TState, data.version)
},
onMoveAccepted: (data) => {
console.log('[ArcadeSession] Move accepted by server')
optimistic.handleMoveAccepted(data.gameState as TState, data.version, data.move)
},
onMoveRejected: (data) => {
console.log('[ArcadeSession] Move rejected by server:', data.error)
console.log(`[ArcadeSession] Move rejected: ${data.error}`)
optimistic.handleMoveRejected(data.error, data.move)
},
onSessionEnded: () => {
console.log('[ArcadeSession] Session ended')
optimistic.reset()
},
onNoActiveSession: () => {
console.log('[ArcadeSession] No active session found')
// Silent - normal state
},
onError: (data) => {
console.error('[ArcadeSession] Error:', data.error)
// Users can handle errors via the onMoveRejected callback
console.error(`[ArcadeSession] Error: ${data.error}`)
},
})

View File

@@ -62,22 +62,19 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
})
socketInstance.on('session-state', (data) => {
console.log('[ArcadeSocket] Received session state', data)
eventsRef.current.onSessionState?.(data)
})
socketInstance.on('no-active-session', () => {
console.log('[ArcadeSocket] No active session')
eventsRef.current.onNoActiveSession?.()
})
socketInstance.on('move-accepted', (data) => {
console.log('[ArcadeSocket] Move accepted', data)
eventsRef.current.onMoveAccepted?.(data)
})
socketInstance.on('move-rejected', (data) => {
console.log('[ArcadeSocket] Move rejected', data)
console.log(`[ArcadeSocket] Move rejected: ${data.error}`)
eventsRef.current.onMoveRejected?.(data)
})
@@ -124,12 +121,7 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
console.warn('[ArcadeSocket] Cannot send move - socket not connected')
return
}
const payload = { userId, move, roomId }
console.log(
'[ArcadeSocket] Sending game-move event with payload:',
JSON.stringify(payload, null, 2)
)
socket.emit('game-move', payload)
socket.emit('game-move', { userId, move, roomId })
},
[socket]
)

View File

@@ -451,7 +451,6 @@ export function useRoomData() {
gameName: string | null
gameConfig?: Record<string, unknown>
}) => {
console.log('[useRoomData] Room game changed:', data)
if (data.roomId === roomData?.id) {
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
if (!prev) return null
@@ -683,18 +682,6 @@ async function updateGameConfigApi(params: {
roomId: string
gameConfig: Record<string, unknown>
}): Promise<void> {
console.log(
'[updateGameConfigApi] Sending PATCH to server:',
JSON.stringify(
{
url: `/api/arcade/rooms/${params.roomId}/settings`,
gameConfig: params.gameConfig,
},
null,
2
)
)
const response = await fetch(`/api/arcade/rooms/${params.roomId}/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -705,11 +692,8 @@ async function updateGameConfigApi(params: {
if (!response.ok) {
const errorData = await response.json()
console.error('[updateGameConfigApi] Server error:', JSON.stringify(errorData, null, 2))
throw new Error(errorData.error || 'Failed to update game config')
}
console.log('[updateGameConfigApi] Server responded OK')
}
/**
@@ -730,10 +714,6 @@ export function useUpdateGameConfig() {
gameConfig: variables.gameConfig,
}
})
console.log(
'[useUpdateGameConfig] Updated cache with new gameConfig:',
JSON.stringify(variables.gameConfig, null, 2)
)
},
})
}

View File

@@ -79,11 +79,6 @@ export async function createArcadeSession(
// Check if session already exists for this room (roomId is PRIMARY KEY)
const existingRoomSession = await getArcadeSessionByRoom(options.roomId)
if (existingRoomSession) {
console.log('[Session Manager] Room session already exists, returning existing:', {
roomId: options.roomId,
sessionUserId: existingRoomSession.userId,
version: existingRoomSession.version,
})
return existingRoomSession
}
@@ -93,7 +88,6 @@ export async function createArcadeSession(
})
if (!user) {
console.log('[Session Manager] Creating new user with guestId:', options.userId)
const [newUser] = await db
.insert(schema.users)
.values({
@@ -102,9 +96,6 @@ export async function createArcadeSession(
})
.returning()
user = newUser
console.log('[Session Manager] Created user with id:', user.id)
} else {
console.log('[Session Manager] Found existing user with id:', user.id)
}
const newSession: schema.NewArcadeSession = {
@@ -121,12 +112,6 @@ export async function createArcadeSession(
version: 1,
}
console.log('[Session Manager] Creating new session:', {
roomId: options.roomId,
userId: user.id,
gameName: options.gameName,
})
try {
const [session] = await db.insert(schema.arcadeSessions).values(newSession).returning()
return session
@@ -134,10 +119,6 @@ export async function createArcadeSession(
// Handle PRIMARY KEY constraint violation (UNIQUE constraint on roomId)
// This can happen if two users try to create a session for the same room simultaneously
if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) {
console.log(
'[Session Manager] Session already exists (race condition), fetching existing session for room:',
options.roomId
)
const existingSession = await getArcadeSessionByRoom(options.roomId)
if (existingSession) {
return existingSession
@@ -180,7 +161,6 @@ export async function getArcadeSession(guestId: string): Promise<schema.ArcadeSe
})
if (!room) {
console.log('[Session Manager] Deleting session with non-existent room:', session.roomId)
await deleteArcadeSessionByRoom(session.roomId)
return undefined
}
@@ -220,16 +200,6 @@ export async function applyGameMove(
// Get the validator for this game
const validator = getValidator(session.currentGame as GameName)
console.log('[SessionManager] About to validate move:', {
gameName: session.currentGame,
moveType: move.type,
playerId: move.playerId,
moveData: move.type === 'SET_CONFIG' ? (move as any).data : undefined,
gameStateCurrentPlayer: (session.gameState as any)?.currentPlayer,
gameStateActivePlayers: (session.gameState as any)?.activePlayers,
gameStatePhase: (session.gameState as any)?.gamePhase,
})
// Fetch player ownership for authorization checks (room-based games)
let playerOwnership: PlayerOwnershipMap | undefined
let internalUserId: string | undefined
@@ -247,8 +217,6 @@ export async function applyGameMove(
// Use centralized ownership utility
playerOwnership = await buildPlayerOwnershipMap(session.roomId)
console.log('[SessionManager] Player ownership map:', playerOwnership)
console.log('[SessionManager] Internal userId for authorization:', internalUserId)
} catch (error) {
console.error('[SessionManager] Failed to fetch player ownership:', error)
}
@@ -260,11 +228,6 @@ export async function applyGameMove(
playerOwnership,
})
console.log('[SessionManager] Validation result:', {
valid: validationResult.valid,
error: validationResult.error,
})
if (!validationResult.valid) {
return {
success: false,
@@ -373,10 +336,6 @@ export async function updateSessionActivePlayers(
// Only update if game is in setup phase (not started yet)
const gameState = session.gameState as any
if (gameState.gamePhase !== 'setup') {
console.log('[Session Manager] Cannot update activePlayers - game already started:', {
roomId,
gamePhase: gameState.gamePhase,
})
return false
}
@@ -397,12 +356,6 @@ export async function updateSessionActivePlayers(
})
.where(eq(schema.arcadeSessions.roomId, roomId))
console.log('[Session Manager] Updated session activePlayers:', {
roomId,
playerIds,
count: playerIds.length,
})
return true
}

View File

@@ -41,7 +41,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
})
io.on('connection', (socket) => {
console.log('🔌 Client connected:', socket.id)
let currentUserId: string | null = null
// Join arcade session room
@@ -50,12 +49,10 @@ export function initializeSocketServer(httpServer: HTTPServer) {
async ({ userId, roomId }: { userId: string; roomId?: string }) => {
currentUserId = userId
socket.join(`arcade:${userId}`)
console.log(`👤 User ${userId} joined arcade room`)
// If this session is part of a room, also join the game room for multi-user sync
if (roomId) {
socket.join(`game:${roomId}`)
console.log(`🎮 User ${userId} joined game room ${roomId}`)
}
// Send current session state if exists
@@ -68,19 +65,14 @@ export function initializeSocketServer(httpServer: HTTPServer) {
// If no session exists for this room, create one in setup phase
// This allows users to send SET_CONFIG moves before starting the game
if (!session && roomId) {
console.log('[join-arcade-session] Creating initial session for room:', roomId)
// Get the room to determine game type and config
const room = await getRoomById(roomId)
if (room) {
// Fetch all active player IDs from room members (respects isActive flag)
const roomPlayerIds = await getRoomPlayerIds(roomId)
console.log('[join-arcade-session] Room active players:', roomPlayerIds)
// Get initial state from the correct validator based on game type
console.log('[join-arcade-session] Room game name:', room.gameName)
const validator = getValidator(room.gameName as GameName)
console.log('[join-arcade-session] Got validator for:', room.gameName)
// Get game-specific config from database (type-safe)
const gameConfig = await getGameConfig(roomId, room.gameName as GameName)
@@ -94,23 +86,10 @@ export function initializeSocketServer(httpServer: HTTPServer) {
activePlayers: roomPlayerIds, // Include all room members' active players
roomId: room.id,
})
console.log('[join-arcade-session] Created initial session:', {
roomId,
sessionId: session.userId,
gamePhase: (session.gameState as any).gamePhase,
activePlayersCount: roomPlayerIds.length,
})
}
}
if (session) {
console.log('[join-arcade-session] Found session:', {
userId,
roomId,
version: session.version,
sessionUserId: session.userId,
})
socket.emit('session-state', {
gameState: session.gameState,
currentGame: session.currentGame,
@@ -119,10 +98,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
version: session.version,
})
} else {
console.log('[join-arcade-session] No active session found for:', {
userId,
roomId,
})
socket.emit('no-active-session')
}
} catch (error) {
@@ -134,15 +109,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
// Handle game moves
socket.on('game-move', async (data: { userId: string; move: GameMove; roomId?: string }) => {
console.log('🎮 Game move received:', {
userId: data.userId,
moveType: data.move.type,
playerId: data.move.playerId,
timestamp: data.move.timestamp,
roomId: data.roomId,
fullMove: JSON.stringify(data.move, null, 2),
})
try {
// Special handling for START_GAME - create session if it doesn't exist
if (data.move.type === 'START_GAME') {
@@ -152,12 +118,9 @@ export function initializeSocketServer(httpServer: HTTPServer) {
: await getArcadeSession(data.userId)
if (!existingSession) {
console.log('🎯 Creating new session for START_GAME')
// activePlayers must be provided in the START_GAME move data
const activePlayers = (data.move.data as any)?.activePlayers
if (!activePlayers || activePlayers.length === 0) {
console.error('❌ START_GAME move missing activePlayers')
socket.emit('move-rejected', {
error: 'START_GAME requires at least one active player',
move: data.move,
@@ -186,7 +149,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
existingRoom.status !== 'finished'
) {
room = existingRoom
console.log('🏠 Using existing room:', room.code)
break
}
}
@@ -205,7 +167,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
},
ttlMinutes: 60,
})
console.log('🏠 Created new room:', room.code)
}
// Now create the session linked to the room
@@ -218,8 +179,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
roomId: room.id,
})
console.log('✅ Session created successfully with room association')
// Notify all connected clients about the new session
const newSession = await getArcadeSession(data.userId)
if (newSession) {
@@ -230,7 +189,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
activePlayers: newSession.activePlayers,
version: newSession.version,
})
console.log('📢 Emitted session-state to notify clients of new session')
}
}
}
@@ -251,7 +209,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
// If this is a room-based session, ALSO broadcast to all users in the room
if (result.session.roomId) {
io!.to(`game:${result.session.roomId}`).emit('move-accepted', moveAcceptedData)
console.log(`📢 Broadcasted move to game room ${result.session.roomId}`)
}
// Update activity timestamp
@@ -275,8 +232,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
// Handle session exit
socket.on('exit-arcade-session', async ({ userId }: { userId: string }) => {
console.log('🚪 User exiting arcade session:', userId)
try {
await deleteArcadeSession(userId)
io!.to(`arcade:${userId}`).emit('session-ended')
@@ -298,8 +253,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
// Room: Join
socket.on('join-room', async ({ roomId, userId }: { roomId: string; userId: string }) => {
console.log(`🏠 User ${userId} joining room ${roomId}`)
try {
// Join the socket room
socket.join(`room:${roomId}`)
@@ -323,10 +276,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
const sessionUpdated = await updateSessionActivePlayers(roomId, roomPlayerIds)
if (sessionUpdated) {
console.log(`🎮 Updated session activePlayers for room ${roomId}:`, {
playerCount: roomPlayerIds.length,
})
// Broadcast updated session state to all users in the game room
const updatedSession = await getArcadeSessionByRoom(roomId)
if (updatedSession) {
@@ -337,7 +286,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
activePlayers: updatedSession.activePlayers,
version: updatedSession.version,
})
console.log(`📢 Broadcasted updated session state to game room ${roomId}`)
}
}
@@ -355,8 +303,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
members,
memberPlayers: memberPlayersObj,
})
console.log(`✅ User ${userId} joined room ${roomId}`)
} catch (error) {
console.error('Error joining room:', error)
socket.emit('room-error', { error: 'Failed to join room' })
@@ -365,11 +311,9 @@ export function initializeSocketServer(httpServer: HTTPServer) {
// User Channel: Join (for moderation events)
socket.on('join-user-channel', async ({ userId }: { userId: string }) => {
console.log(`👤 User ${userId} joining user-specific channel`)
try {
// Join user-specific channel for moderation notifications
socket.join(`user:${userId}`)
console.log(`✅ User ${userId} joined user channel`)
} catch (error) {
console.error('Error joining user channel:', error)
}
@@ -377,8 +321,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
// Room: Leave
socket.on('leave-room', async ({ roomId, userId }: { roomId: string; userId: string }) => {
console.log(`🚪 User ${userId} leaving room ${roomId}`)
try {
// Leave the socket room
socket.leave(`room:${roomId}`)
@@ -403,8 +345,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
members,
memberPlayers: memberPlayersObj,
})
console.log(`✅ User ${userId} left room ${roomId}`)
} catch (error) {
console.error('Error leaving room:', error)
}
@@ -412,8 +352,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
// Room: Players updated
socket.on('players-updated', async ({ roomId, userId }: { roomId: string; userId: string }) => {
console.log(`🎯 Players updated for user ${userId} in room ${roomId}`)
try {
// Get updated player data
const memberPlayers = await getRoomActivePlayers(roomId)
@@ -429,11 +367,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
const sessionUpdated = await updateSessionActivePlayers(roomId, roomPlayerIds)
if (sessionUpdated) {
console.log(`🎮 Updated session activePlayers after player toggle:`, {
roomId,
playerCount: roomPlayerIds.length,
})
// Broadcast updated session state to all users in the game room
const updatedSession = await getArcadeSessionByRoom(roomId)
if (updatedSession) {
@@ -444,7 +377,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
activePlayers: updatedSession.activePlayers,
version: updatedSession.version,
})
console.log(`📢 Broadcasted updated session state to game room ${roomId}`)
}
}
@@ -453,8 +385,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
roomId,
memberPlayers: memberPlayersObj,
})
console.log(`✅ Broadcasted player updates for room ${roomId}`)
} catch (error) {
console.error('Error updating room players:', error)
socket.emit('room-error', { error: 'Failed to update players' })
@@ -462,16 +392,11 @@ export function initializeSocketServer(httpServer: HTTPServer) {
})
socket.on('disconnect', () => {
console.log('🔌 Client disconnected:', socket.id)
if (currentUserId) {
// Don't delete session on disconnect - it persists across devices
console.log(`👤 User ${currentUserId} disconnected but session persists`)
}
// Don't delete session on disconnect - it persists across devices
})
})
// Store in globalThis to make accessible across module boundaries
globalThis.__socketIO = io
console.log('✅ Socket.IO initialized on /api/socket')
return io
}