Compare commits

...

18 Commits

Author SHA1 Message Date
semantic-release-bot
43f1f92900 chore(release): 4.4.9 [skip ci]
## [4.4.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.8...v4.4.9) (2025-10-17)

### Bug Fixes

* **complement-race:** reduce initial momentum from 50 to 10 to prevent train sailing past first station ([5f146b0](5f146b0daf))
2025-10-17 12:58:50 +00:00
Thomas Hallock
5f146b0daf fix(complement-race): reduce initial momentum from 50 to 10 to prevent train sailing past first station
The train was starting with too much momentum (50), causing it to sail past the first station without requiring user input. Reduced to 10 for a gentle push that still requires player engagement.

Changes:
- Reduce initial momentum from 50 to 10 in all three locations:
  - Initial state (useState)
  - Game start initialization
  - Route reset when advancing to next route
- Update pressure calculation to match new starting momentum

With momentum=10: speed = 1.5% per second (gentle start requiring answers to progress)
vs momentum=50: speed = 7.5% per second (too aggressive, reaches station without input)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 07:57:56 -05:00
semantic-release-bot
734da610b7 chore(release): 4.4.8 [skip ci]
## [4.4.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.7...v4.4.8) (2025-10-17)

### Bug Fixes

* **complement-race:** implement client-side momentum with continuous decay for smooth train movement ([ea19ff9](ea19ff918b))
2025-10-17 12:51:40 +00:00
Thomas Hallock
ea19ff918b fix(complement-race): implement client-side momentum with continuous decay for smooth train movement
Fixes train jumping backward and pressure not decaying to zero in sprint mode by moving momentum/position/pressure tracking entirely to the client.

Changes:
- Remove momentum/pressure from server PlayerState type (sprint mode only)
- Remove all momentum updates from Validator (server tracks only scoring)
- Add client-side momentum state with 50ms game loop for smooth 20fps movement
- Implement continuous momentum decay based on skill level (2.0-13.0/sec)
- Calculate position and pressure client-side from momentum
- Handle answer boosts (+15 correct, -10 wrong) in client

This matches the arcade room's event-driven architecture where visual elements are client-side and the server maintains authoritative game state (score, streak, passengers).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 07:50:45 -05:00
semantic-release-bot
ea1e548e61 chore(release): 4.4.7 [skip ci]
## [4.4.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.6...v4.4.7) (2025-10-17)

### Bug Fixes

* **complement-race:** add missing useRef import ([d43829a](d43829ad48))
2025-10-17 12:32:46 +00:00
Thomas Hallock
d43829ad48 fix(complement-race): add missing useRef import
- TypeScript error: Cannot find name 'useRef'
- Added useRef to React imports in Provider.tsx

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 07:31:43 -05:00
semantic-release-bot
dbcedb7144 chore(release): 4.4.6 [skip ci]
## [4.4.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.5...v4.4.6) (2025-10-17)

### Bug Fixes

* **complement-race:** restore smooth train movement with client-side game loop ([46a80cb](46a80cbcc8))
2025-10-17 12:30:51 +00:00
Thomas Hallock
46a80cbcc8 fix(complement-race): restore smooth train movement with client-side game loop
**Problem**: Train was jumping discretely on each answer instead of moving smoothly

**Root Cause**: Ported incorrectly - position updated on answer submission instead of continuously

**Original Mechanics** (from useSteamJourney.ts):
- 50ms game loop (20fps) runs continuously
- Position calculated from momentum: `position += (momentum * 0.15 * deltaTime) / 1000`
- Pressure calculated from momentum: `pressure = (momentum / 100) * 150` (0-150 PSI)
- Answers only affect momentum (+15 correct, -10 wrong)

**Fixed Implementation**:
- Client-side game loop at 50ms interval
- Position calculated continuously from server momentum
- Pressure calculated continuously from momentum (0-150 PSI)
- Server only tracks momentum (authoritative)
- Removed discrete position jumps from Validator
- Position/pressure are derived values, not stored

**Files Changed**:
- Provider.tsx: Added client game loop, use calculated position/pressure
- Validator.ts: Removed position updates, only track momentum
- types.ts: Removed pressure field (calculated client-side)

This matches the original smooth movement behavior.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 07:29:34 -05:00
semantic-release-bot
5d89ad7ada chore(release): 4.4.5 [skip ci]
## [4.4.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.4...v4.4.5) (2025-10-17)

### Bug Fixes

* **complement-race:** add missing useEffect import ([3054130](30541304dd))
2025-10-17 12:24:38 +00:00
Thomas Hallock
30541304dd fix(complement-race): add missing useEffect import
- Runtime error: useEffect is not defined
- Added useEffect to React imports in Provider.tsx

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 07:23:39 -05:00
semantic-release-bot
376c8eb901 chore(release): 4.4.4 [skip ci]
## [4.4.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.3...v4.4.4) (2025-10-17)

### Bug Fixes

* **complement-race:** add pressure decay system and improve logging ([66992e8](66992e8770))
2025-10-17 12:23:29 +00:00
Thomas Hallock
66992e8770 fix(complement-race): add pressure decay system and improve logging
**1. Smart Logging (event-based instead of frame-based)**
- Only logs on answer submission, not every frame
- Format: "🚂 Answer #X: momentum=Y pos=Z pressure=P streak=S"
- Prevents console overflow in real-time game

**2. Pressure Decay System**
- Added `pressure` field to PlayerState type
- Pressure now independent from momentum (was stuck at 100)
- Correct answer: +20 pressure (add steam)
- Wrong answer: +5 pressure (less steam)
- Decay: -8 pressure per answer (steam escapes over time)
- Range: 0-100 with min/max caps

**3. Implementation**
- types.ts: Added pressure field to PlayerState
- Validator.ts: Initialize pressure=60, update with decay
- Provider.tsx: Use actual pressure from server (not calculated)
- Route reset: Reset pressure to 60 on new routes

This fixes the pressure gauge being pinned at 100 constantly.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 07:22:37 -05:00
semantic-release-bot
52019a24c2 chore(release): 4.4.3 [skip ci]
## [4.4.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.2...v4.4.3) (2025-10-17)

### Bug Fixes

* **complement-race:** train now moves in sprint mode ([54b46e7](54b46e771e))

### Code Refactoring

* simplify train debug logs to strings only ([334a49c](334a49c92e))
2025-10-17 12:15:31 +00:00
Thomas Hallock
54b46e771e fix(complement-race): train now moves in sprint mode
**THE BUG**: Validator was only updating momentum in Sprint mode,
but NEVER updating position! This caused trainPosition to stay at 0.

**THE FIX**: Added position calculation based on momentum:
- moveDistance = momentum / 20
- Starting momentum (50) → 2.5 units per answer
- Max momentum (100) → 5 units per answer
- Creates progression: higher momentum = faster train movement

Position updates per answer now work:
- Correct answer: momentum +15, then position +=(momentum/20)
- Wrong answer: momentum -10, then position +=(momentum/20)
- Position capped at 100 (end of route)

This matches the original single-player behavior where the train
speed was tied to momentum.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 07:14:30 -05:00
Thomas Hallock
334a49c92e refactor: simplify train debug logs to strings only
Changed from logging objects to simple string format:
- Before: { momentum: 50, trainPosition: 0, pressure: 60, ... }
- After: Sprint: momentum=50 pos=0 pressure=60

Issue identified from logs: trainPosition stuck at 0!
This is why train isn't appearing/moving.
2025-10-17 07:13:24 -05:00
semantic-release-bot
739e928c6e chore(release): 4.4.2 [skip ci]
## [4.4.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.1...v4.4.2) (2025-10-17)

### Code Refactoring

* **complement-race:** remove verbose logging, keep only train debug logs ([86af2fe](86af2fe902))
2025-10-17 12:07:39 +00:00
Thomas Hallock
86af2fe902 refactor(complement-race): remove verbose logging, keep only train debug logs
Removed all excessive console logging that was causing console overflow.

**Removed**:
- GameDisplay: All keyboard/answer validation logs (input bug is fixed)
- Context reducer: All action dispatched logs
- Provider: Verbose state transformation details
- Provider: Dispatch compatibility layer logs

**Kept (for train/pressure debugging)**:
- Provider: Sprint-specific values (momentum, trainPosition, pressure)
- SteamTrainJourney: Component props and state

This should give us minimal, focused logs to debug:
1. Why train isn't appearing
2. Why pressure is stuck at 100

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 07:06:30 -05:00
Thomas Hallock
60ce9c0eb1 debug(complement-race): add comprehensive logging for missing train issue
Added detailed console logging to debug why train isn't appearing:

**Provider.tsx**:
- State transformation details (localPlayer, all players, game phase)
- Transformed sprint-specific values (momentum, trainPosition, pressure)

**SteamTrainJourney.tsx**:
- Component props (momentum, trainPosition, pressure, etc.)
- State from provider (stations, passengers, currentRoute, gamePhase)

This will help identify:
1. If localPlayer is null/undefined
2. If momentum/position values are 0
3. If stations/passengers are empty
4. What game phase we're in

Note: User reports pressure is pinned at 100 - likely related to formula:
`pressure: localPlayer?.momentum ? Math.min(100, localPlayer.momentum + 10) : 0`

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 06:59:51 -05:00
8 changed files with 237 additions and 97 deletions

View File

@@ -1,3 +1,64 @@
## [4.4.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.8...v4.4.9) (2025-10-17)
### Bug Fixes
* **complement-race:** reduce initial momentum from 50 to 10 to prevent train sailing past first station ([5f146b0](https://github.com/antialias/soroban-abacus-flashcards/commit/5f146b0daf74d54e1c7b9a57d3a2f37e73849ff2))
## [4.4.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.7...v4.4.8) (2025-10-17)
### Bug Fixes
* **complement-race:** implement client-side momentum with continuous decay for smooth train movement ([ea19ff9](https://github.com/antialias/soroban-abacus-flashcards/commit/ea19ff918bc70ad3eb0339e18dbd32195f34816e))
## [4.4.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.6...v4.4.7) (2025-10-17)
### Bug Fixes
* **complement-race:** add missing useRef import ([d43829a](https://github.com/antialias/soroban-abacus-flashcards/commit/d43829ad48f7ee879a46879f5e6ac1256db1f564))
## [4.4.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.5...v4.4.6) (2025-10-17)
### Bug Fixes
* **complement-race:** restore smooth train movement with client-side game loop ([46a80cb](https://github.com/antialias/soroban-abacus-flashcards/commit/46a80cbcc8ec39224d4edaf540da25611d48fbdd))
## [4.4.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.4...v4.4.5) (2025-10-17)
### Bug Fixes
* **complement-race:** add missing useEffect import ([3054130](https://github.com/antialias/soroban-abacus-flashcards/commit/30541304dd0f0801860dd62967f7f7cae717bcdd))
## [4.4.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.3...v4.4.4) (2025-10-17)
### Bug Fixes
* **complement-race:** add pressure decay system and improve logging ([66992e8](https://github.com/antialias/soroban-abacus-flashcards/commit/66992e877065a42d00379ef8fae0a6e252b0ffcb))
## [4.4.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.2...v4.4.3) (2025-10-17)
### Bug Fixes
* **complement-race:** train now moves in sprint mode ([54b46e7](https://github.com/antialias/soroban-abacus-flashcards/commit/54b46e771e654721e7fabb1f45ecd45daf8e447f))
### Code Refactoring
* simplify train debug logs to strings only ([334a49c](https://github.com/antialias/soroban-abacus-flashcards/commit/334a49c92e112c852c483b5dbe3a3d0aef8a5c03))
## [4.4.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.1...v4.4.2) (2025-10-17)
### Code Refactoring
* **complement-race:** remove verbose logging, keep only train debug logs ([86af2fe](https://github.com/antialias/soroban-abacus-flashcards/commit/86af2fe902b3d3790b7b4659fdc698caed8e4dd9))
## [4.4.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.0...v4.4.1) (2025-10-17)

View File

@@ -5,7 +5,6 @@ import { useComplementRace } from '@/arcade-games/complement-race/Provider'
import { useAdaptiveDifficulty } from '../hooks/useAdaptiveDifficulty'
import { useAIRacers } from '../hooks/useAIRacers'
import { useSoundEffects } from '../hooks/useSoundEffects'
import { useSteamJourney } from '../hooks/useSteamJourney'
import { generatePassengers } from '../lib/passengerGenerator'
import { AbacusTarget } from './AbacusTarget'
import { CircularTrack } from './RaceTrack/CircularTrack'
@@ -16,10 +15,9 @@ import { RouteCelebration } from './RouteCelebration'
type FeedbackAnimation = 'correct' | 'incorrect' | null
export function GameDisplay() {
const { state, dispatch } = useComplementRace()
const { state, dispatch, boostMomentum } = useComplementRace()
useAIRacers() // Activate AI racer updates (not used in sprint mode)
const { trackPerformance, getAdaptiveFeedbackMessage } = useAdaptiveDifficulty()
const { boostMomentum } = useSteamJourney()
const { playSound } = useSoundEffects()
const [feedbackAnimation, setFeedbackAnimation] = useState<FeedbackAnimation>(null)
@@ -70,12 +68,6 @@ export function GameDisplay() {
// Only process number keys
if (/^[0-9]$/.test(e.key)) {
const newInput = state.currentInput + e.key
console.log('⌨️ [KeyPress] Number key pressed:', {
key: e.key,
oldInput: state.currentInput,
newInput,
currentQuestion: state.currentQuestion?.number,
})
dispatch({ type: 'UPDATE_INPUT', input: newInput })
// Check if answer is complete
@@ -83,27 +75,12 @@ export function GameDisplay() {
const answer = parseInt(newInput, 10)
const correctAnswer = state.currentQuestion.correctAnswer
console.log('🔍 [KeyPress] Checking answer:', {
newInput,
newInputLength: newInput.length,
correctAnswer,
correctAnswerLength: correctAnswer.toString().length,
willSubmit: newInput.length >= correctAnswer.toString().length,
})
// If we have enough digits to match the answer, submit
if (newInput.length >= correctAnswer.toString().length) {
const responseTime = Date.now() - state.questionStartTime
const isCorrect = answer === correctAnswer
const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}`
console.log('📝 [KeyPress] Submitting answer:', {
answer,
correctAnswer,
isCorrect,
responseTime,
})
if (isCorrect) {
// Correct answer
dispatch({ type: 'SUBMIT_ANSWER', answer })
@@ -130,7 +107,7 @@ export function GameDisplay() {
// Boost momentum for sprint mode
if (state.style === 'sprint') {
boostMomentum()
boostMomentum(true)
// Play train whistle for milestones in sprint mode (line 13222-13235)
if (newStreak >= 5 && newStreak % 3 === 0) {
@@ -154,7 +131,6 @@ export function GameDisplay() {
dispatch({ type: 'SHOW_ADAPTIVE_FEEDBACK', feedback })
}
console.log('➡️ [KeyPress] Dispatching NEXT_QUESTION after correct answer')
dispatch({ type: 'NEXT_QUESTION' })
} else {
// Incorrect answer
@@ -166,22 +142,22 @@ export function GameDisplay() {
// Play incorrect sound (from web_generator.py line 11589)
playSound('incorrect')
// Reduce momentum for sprint mode
if (state.style === 'sprint') {
boostMomentum(false)
}
// Show adaptive feedback
const feedback = getAdaptiveFeedbackMessage(pairKey, false, responseTime)
if (feedback) {
dispatch({ type: 'SHOW_ADAPTIVE_FEEDBACK', feedback })
}
console.log('❌ [KeyPress] Incorrect answer - clearing input')
dispatch({ type: 'UPDATE_INPUT', input: '' })
}
}
}
} else if (e.key === 'Backspace') {
console.log('⌫ [KeyPress] Backspace pressed:', {
oldInput: state.currentInput,
newInput: state.currentInput.slice(0, -1),
})
dispatch({
type: 'UPDATE_INPUT',
input: state.currentInput.slice(0, -1),
@@ -189,16 +165,8 @@ export function GameDisplay() {
}
}
console.log('🔄 [KeyPress Effect] Setting up keyboard listener with state:', {
currentInput: state.currentInput,
currentQuestion: state.currentQuestion?.number,
})
window.addEventListener('keydown', handleKeyPress)
return () => {
console.log('🧹 [KeyPress Effect] Cleaning up keyboard listener')
window.removeEventListener('keydown', handleKeyPress)
}
return () => window.removeEventListener('keydown', handleKeyPress)
}, [
state.currentInput,
state.currentQuestion,
@@ -231,19 +199,6 @@ export function GameDisplay() {
if (!state.currentQuestion) return null
// DEBUG: Log state on every render
console.log('🎮 [GameDisplay] Render:', {
currentInput: state.currentInput,
currentInputLength: state.currentInput?.length,
currentInputType: typeof state.currentInput,
currentQuestion: state.currentQuestion,
questionNumber: state.currentQuestion?.number,
correctAnswer: state.currentQuestion?.correctAnswer,
targetSum: state.currentQuestion?.targetSum,
score: state.score,
streak: state.streak,
})
return (
<div
data-component="game-display"

View File

@@ -94,6 +94,10 @@ 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()
const period = getTimeOfDayPeriod()

View File

@@ -113,13 +113,6 @@ const initialState: GameState = {
}
function gameReducer(state: GameState, action: GameAction): GameState {
console.log('🔄 [Reducer] Action dispatched:', {
type: action.type,
action,
currentInput: state.currentInput,
currentQuestion: state.currentQuestion?.number,
})
switch (action.type) {
case 'SET_MODE':
return { ...state, mode: action.mode }
@@ -178,8 +171,6 @@ function gameReducer(state: GameState, action: GameAction): GameState {
}
case 'NEXT_QUESTION': {
console.log('➡️ [Reducer] NEXT_QUESTION - clearing input and generating new question')
// Generate new question based on mode
const generateQuestion = () => {
let targetSum: number
@@ -221,28 +212,16 @@ function gameReducer(state: GameState, action: GameAction): GameState {
}
}
const newQuestion = generateQuestion()
console.log('📊 [Reducer] NEXT_QUESTION result:', {
oldQuestion: state.currentQuestion,
newQuestion,
oldInput: state.currentInput,
newInput: '',
})
return {
...state,
previousQuestion: state.currentQuestion,
currentQuestion: newQuestion,
currentQuestion: generateQuestion(),
questionStartTime: Date.now(),
currentInput: '',
}
}
case 'UPDATE_INPUT':
console.log('✏️ [Reducer] UPDATE_INPUT:', {
oldInput: state.currentInput,
newInput: action.input,
})
return { ...state, currentInput: action.input }
case 'SUBMIT_ANSWER': {

View File

@@ -5,7 +5,16 @@
'use client'
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import {
type GameMove,
buildPlayerMetadata,
@@ -102,6 +111,7 @@ interface ComplementRaceContextValue {
setConfig: (field: keyof ComplementRaceConfig, value: unknown) => void
clearError: () => void
exitSession: () => void
boostMomentum: (correct: boolean) => void // Client-side momentum boost/reduce
}
const ComplementRaceContext = createContext<ComplementRaceContextValue | null>(null)
@@ -240,6 +250,32 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
})
}, [activePlayers, players])
// 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)
const lastUpdateRef = useRef(Date.now())
const gameStartTimeRef = useRef(0)
// Decay rates based on skill level (momentum lost per second)
const MOMENTUM_DECAY_RATES = {
preschool: 2.0,
kindergarten: 3.5,
relaxed: 5.0,
slow: 7.0,
normal: 9.0,
fast: 11.0,
expert: 13.0,
}
const MOMENTUM_GAIN_PER_CORRECT = 15
const MOMENTUM_LOSS_PER_WRONG = 10
const SPEED_MULTIPLIER = 0.15 // momentum * 0.15 = % per second
const UPDATE_INTERVAL = 50 // 50ms = ~20fps
// Transform multiplayer state to look like single-player state
const compatibleState = useMemo((): CompatibleGameState => {
const localPlayer = localPlayerId ? multiplayerState.players[localPlayerId] : null
@@ -303,10 +339,10 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
previousPosition: ai.position,
})),
// Sprint mode specific
momentum: localPlayer?.momentum || 0,
trainPosition: localPlayer?.position || 0,
pressure: localPlayer?.momentum ? Math.min(100, localPlayer.momentum + 10) : 0,
// Sprint mode specific (all client-side for smooth movement)
momentum: clientMomentum, // Client-only state with continuous decay
trainPosition: clientPosition, // Client-calculated from momentum
pressure: clientPressure, // Client-calculated from momentum (0-150 PSI)
elapsedTime: multiplayerState.gameStartTime ? Date.now() - multiplayerState.gameStartTime : 0,
lastCorrectAnswerTime: localPlayer?.lastAnswerTime || Date.now(),
currentRoute: multiplayerState.currentRoute,
@@ -328,7 +364,103 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
adaptiveFeedback: localUIState.adaptiveFeedback,
difficultyTracker: localUIState.difficultyTracker,
}
}, [multiplayerState, localPlayerId, localUIState])
}, [multiplayerState, localPlayerId, localUIState, clientPosition, clientPressure])
// 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
}
} else {
// Reset when game ends
gameStartTimeRef.current = 0
}
}, [compatibleState.isGameActive, compatibleState.style])
// Main client-side game loop: momentum decay and position calculation
useEffect(() => {
if (!compatibleState.isGameActive || compatibleState.style !== 'sprint') return
const interval = setInterval(() => {
const now = Date.now()
const deltaTime = now - lastUpdateRef.current
lastUpdateRef.current = now
// Get decay rate based on skill level
const decayRate =
MOMENTUM_DECAY_RATES[compatibleState.timeoutSetting as keyof typeof MOMENTUM_DECAY_RATES] ||
MOMENTUM_DECAY_RATES.normal
setClientMomentum((prevMomentum) => {
// Calculate momentum decay for this frame
const momentumLoss = (decayRate * deltaTime) / 1000
// Update momentum (don't go below 0)
const newMomentum = Math.max(0, prevMomentum - momentumLoss)
// Calculate speed from momentum (% per second)
const speed = newMomentum * SPEED_MULTIPLIER
// Update position (accumulate, never go backward)
const positionDelta = (speed * deltaTime) / 1000
setClientPosition((prev) => prev + positionDelta)
// Calculate pressure (0-150 PSI)
const pressure = Math.min(150, (newMomentum / 100) * 150)
setClientPressure(pressure)
return newMomentum
})
}, UPDATE_INTERVAL)
return () => clearInterval(interval)
}, [
compatibleState.isGameActive,
compatibleState.style,
compatibleState.timeoutSetting,
MOMENTUM_DECAY_RATES,
SPEED_MULTIPLIER,
UPDATE_INTERVAL,
])
// Reset client position when route changes
useEffect(() => {
const currentRoute = multiplayerState.currentRoute
// When route changes, reset position and give starting momentum
if (currentRoute > 1 && compatibleState.style === 'sprint') {
setClientPosition(0)
setClientMomentum(10) // Reset to starting momentum (gentle push)
}
}, [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,
])
// Action creators
const startGame = useCallback(() => {
@@ -482,8 +614,6 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
// Compatibility dispatch function for existing UI components
const dispatch = useCallback(
(action: { type: string; [key: string]: any }) => {
console.log('[ComplementRaceProvider] dispatch called (compatibility layer):', action.type)
// Map old reducer actions to new action creators
switch (action.type) {
case 'START_COUNTDOWN':
@@ -497,7 +627,6 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
}
break
case 'NEXT_QUESTION':
console.log('🧹 [Provider] NEXT_QUESTION - clearing local input state')
setLocalUIState((prev) => ({ ...prev, currentInput: '' }))
nextQuestion()
break
@@ -603,6 +732,22 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
]
)
// Client-side momentum boost/reduce (sprint mode only)
const boostMomentum = useCallback(
(correct: boolean) => {
if (compatibleState.style !== 'sprint') return
setClientMomentum((prevMomentum) => {
if (correct) {
return Math.min(100, prevMomentum + MOMENTUM_GAIN_PER_CORRECT)
} else {
return Math.max(0, prevMomentum - MOMENTUM_LOSS_PER_WRONG)
}
})
},
[compatibleState.style, MOMENTUM_GAIN_PER_CORRECT, MOMENTUM_LOSS_PER_WRONG]
)
const contextValue: ComplementRaceContextValue = {
state: compatibleState, // Use transformed state
dispatch,
@@ -618,6 +763,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
setConfig,
clearError,
exitSession,
boostMomentum, // Client-side momentum control
}
return (

View File

@@ -168,8 +168,7 @@ export class ComplementRaceValidator
bestStreak: 0,
correctAnswers: 0,
totalQuestions: 0,
position: 0,
momentum: 50, // Start with some momentum in sprint mode
position: 0, // Only used for practice/survival; sprint mode is client-side
isReady: false,
isActive: true,
currentAnswer: null,
@@ -317,12 +316,9 @@ export class ComplementRaceValidator
updatedPlayer.position = Math.min(100, player.position + 100 / state.config.raceGoal)
}
} else if (state.config.style === 'sprint') {
// Sprint: Update momentum
if (correct) {
updatedPlayer.momentum = Math.min(100, player.momentum + 15)
} else {
updatedPlayer.momentum = Math.max(0, player.momentum - 10)
}
// Sprint: All momentum/position handled client-side for smooth 20fps movement
// Server only tracks scoring, passengers, and game progression
// No server-side position updates needed
} else if (state.config.style === 'survival') {
// Survival: Always move forward, speed based on accuracy
const moveDistance = correct ? 5 : 2
@@ -522,12 +518,12 @@ export class ComplementRaceValidator
return { valid: false, error: 'Routes only available in sprint mode' }
}
// Reset all player positions to 0
// Reset all player positions to 0 for new route (client handles momentum reset)
const resetPlayers: Record<string, PlayerState> = {}
for (const [playerId, player] of Object.entries(state.players)) {
resetPlayers[playerId] = {
...player,
position: 0,
position: 0, // Server position not used in sprint; client will reset
passengers: [], // Clear any remaining passengers
}
}

View File

@@ -61,8 +61,7 @@ export interface PlayerState {
totalQuestions: number
// Position & Progress
position: number // 0-100% for practice/sprint, lap count for survival
momentum: number // 0-100 (sprint mode only)
position: number // 0-100% for practice/survival only (sprint mode: client-side)
// Current state
isReady: boolean

View File

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