Compare commits

...

5 Commits

Author SHA1 Message Date
semantic-release-bot
e6c12e87e4 chore(release): 4.66.2 [skip ci]
## [4.66.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.66.1...v4.66.2) (2025-10-22)

### Bug Fixes

* **complement-race:** fix ghost train position update lag and reload position reset ([ad78a65](ad78a65ed7))
2025-10-22 18:44:19 +00:00
Thomas Hallock
ad78a65ed7 fix(complement-race): fix ghost train position update lag and reload position reset
Fixed three critical multiplayer issues:

1. **Fixed interval restart bug**: Position broadcast interval was constantly restarting
   because useEffect depended on `compatibleState`, which changed on every position
   update. Now uses stable dependencies (`multiplayerState.gamePhase`, etc.)

2. **Increased broadcast frequency**: Changed from 200ms (5 Hz) to 100ms (10 Hz)
   for smoother ghost train movement during multiplayer races

3. **Fixed position reset on reload**: Client position now syncs from server's
   authoritative position when browser reloads, preventing trains from resetting
   to start of track

Additional fixes:
- Used refs for `sendMove` to prevent interval recreation
- Removed unused imports (useEffect from GhostTrain, SteamTrainJourney)
- Added strategic logging for position broadcast and reception

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 13:43:02 -05:00
Thomas Hallock
b95fc1fdff debug(complement-race): add strategic logging to trace ghost train position updates
Removed all existing debug logs and added focused logging to identify why ghost
trains only update when players stop moving.

Strategic logging added:
- [POS_BROADCAST] Logs when position broadcast interval starts/stops
- [POS_BROADCAST] Throttled logging of position broadcasts (>2% change or 5s interval)
- [POS_RECEIVED] Logs when position updates are received from other players (>2% change)

This will help identify if:
1. Position broadcasts are being sent continuously during movement
2. Position updates are being received from the server
3. Updates are being processed and applied to ghost train positions

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 13:20:59 -05:00
semantic-release-bot
79bc0e4c80 chore(release): 4.66.1 [skip ci]
## [4.66.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.66.0...v4.66.1) (2025-10-22)

### Bug Fixes

* **complement-race:** ensure continuous position broadcasting during train movement ([df60824](df60824f37))
2025-10-22 18:14:21 +00:00
Thomas Hallock
df60824f37 fix(complement-race): ensure continuous position broadcasting during train movement
Fixed an issue where ghost trains only updated when players stopped moving.

Root cause: clientPosition in useEffect dependency array caused the
position broadcasting interval to restart on every position change,
creating gaps in broadcasts during continuous movement.

Solution:
- Use useRef to track latest clientPosition without triggering effect
- Keep ref synced with position via separate useEffect
- Read position from ref inside interval callback
- Remove clientPosition from broadcasting useEffect dependencies

Now positions broadcast smoothly every 200ms regardless of movement state.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 13:12:59 -05:00
5 changed files with 123 additions and 63 deletions

View File

@@ -1,3 +1,17 @@
## [4.66.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.66.1...v4.66.2) (2025-10-22)
### Bug Fixes
* **complement-race:** fix ghost train position update lag and reload position reset ([ad78a65](https://github.com/antialias/soroban-abacus-flashcards/commit/ad78a65ed7f63509602e79246e3761653ea39a15))
## [4.66.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.66.0...v4.66.1) (2025-10-22)
### Bug Fixes
* **complement-race:** ensure continuous position broadcasting during train movement ([df60824](https://github.com/antialias/soroban-abacus-flashcards/commit/df60824f37f52e77e69d32c26926a24e1af88e66))
## [4.66.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.65.1...v4.66.0) (2025-10-22)

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useMemo, useRef } from 'react'
import { useMemo, useRef } from 'react'
import type { PlayerState } from '@/arcade-games/complement-race/types'
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
@@ -115,15 +115,6 @@ export function GhostTrain({
return cars
}, [trainPosition, maxCars, carSpacing, localTrainCarPositions, pathRef])
// Log only once when this ghost train first renders
const hasLoggedRef = useRef(false)
useEffect(() => {
if (!hasLoggedRef.current && locomotiveTransform) {
console.log('[GhostTrain] rendering:', player.name, 'at position:', trainPosition.toFixed(1))
hasLoggedRef.current = true
}
}, [locomotiveTransform, player.name, trainPosition])
// Don't render if position data isn't ready
if (!locomotiveTransform) {
return null

View File

@@ -1,7 +1,7 @@
'use client'
import { animated, useSpring } from '@react-spring/web'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { memo, useMemo, useRef, useState } from 'react'
import { useGameMode } from '@/contexts/GameModeContext'
import { useUserProfile } from '@/contexts/UserProfileContext'
import { useComplementRace } from '@/arcade-games/complement-race/Provider'
@@ -106,16 +106,6 @@ export function SteamTrainJourney({
const localPlayer = activePlayers.find((p) => p.isLocal)
const playerEmoji = localPlayer?.emoji ?? '👤'
// Log only when localPlayer changes
useEffect(() => {
console.log(
'[SteamTrainJourney] localPlayer:',
localPlayer?.name,
'isLocal:',
localPlayer?.isLocal
)
}, [localPlayer?.id, localPlayer?.name, localPlayer?.isLocal])
const svgRef = useRef<SVGSVGElement>(null)
const pathRef = useRef<SVGPathElement>(null)
const [trackGenerator] = useState(() => new RailroadTrackGenerator(800, 600))
@@ -224,17 +214,6 @@ export function SteamTrainJourney({
return filtered
}, [multiplayerState?.players, localPlayerId])
// Log only when otherPlayers count changes
useEffect(() => {
console.log('[SteamTrainJourney] otherPlayers count:', otherPlayers.length)
if (otherPlayers.length > 0) {
console.log(
'[SteamTrainJourney] ghost positions:',
otherPlayers.map((p) => `${p.name}: ${p.position.toFixed(1)}`).join(', ')
)
}
}, [otherPlayers.length, otherPlayers])
if (!trackData) return null
return (

View File

@@ -313,18 +313,28 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
return foundId
}, [activePlayers, players])
// Log only when localPlayerId changes
useEffect(() => {
console.log('[Provider] localPlayerId:', localPlayerId)
}, [localPlayerId])
// Debug logging ref (track last logged values)
const lastLogRef = useState({ key: '', count: 0 })[0]
// Client-side game state (NOT synced to server - purely visual/gameplay)
const [clientMomentum, setClientMomentum] = useState(10) // Start at 10 for gentle push
const [clientPosition, setClientPosition] = useState(0)
const [clientPressure, setClientPressure] = useState(0)
// Track if we've synced position from server (for reconnect/reload scenarios)
const hasInitializedPositionRef = useRef(false)
// Ref to track latest position for broadcasting (avoids recreating interval on every position change)
const clientPositionRef = useRef(clientPosition)
// Refs for throttled logging
const lastBroadcastLogRef = useRef({ position: 0, time: 0 })
const broadcastCountRef = useRef(0)
const lastReceivedPositionsRef = useRef<Record<string, number>>({})
// Ref to hold sendMove so interval doesn't restart when sendMove changes
const sendMoveRef = useRef(sendMove)
useEffect(() => {
sendMoveRef.current = sendMove
}, [sendMove])
const [clientAIRacers, setClientAIRacers] = useState<
Array<{
id: string
@@ -451,16 +461,45 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
clientAIRacers,
])
// Sync client position from server on reconnect/reload (multiplayer only)
useEffect(() => {
// Only sync if:
// 1. We haven't synced yet
// 2. Game is active
// 3. We're in sprint mode
// 4. We have a local player with a position from server
if (
!hasInitializedPositionRef.current &&
multiplayerState.gamePhase === 'playing' &&
multiplayerState.config.style === 'sprint' &&
localPlayerId
) {
const serverPosition = multiplayerState.players[localPlayerId]?.position
if (serverPosition !== undefined && serverPosition > 0) {
console.log(`[POSITION_SYNC] Restoring position from server: ${serverPosition.toFixed(1)}%`)
setClientPosition(serverPosition)
hasInitializedPositionRef.current = true
}
}
// Reset sync flag when game ends
if (multiplayerState.gamePhase !== 'playing') {
hasInitializedPositionRef.current = false
}
}, [multiplayerState.gamePhase, multiplayerState.config.style, multiplayerState.players, localPlayerId])
// Initialize game start time when game becomes active
useEffect(() => {
if (compatibleState.isGameActive && compatibleState.style === 'sprint') {
if (gameStartTimeRef.current === 0) {
gameStartTimeRef.current = Date.now()
lastUpdateRef.current = Date.now()
// Reset client state for new game
setClientMomentum(10) // Start with gentle push
setClientPosition(0)
setClientPressure((10 / 100) * 150) // Initial pressure from starting momentum
// Reset client state for new game (only if not restored from server)
if (!hasInitializedPositionRef.current) {
setClientMomentum(10) // Start with gentle push
setClientPosition(0)
setClientPressure((10 / 100) * 150) // Initial pressure from starting momentum
}
}
} else {
// Reset when game ends
@@ -556,39 +595,77 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
const currentRoute = multiplayerState.currentRoute
// When route changes, reset position and give starting momentum
if (currentRoute > 1 && compatibleState.style === 'sprint') {
console.log(
`[Provider] Route changed to ${currentRoute}, resetting position. Passengers: ${multiplayerState.passengers.length}`
)
setClientPosition(0)
setClientMomentum(10) // Reset to starting momentum (gentle push)
}
}, [multiplayerState.currentRoute, compatibleState.style, multiplayerState.passengers.length])
// Keep position ref in sync with latest position
useEffect(() => {
clientPositionRef.current = clientPosition
}, [clientPosition])
// Log when we receive position updates from other players
useEffect(() => {
if (!multiplayerState?.players || !localPlayerId) return
Object.entries(multiplayerState.players).forEach(([playerId, player]) => {
if (playerId === localPlayerId || !player.isActive) return
const lastPos = lastReceivedPositionsRef.current[playerId] ?? -1
const currentPos = player.position
// Log when position changes significantly (>2%)
if (Math.abs(currentPos - lastPos) > 2) {
console.log(
`[POS_RECEIVED] ${player.name}: ${currentPos.toFixed(1)}% (was ${lastPos.toFixed(1)}%, delta=${(currentPos - lastPos).toFixed(1)}%)`
)
lastReceivedPositionsRef.current[playerId] = currentPos
}
})
}, [multiplayerState?.players, localPlayerId])
// Broadcast position to server for multiplayer ghost trains
useEffect(() => {
if (!compatibleState.isGameActive || compatibleState.style !== 'sprint' || !localPlayerId) {
const isGameActive = multiplayerState.gamePhase === 'playing'
const isSprint = multiplayerState.config.style === 'sprint'
if (!isGameActive || !isSprint || !localPlayerId) {
return
}
// Send position update every 200ms
console.log('[POS_BROADCAST] Starting position broadcast interval')
// Send position update every 100ms for smoother ghost trains (reads from refs to avoid restarting interval)
const interval = setInterval(() => {
sendMove({
const currentPos = clientPositionRef.current
broadcastCountRef.current++
// Throttled logging: only log when position changes by >2% or every 5 seconds
const now = Date.now()
const posDiff = Math.abs(currentPos - lastBroadcastLogRef.current.position)
const timeDiff = now - lastBroadcastLogRef.current.time
if (posDiff > 2 || timeDiff > 5000) {
console.log(
`[POS_BROADCAST] #${broadcastCountRef.current} pos=${currentPos.toFixed(1)}% (delta=${posDiff.toFixed(1)}%)`
)
lastBroadcastLogRef.current = { position: currentPos, time: now }
}
sendMoveRef.current({
type: 'UPDATE_POSITION',
playerId: localPlayerId,
userId: viewerId || '',
data: { position: clientPosition },
data: { position: currentPos },
} as ComplementRaceMove)
}, 200)
}, 100)
return () => clearInterval(interval)
}, [
compatibleState.isGameActive,
compatibleState.style,
clientPosition,
localPlayerId,
viewerId,
sendMove,
])
return () => {
console.log(`[POS_BROADCAST] Stopping interval (sent ${broadcastCountRef.current} updates)`)
clearInterval(interval)
}
}, [multiplayerState.gamePhase, multiplayerState.config.style, localPlayerId, viewerId])
// Keep lastLogRef for future debugging needs
// (removed debug logging)
@@ -803,7 +880,6 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
case 'START_NEW_ROUTE':
// Send route progression to server
if (action.routeNumber !== undefined) {
console.log(`[Provider] Dispatching START_NEW_ROUTE for route ${action.routeNumber}`)
sendMove({
type: 'START_NEW_ROUTE',
playerId: activePlayers[0] || '',

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "4.66.0",
"version": "4.66.2",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [