Compare commits

...

11 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
semantic-release-bot
543675340d chore(release): 4.66.0 [skip ci]
## [4.66.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.65.1...v4.66.0) (2025-10-22)

### Features

* **complement-race:** implement per-car adaptive opacity for ghost trains ([9b1d47d](9b1d47d4c7))
2025-10-22 18:07:48 +00:00
Thomas Hallock
9b1d47d4c7 feat(complement-race): implement per-car adaptive opacity for ghost trains
Ghost train cars now individually adjust opacity based on proximity to local
train, reducing visual clutter when overlapping while maintaining clarity
when separated.

Changes:
- Calculate local train car positions array in SteamTrainJourney
- Pass positions to GhostTrain for overlap detection
- Rewrite GhostTrain to render locomotive and each car separately
- Each car calculates opacity independently (0.35 when <20% from any local car, 1.0 otherwise)
- Smooth 0.3s CSS transitions between opacity states
- Overlap threshold: 20% of track length

Benefits:
- Reduced clutter when trains overlap
- Clear visibility when trains separated
- Per-car granularity for mixed scenarios

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 13:06:32 -05:00
semantic-release-bot
659464d3b4 chore(release): 4.65.1 [skip ci]
## [4.65.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.65.0...v4.65.1) (2025-10-22)

### Bug Fixes

* **complement-race:** use sendMove with correct parameters for position updates ([06cd94b](06cd94b24c))
2025-10-22 17:43:07 +00:00
Thomas Hallock
06cd94b24c fix(complement-race): use sendMove with correct parameters for position updates
Fixed ReferenceError where makeMove was undefined. The correct function
is sendMove from useArcadeSession, which requires playerId and userId
parameters in addition to the move type and data.

Changes:
- Changed makeMove to sendMove
- Added playerId and userId to the move object
- Added localPlayerId guard to prevent updates before player is identified
- Updated dependency array to include localPlayerId, viewerId, and sendMove

This fixes the runtime error preventing ghost trains from working.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 12:41:44 -05:00
semantic-release-bot
ada0becee5 chore(release): 4.65.0 [skip ci]
## [4.65.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.64.2...v4.65.0) (2025-10-22)

### Features

* **complement-race:** implement position broadcasting for ghost trains ([c5fba5b](c5fba5b7dd))
2025-10-22 17:09:44 +00:00
Thomas Hallock
c5fba5b7dd feat(complement-race): implement position broadcasting for ghost trains
Clients now broadcast their train position to server every 200ms,
allowing other clients to render ghost trains at correct locations.

Added UPDATE_POSITION move type and server-side validation to sync
client-calculated positions (from momentum physics) to server state.

This fixes the issue where ghost trains were rendering but invisible
because they all had position=0 from server.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 12:08:19 -05:00
7 changed files with 363 additions and 109 deletions

View File

@@ -1,3 +1,38 @@
## [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)
### Features
* **complement-race:** implement per-car adaptive opacity for ghost trains ([9b1d47d](https://github.com/antialias/soroban-abacus-flashcards/commit/9b1d47d4c7bdaf44f3921ff99971dfb3b65442bd))
## [4.65.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.65.0...v4.65.1) (2025-10-22)
### Bug Fixes
* **complement-race:** use sendMove with correct parameters for position updates ([06cd94b](https://github.com/antialias/soroban-abacus-flashcards/commit/06cd94b24cdd9dbd36fb5800c9ba7be194f7eed0))
## [4.65.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.64.2...v4.65.0) (2025-10-22)
### Features
* **complement-race:** implement position broadcasting for ghost trains ([c5fba5b](https://github.com/antialias/soroban-abacus-flashcards/commit/c5fba5b7dd0f36fd3bbe596409e01b0d3dbd4fbe))
## [4.64.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.64.1...v4.64.2) (2025-10-22)

View File

@@ -1,27 +1,65 @@
'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'
// Overlap threshold: if ghost car is within this distance of any local car, make it ghostly
const OVERLAP_THRESHOLD = 20 // % of track length
const GHOST_OPACITY = 0.35 // Opacity when overlapping
const SOLID_OPACITY = 1.0 // Opacity when separated
interface GhostTrainProps {
player: PlayerState
trainPosition: number
localTrainCarPositions: number[] // [locomotive, car1, car2, car3]
maxCars: number
carSpacing: number
trackGenerator: RailroadTrackGenerator
pathRef: React.RefObject<SVGPathElement>
}
interface CarTransform {
x: number
y: number
rotation: number
opacity: number
position: number
}
/**
* Calculate opacity for a ghost car based on distance to nearest local car
*/
function calculateCarOpacity(ghostCarPosition: number, localCarPositions: number[]): number {
// Find minimum distance to any local car
const minDistance = Math.min(
...localCarPositions.map((localPos) => Math.abs(ghostCarPosition - localPos))
)
// If within threshold, use ghost opacity; otherwise solid
return minDistance < OVERLAP_THRESHOLD ? GHOST_OPACITY : SOLID_OPACITY
}
/**
* GhostTrain - Renders a semi-transparent train for other players in multiplayer
* Shows opponent positions in real-time during steam sprint races
* Uses per-car adaptive opacity: cars are ghostly when overlapping local train,
* solid when separated
*/
export function GhostTrain({ player, trainPosition, trackGenerator, pathRef }: GhostTrainProps) {
export function GhostTrain({
player,
trainPosition,
localTrainCarPositions,
maxCars,
carSpacing,
trackGenerator,
pathRef,
}: GhostTrainProps) {
const ghostRef = useRef<SVGGElement>(null)
// Calculate train transform using same logic as local player
const trainTransform = useMemo(() => {
// Calculate ghost train locomotive transform and opacity
const locomotiveTransform = useMemo<CarTransform | null>(() => {
if (!pathRef.current) {
return { x: 0, y: 0, rotation: 0, opacity: 0 }
return null
}
const pathLength = pathRef.current.getTotalLength()
@@ -39,85 +77,135 @@ export function GhostTrain({ player, trainPosition, trackGenerator, pathRef }: G
x: point.x,
y: point.y,
rotation,
opacity: 0.35, // Ghost effect - 35% opacity
position: trainPosition,
opacity: calculateCarOpacity(trainPosition, localTrainCarPositions),
}
}, [trainPosition, pathRef])
}, [trainPosition, localTrainCarPositions, pathRef])
// Log only once when this ghost train first renders
const hasLoggedRef = useRef(false)
useEffect(() => {
if (!hasLoggedRef.current && trainTransform.opacity > 0) {
console.log('[GhostTrain] rendering:', player.name, 'at position:', trainPosition.toFixed(1))
hasLoggedRef.current = true
// Calculate ghost train car transforms (each car behind locomotive)
const carTransforms = useMemo<CarTransform[]>(() => {
if (!pathRef.current) {
return []
}
}, [trainTransform.opacity, player.name, trainPosition])
const pathLength = pathRef.current.getTotalLength()
const cars: CarTransform[] = []
for (let i = 0; i < maxCars; i++) {
const carPosition = Math.max(0, trainPosition - (i + 1) * carSpacing)
const targetDistance = (carPosition / 100) * pathLength
const point = pathRef.current.getPointAtLength(targetDistance)
// Calculate tangent for rotation
const tangentDelta = 1
const tangentDistance = Math.min(targetDistance + tangentDelta, pathLength)
const tangentPoint = pathRef.current.getPointAtLength(tangentDistance)
const rotation =
(Math.atan2(tangentPoint.y - point.y, tangentPoint.x - point.x) * 180) / Math.PI
cars.push({
x: point.x,
y: point.y,
rotation,
position: carPosition,
opacity: calculateCarOpacity(carPosition, localTrainCarPositions),
})
}
return cars
}, [trainPosition, maxCars, carSpacing, localTrainCarPositions, pathRef])
// Don't render if position data isn't ready
if (trainTransform.opacity === 0) {
if (!locomotiveTransform) {
return null
}
return (
<g
ref={ghostRef}
data-component="ghost-train"
data-player-id={player.id}
transform={`translate(${trainTransform.x}, ${trainTransform.y}) rotate(${trainTransform.rotation}) scale(-1, 1)`}
opacity={trainTransform.opacity}
style={{
transition: 'opacity 0.3s ease-in',
}}
>
<g ref={ghostRef} data-component="ghost-train" data-player-id={player.id}>
{/* Ghost locomotive */}
<text
data-element="ghost-locomotive"
x={0}
y={0}
textAnchor="middle"
<g
transform={`translate(${locomotiveTransform.x}, ${locomotiveTransform.y}) rotate(${locomotiveTransform.rotation}) scale(-1, 1)`}
opacity={locomotiveTransform.opacity}
style={{
fontSize: '100px',
filter: `drop-shadow(0 2px 8px ${player.color || 'rgba(100, 100, 255, 0.6)'})`,
pointerEvents: 'none',
transition: 'opacity 0.3s ease-in-out',
}}
>
🚂
</text>
<text
data-element="ghost-locomotive"
x={0}
y={0}
textAnchor="middle"
style={{
fontSize: '100px',
filter: `drop-shadow(0 2px 8px ${player.color || 'rgba(100, 100, 255, 0.6)'})`,
pointerEvents: 'none',
}}
>
🚂
</text>
{/* Player name label - positioned above train */}
<text
data-element="ghost-label"
x={0}
y={-60}
textAnchor="middle"
style={{
fontSize: '18px',
fontWeight: 'bold',
fill: player.color || '#6366f1',
filter: 'drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3))',
pointerEvents: 'none',
transform: 'scaleX(-1)', // Counter the parent's scaleX(-1)
}}
>
{player.name || `Player ${player.id.slice(0, 4)}`}
</text>
{/* Player name label - positioned above locomotive */}
<text
data-element="ghost-label"
x={0}
y={-60}
textAnchor="middle"
style={{
fontSize: '18px',
fontWeight: 'bold',
fill: player.color || '#6366f1',
filter: 'drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3))',
pointerEvents: 'none',
transform: 'scaleX(-1)', // Counter the parent's scaleX(-1)
}}
>
{player.name || `Player ${player.id.slice(0, 4)}`}
</text>
{/* Score indicator - positioned below train */}
<text
data-element="ghost-score"
x={0}
y={50}
textAnchor="middle"
style={{
fontSize: '14px',
fontWeight: 'bold',
fill: 'rgba(255, 255, 255, 0.9)',
filter: 'drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5))',
pointerEvents: 'none',
transform: 'scaleX(-1)', // Counter the parent's scaleX(-1)
}}
>
{player.score}
</text>
{/* Score indicator - positioned below locomotive */}
<text
data-element="ghost-score"
x={0}
y={50}
textAnchor="middle"
style={{
fontSize: '14px',
fontWeight: 'bold',
fill: 'rgba(255, 255, 255, 0.9)',
filter: 'drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5))',
pointerEvents: 'none',
transform: 'scaleX(-1)', // Counter the parent's scaleX(-1)
}}
>
{player.score}
</text>
</g>
{/* Ghost cars - each with individual opacity */}
{carTransforms.map((car, index) => (
<g
key={`car-${index}`}
transform={`translate(${car.x}, ${car.y}) rotate(${car.rotation}) scale(-1, 1)`}
opacity={car.opacity}
style={{
transition: 'opacity 0.3s ease-in-out',
}}
>
<text
data-element={`ghost-car-${index}`}
x={0}
y={0}
textAnchor="middle"
style={{
fontSize: '85px',
filter: `drop-shadow(0 2px 6px ${player.color || 'rgba(100, 100, 255, 0.4)'})`,
pointerEvents: 'none',
}}
>
🚃
</text>
</g>
))}
</g>
)
}

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))
@@ -201,6 +191,16 @@ export function SteamTrainJourney({
[]
)
// Calculate local train car positions for ghost train overlap detection
// Array includes locomotive + all cars: [locomotive, car1, car2, car3]
const localTrainCarPositions = useMemo(() => {
const positions = [trainPosition] // Locomotive at front
for (let i = 0; i < maxCars; i++) {
positions.push(Math.max(0, trainPosition - (i + 1) * carSpacing))
}
return positions
}, [trainPosition, maxCars, carSpacing])
// Get other players for ghost trains (filter out local player)
const otherPlayers = useMemo(() => {
if (!multiplayerState?.players || !localPlayerId) {
@@ -214,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 (
@@ -292,6 +281,9 @@ export function SteamTrainJourney({
key={player.id}
player={player}
trainPosition={player.position} // Use each player's individual position
localTrainCarPositions={localTrainCarPositions} // For per-car overlap detection
maxCars={maxCars}
carSpacing={carSpacing}
trackGenerator={trackGenerator}
pathRef={pathRef}
/>

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,14 +595,78 @@ 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(() => {
const isGameActive = multiplayerState.gamePhase === 'playing'
const isSprint = multiplayerState.config.style === 'sprint'
if (!isGameActive || !isSprint || !localPlayerId) {
return
}
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(() => {
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: currentPos },
} as ComplementRaceMove)
}, 100)
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)
@@ -777,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

@@ -97,6 +97,9 @@ export class ComplementRaceValidator
case 'UPDATE_INPUT':
return this.validateUpdateInput(state, move.playerId, move.data.input)
case 'UPDATE_POSITION':
return this.validateUpdatePosition(state, move.playerId, move.data.position)
case 'CLAIM_PASSENGER':
return this.validateClaimPassenger(
state,
@@ -397,6 +400,39 @@ export class ComplementRaceValidator
return { valid: true, newState }
}
private validateUpdatePosition(
state: ComplementRaceState,
playerId: string,
position: number
): ValidationResult {
if (state.gamePhase !== 'playing') {
return { valid: false, error: 'Game not in playing phase' }
}
const player = state.players[playerId]
if (!player) {
return { valid: false, error: 'Player not found' }
}
// Validate position is a reasonable number (0-100)
if (typeof position !== 'number' || position < 0 || position > 100) {
return { valid: false, error: 'Invalid position value' }
}
const newState: ComplementRaceState = {
...state,
players: {
...state.players,
[playerId]: {
...player,
position,
},
},
}
return { valid: true, newState }
}
// ==========================================================================
// Sprint Mode: Passenger Management
// ==========================================================================

View File

@@ -143,6 +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: 'UPDATE_POSITION'; data: { position: number } } // Sprint mode: sync train position
| { type: 'CLAIM_PASSENGER'; data: { passengerId: string; carIndex: number } } // Sprint mode: pickup
| { type: 'DELIVER_PASSENGER'; data: { passengerId: string } } // Sprint mode: delivery

View File

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