Compare commits

...

20 Commits

Author SHA1 Message Date
semantic-release-bot
c5bfcf990a chore(release): 4.64.2 [skip ci]
## [4.64.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.64.1...v4.64.2) (2025-10-22)

### Bug Fixes

* **complement-race:** use individual player positions for ghost trains ([00dc4b1](00dc4b1d06))
2025-10-22 16:22:55 +00:00
Thomas Hallock
00dc4b1d06 fix(complement-race): use individual player positions for ghost trains
Previously all ghost trains used the local player's trainPosition,
causing them to render at the same location (hidden behind local train).

Now each ghost train uses its own player.position from multiplayer state,
allowing them to be visible at different positions on the track.

Also added logging to show ghost train positions for debugging.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 11:21:28 -05:00
semantic-release-bot
76063884af chore(release): 4.64.1 [skip ci]
## [4.64.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.64.0...v4.64.1) (2025-10-22)

### Bug Fixes

* **complement-race:** use local player instead of first player for train display ([915d8a5](915d8a5343))
2025-10-22 16:15:59 +00:00
Thomas Hallock
915d8a5343 fix(complement-race): use local player instead of first player for train display
Previously used `firstActivePlayer` which could show the wrong player's
name/emoji on the local train in multiplayer sessions. Now explicitly
finds the local player using `isLocal` flag.

Also updated passenger filtering to only show passengers claimed by
the local player, not the first player in the list.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 11:14:33 -05:00
Thomas Hallock
028b0cb86f debug: use useEffect to log only on changes, not every frame 2025-10-22 11:11:04 -05:00
Thomas Hallock
2bf00af952 debug: fix remaining verbose logs 2025-10-22 11:09:13 -05:00
Thomas Hallock
1d229333bc debug: reduce logging to essential info only 2025-10-22 11:08:28 -05:00
Thomas Hallock
0c67f63ac7 debug: add comprehensive logging for ghost trains troubleshooting 2025-10-22 11:06:09 -05:00
semantic-release-bot
106b348585 chore(release): 4.64.0 [skip ci]
## [4.64.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.11...v4.64.0) (2025-10-22)

### Features

* **complement-race:** add ghost trains for multiplayer visibility ([7668cc9](7668cc9b11))
2025-10-22 16:01:11 +00:00
Thomas Hallock
7668cc9b11 feat(complement-race): add ghost trains for multiplayer visibility
Implement semi-transparent ghost trains to show other players' positions
in steam sprint multiplayer mode. Players can now see opponents racing
alongside them in real-time.

Changes:
- Create GhostTrain component (35% opacity with player name/score labels)
- Expose multiplayer state (players, localPlayerId) in Provider context
- Render ghost trains for all active non-local players in SteamTrainJourney
- Filter by isActive to only show currently playing opponents

Addresses multiplayer visibility gap from COMPLEMENT_RACE_MULTIPLAYER_REVIEW.md
(Priority: HIGH - "Breaks Multiplayer Experience")

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 11:00:00 -05:00
semantic-release-bot
93527e6e0b chore(release): 4.63.11 [skip ci]
## [4.63.11](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.10...v4.63.11) (2025-10-22)

### Bug Fixes

* **complement-race:** actually filter by isActive instead of just id ([ef4ca57](ef4ca57a6c))
2025-10-22 15:49:53 +00:00
Thomas Hallock
ef4ca57a6c fix(complement-race): actually filter by isActive instead of just id
The previous fix attempted to filter by firstActivePlayer.id but was still
getting the first player with ANY id, not the first ACTIVE player.

The root cause was line 104 filtering by `p.id` (whether player has an ID)
instead of `p.isActive` (whether player is actually active).

Changes:
- Change filter from `(p) => p.id` to `(p) => p.isActive`
- Now correctly identifies the first active player
- Train shows only that player's passengers

This properly fixes the issue where inactive/disconnected players' passengers
were being displayed in the train cars.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 10:48:35 -05:00
semantic-release-bot
095221564f chore(release): 4.63.10 [skip ci]
## [4.63.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.9...v4.63.10) (2025-10-22)

### Bug Fixes

* **complement-race:** show only first active player's passengers on train ([2bfd5d2](2bfd5d2bda))
2025-10-22 15:46:43 +00:00
Thomas Hallock
2bfd5d2bda fix(complement-race): show only first active player's passengers on train
In multiplayer steam sprint games, the train was displaying passengers from
all players (including inactive ones) instead of only showing passengers
belonging to the first active player.

The issue was that boardedPassengers was filtering for any claimed passenger,
not checking if the claiming player was still active.

Changes to SteamTrainJourney.tsx:
- Filter boardedPassengers to only include passengers where claimedBy matches
  the firstActivePlayer's ID
- Add firstActivePlayer.id to the useMemo dependency array
- Update comment to clarify filtering logic

Now the train correctly displays only the first active player's passengers
in their train cars, matching the player emoji shown as the engineer.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 10:45:33 -05:00
semantic-release-bot
6dabb71600 chore(release): 4.63.9 [skip ci]
## [4.63.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.8...v4.63.9) (2025-10-21)

### Bug Fixes

* **homepage:** use app-wide abacus config in interactive flashcards ([cf1be2d](cf1be2d173))
2025-10-21 17:09:40 +00:00
Thomas Hallock
cf1be2d173 fix(homepage): use app-wide abacus config in interactive flashcards
The interactive flashcards section at the bottom of the homepage was
hardcoding beadShape="circle" instead of respecting the app-wide abacus
configuration context that all other abaci on the site use.

Changes to InteractiveFlashcards.tsx:
- Import useAbacusConfig hook from @soroban/abacus-react
- Call useAbacusConfig() in DraggableCard component
- Use appConfig.beadShape instead of hardcoded "circle"

This ensures visual consistency across all abacus displays on the site
and respects user preferences for bead styling.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 12:08:26 -05:00
semantic-release-bot
0169ab5128 chore(release): 4.63.8 [skip ci]
## [4.63.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.7...v4.63.8) (2025-10-21)

### Bug Fixes

* **mobile:** restore abacus visibility in "Your Journey" section ([c96036d](c96036d86b))
2025-10-21 17:08:03 +00:00
Thomas Hallock
c96036d86b fix(mobile): restore abacus visibility in "Your Journey" section
On mobile screens (base breakpoint), the level details cards were taking
up all vertical space within the 500px maxHeight constraint, pushing the
abacus completely out of view.

Solution: Hide level details on mobile (display: { base: 'none', lg: 'grid' })
so mobile users see only the slider and abacus, while desktop users see
all components. Also added minH: 0 to containers to ensure proper flex
shrinking behavior.

Changes to LevelSliderDisplay.tsx:
- Level details grid now hidden on base breakpoint, visible on lg+
- Simplified grid columns to single value since it's desktop-only
- Added minH: 0 to flex containers for proper shrinking

Tested on iPhone 14 (390px viewport).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 12:06:48 -05:00
semantic-release-bot
653db575ff chore(release): 4.63.7 [skip ci]
## [4.63.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.6...v4.63.7) (2025-10-21)

### Bug Fixes

* **mobile:** reduce height of Your Journey section on mobile ([8944035](89440355bf))
2025-10-21 17:03:24 +00:00
Thomas Hallock
89440355bf fix(mobile): reduce height of Your Journey section on mobile
Added maxHeight constraint and reduced padding to ensure the abacus
stays visible while scrubbing the slider on mobile devices.

Changes:
- Added maxHeight: 500px on mobile (base), none on desktop (md)
- Reduced padding from 6 to 4 on mobile (base)

This ensures users can see both the slider and abacus simultaneously
on iPhone displays without scrolling.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 12:02:10 -05:00
7 changed files with 262 additions and 22 deletions

View File

@@ -1,3 +1,59 @@
## [4.64.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.64.1...v4.64.2) (2025-10-22)
### Bug Fixes
* **complement-race:** use individual player positions for ghost trains ([00dc4b1](https://github.com/antialias/soroban-abacus-flashcards/commit/00dc4b1d06a4e1763deb16333a298145cafd9187))
## [4.64.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.64.0...v4.64.1) (2025-10-22)
### Bug Fixes
* **complement-race:** use local player instead of first player for train display ([915d8a5](https://github.com/antialias/soroban-abacus-flashcards/commit/915d8a5343e70a30c7a82bed645e6628fcc08a86))
## [4.64.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.11...v4.64.0) (2025-10-22)
### Features
* **complement-race:** add ghost trains for multiplayer visibility ([7668cc9](https://github.com/antialias/soroban-abacus-flashcards/commit/7668cc9b113b3eae2acb1b852b0ad48c979e6604))
## [4.63.11](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.10...v4.63.11) (2025-10-22)
### Bug Fixes
* **complement-race:** actually filter by isActive instead of just id ([ef4ca57](https://github.com/antialias/soroban-abacus-flashcards/commit/ef4ca57a6c3f35d1bddc6a70952f478058fbc6b5))
## [4.63.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.9...v4.63.10) (2025-10-22)
### Bug Fixes
* **complement-race:** show only first active player's passengers on train ([2bfd5d2](https://github.com/antialias/soroban-abacus-flashcards/commit/2bfd5d2bda7f7d2d83c69f75600ab461fde15d92))
## [4.63.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.8...v4.63.9) (2025-10-21)
### Bug Fixes
* **homepage:** use app-wide abacus config in interactive flashcards ([cf1be2d](https://github.com/antialias/soroban-abacus-flashcards/commit/cf1be2d1730543bd30836a87d9cbdfd2cf48360e))
## [4.63.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.7...v4.63.8) (2025-10-21)
### Bug Fixes
* **mobile:** restore abacus visibility in "Your Journey" section ([c96036d](https://github.com/antialias/soroban-abacus-flashcards/commit/c96036d86b6de2e25f7ecd3d00dd36221badc3b1))
## [4.63.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.6...v4.63.7) (2025-10-21)
### Bug Fixes
* **mobile:** reduce height of Your Journey section on mobile ([8944035](https://github.com/antialias/soroban-abacus-flashcards/commit/89440355bf494e54072d2d1a1f228c33ec43d52d))
## [4.63.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.5...v4.63.6) (2025-10-21)

View File

@@ -0,0 +1,123 @@
'use client'
import { useEffect, useMemo, useRef } from 'react'
import type { PlayerState } from '@/arcade-games/complement-race/types'
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
interface GhostTrainProps {
player: PlayerState
trainPosition: number
trackGenerator: RailroadTrackGenerator
pathRef: React.RefObject<SVGPathElement>
}
/**
* GhostTrain - Renders a semi-transparent train for other players in multiplayer
* Shows opponent positions in real-time during steam sprint races
*/
export function GhostTrain({ player, trainPosition, trackGenerator, pathRef }: GhostTrainProps) {
const ghostRef = useRef<SVGGElement>(null)
// Calculate train transform using same logic as local player
const trainTransform = useMemo(() => {
if (!pathRef.current) {
return { x: 0, y: 0, rotation: 0, opacity: 0 }
}
const pathLength = pathRef.current.getTotalLength()
const targetDistance = (trainPosition / 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
return {
x: point.x,
y: point.y,
rotation,
opacity: 0.35, // Ghost effect - 35% opacity
}
}, [trainPosition, 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
}
}, [trainTransform.opacity, player.name, trainPosition])
// Don't render if position data isn't ready
if (trainTransform.opacity === 0) {
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',
}}
>
{/* Ghost locomotive */}
<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>
{/* 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>
</g>
)
}

View File

@@ -1,7 +1,7 @@
'use client'
import { animated, useSpring } from '@react-spring/web'
import { memo, useMemo, useRef, useState } from 'react'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { useGameMode } from '@/contexts/GameModeContext'
import { useUserProfile } from '@/contexts/UserProfileContext'
import { useComplementRace } from '@/arcade-games/complement-race/Provider'
@@ -20,6 +20,7 @@ import { GameHUD } from './GameHUD'
import { RailroadTrackPath } from './RailroadTrackPath'
import { TrainAndCars } from './TrainAndCars'
import { TrainTerrainBackground } from './TrainTerrainBackground'
import { GhostTrain } from './GhostTrain'
const BoardingPassengerAnimation = memo(({ animation }: { animation: BoardingAnimation }) => {
const spring = useSpring({
@@ -92,7 +93,7 @@ export function SteamTrainJourney({
currentQuestion,
currentInput,
}: SteamTrainJourneyProps) {
const { state } = useComplementRace()
const { state, multiplayerState, localPlayerId } = useComplementRace()
const { getSkyGradient, getTimeOfDayPeriod } = useSteamJourney()
const _skyGradient = getSkyGradient()
@@ -100,10 +101,20 @@ export function SteamTrainJourney({
const { players } = useGameMode()
const { profile: _profile } = useUserProfile()
// Get the first active player's emoji
const activePlayers = Array.from(players.values()).filter((p) => p.id)
const firstActivePlayer = activePlayers[0]
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
// Get the LOCAL player's emoji (not just the first player!)
const activePlayers = Array.from(players.values()).filter((p) => p.isActive)
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)
@@ -164,9 +175,13 @@ export function SteamTrainJourney({
// Memoize filtered passenger lists to avoid recalculating on every render
// Arcade room multiplayer uses claimedBy/deliveredBy instead of isBoarded/isDelivered
// Only show passengers claimed by the LOCAL player
const boardedPassengers = useMemo(
() => displayPassengers.filter((p) => p.claimedBy !== null && p.deliveredBy === null),
[displayPassengers]
() =>
displayPassengers.filter(
(p) => p.claimedBy === localPlayer?.id && p.claimedBy !== null && p.deliveredBy === null
),
[displayPassengers, localPlayer?.id]
)
const nonDeliveredPassengers = useMemo(
@@ -186,6 +201,30 @@ export function SteamTrainJourney({
[]
)
// Get other players for ghost trains (filter out local player)
const otherPlayers = useMemo(() => {
if (!multiplayerState?.players || !localPlayerId) {
return []
}
const filtered = Object.entries(multiplayerState.players)
.filter(([playerId, player]) => playerId !== localPlayerId && player.isActive)
.map(([_, player]) => player)
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 (
@@ -247,7 +286,18 @@ export function SteamTrainJourney({
disembarkingAnimations={disembarkingAnimations}
/>
{/* Train, cars, and passenger animations */}
{/* Ghost trains - other players in multiplayer */}
{otherPlayers.map((player) => (
<GhostTrain
key={player.id}
player={player}
trainPosition={player.position} // Use each player's individual position
trackGenerator={trackGenerator}
pathRef={pathRef}
/>
))}
{/* Train, cars, and passenger animations - local player */}
<TrainAndCars
boardingAnimations={boardingAnimations}
disembarkingAnimations={disembarkingAnimations}

View File

@@ -99,6 +99,8 @@ interface CompatibleGameState {
*/
interface ComplementRaceContextValue {
state: CompatibleGameState // Return adapted state
multiplayerState: ComplementRaceState // Raw multiplayer state for rendering other players
localPlayerId: string | undefined // Local player ID for filtering
dispatch: (action: { type: string; [key: string]: any }) => void // Compatibility layer
lastError: string | null
startGame: () => void
@@ -304,12 +306,18 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
// Get local player ID
const localPlayerId = useMemo(() => {
return activePlayers.find((id) => {
const foundId = activePlayers.find((id) => {
const player = players.get(id)
return player?.isLocal
})
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]
@@ -914,6 +922,8 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
const contextValue: ComplementRaceContextValue = {
state: compatibleState, // Use transformed state
multiplayerState, // Expose raw multiplayer state for ghost trains
localPlayerId, // Expose local player ID for filtering
dispatch,
lastError,
startGame,

View File

@@ -1,6 +1,6 @@
'use client'
import { AbacusReact } from '@soroban/abacus-react'
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
import { useEffect, useRef, useState } from 'react'
import { css } from '../../styled-system/css'
@@ -93,6 +93,8 @@ interface DraggableCardProps {
}
function DraggableCard({ card, containerRef }: DraggableCardProps) {
const appConfig = useAbacusConfig()
// Track position - starts at initial, updates when dragged
const [position, setPosition] = useState({ x: card.initialX, y: card.initialY })
const [rotation, setRotation] = useState(card.initialRotation) // Now dynamic!
@@ -337,7 +339,7 @@ function DraggableCard({ card, containerRef }: DraggableCardProps) {
transformOrigin: 'center',
})}
>
<AbacusReact value={card.number} columns={3} beadShape="circle" />
<AbacusReact value={card.number} columns={3} beadShape={appConfig.beadShape} />
</div>
{/* Number display */}

View File

@@ -412,8 +412,9 @@ export function LevelSliderDisplay({
? 'violet.500'
: 'amber.500',
rounded: 'xl',
p: { base: '6', md: '8' },
p: { base: '4', md: '8' },
height: { base: 'auto', md: '700px' },
maxHeight: { base: '500px', md: 'none' },
display: 'flex',
flexDirection: 'column',
})}
@@ -621,6 +622,7 @@ export function LevelSliderDisplay({
borderColor: 'gray.700',
overflow: 'hidden',
flex: 1,
minH: 0, // Allow flex shrinking
})}
>
{/* Level Details (only for Kyu levels) */}
@@ -639,16 +641,12 @@ export function LevelSliderDisplay({
<div
className={css({
flex: '0 0 auto',
display: 'grid',
gridTemplateColumns: {
base: 'repeat(2, 1fr)',
sm: 'repeat(3, 1fr)',
lg: 'repeat(2, 1fr)',
},
display: { base: 'none', lg: 'grid' },
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '2',
p: '2',
w: '100%',
maxW: { base: '100%', lg: '400px' },
maxW: '400px',
alignContent: 'center',
justifyItems: 'center',
})}
@@ -747,7 +745,7 @@ export function LevelSliderDisplay({
) : null
})()}
{/* Abacus (right-aligned for Kyu, centered for Dan) */}
{/* Abacus (centered on mobile, right-aligned for Kyu on desktop, centered for Dan) */}
<div
className={css({
display: 'flex',
@@ -758,6 +756,7 @@ export function LevelSliderDisplay({
overflowX: 'auto',
overflowY: 'hidden',
minW: 0, // Allow flex shrinking
minH: 0, // Allow flex shrinking
})}
>
<animated.div

View File

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