Compare commits

...

27 Commits

Author SHA1 Message Date
semantic-release-bot
cf997b9cbc chore(release): 4.6.8 [skip ci]
## [4.6.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.7...v4.6.8) (2025-10-18)

### Bug Fixes

* **complement-race:** counter-flip AI speech bubbles to make text readable ([07d5607](07d5607218))
2025-10-18 14:07:19 +00:00
Thomas Hallock
07d5607218 fix(complement-race): counter-flip AI speech bubbles to make text readable
When AI racers were flipped with scaleX(-1) to face right, their speech
bubbles were also flipped, making the text appear mirrored/backwards.

Added a wrapper div around SpeechBubble with scaleX(-1) to counter the
parent's flip transform, keeping the text readable while the emoji
remains flipped.

This matches the pattern used in CircularTrack which counter-rotates
speech bubbles to keep them upright.

LinearTrack.tsx:145-154

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 09:06:25 -05:00
semantic-release-bot
614a081ca6 chore(release): 4.6.7 [skip ci]
## [4.6.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.6...v4.6.7) (2025-10-18)

### Bug Fixes

* **complement-race:** use active local players pattern from navbar ([71cdc34](71cdc342c9))
2025-10-18 14:03:39 +00:00
Thomas Hallock
71cdc342c9 fix(complement-race): use active local players pattern from navbar
Fixed player emoji selection to be consistent with navbar's player
display logic. Both LinearTrack and CircularTrack now use the same
pattern as PageWithNav to get the current user's active local players.

Pattern:
- Get activePlayers Set from GameModeContext
- Map to player objects
- Filter for isLocal !== false (excludes remote players)
- Take first player's emoji

This ensures:
- Consistency with navbar display
- Correct handling of solo vs arcade room modes
- Proper filtering of remote players (isLocal === false)
- Future-proof for multi-player support

Files changed:
- LinearTrack.tsx:23,27-30
- CircularTrack.tsx:20,26-29

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 09:02:45 -05:00
semantic-release-bot
214b9077ab chore(release): 4.6.6 [skip ci]
## [4.6.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.5...v4.6.6) (2025-10-18)

### Bug Fixes

* **complement-race:** use local player emoji instead of first active player ([76eb051](76eb0517c2))
2025-10-18 13:58:33 +00:00
Thomas Hallock
76eb0517c2 fix(complement-race): use local player emoji instead of first active player
Fixed a bug where both LinearTrack and CircularTrack were displaying
the first active player's emoji instead of the current user's (local)
player emoji. This would cause incorrect avatar display in multi-player
arcade rooms.

Changed from:
- Getting first element of activePlayers array
- Would show wrong player in multi-player scenarios

To:
- Finding player with isLocal === true
- Correctly shows current user's avatar
- Matches pattern used throughout Provider.tsx

Files changed:
- LinearTrack.tsx: Use local player for emoji
- CircularTrack.tsx: Use local player for emoji

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 08:57:45 -05:00
semantic-release-bot
820000f93b chore(release): 4.6.5 [skip ci]
## [4.6.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.4...v4.6.5) (2025-10-18)

### Bug Fixes

* **complement-race:** flip player avatar to face right in practice mode ([fa6b3b6](fa6b3b69d5))
2025-10-18 13:48:07 +00:00
Thomas Hallock
fa6b3b69d5 fix(complement-race): flip player avatar to face right in practice mode
Adds scaleX(-1) to player racer icon so it faces the same direction as AI racers (racing to the right).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 08:47:11 -05:00
semantic-release-bot
ca4ba6e2d7 chore(release): 4.6.4 [skip ci]
## [4.6.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.3...v4.6.4) (2025-10-18)

### Bug Fixes

* **complement-race:** flip AI racers to face right in practice mode ([ebfff1a](ebfff1a62f))
2025-10-18 13:39:34 +00:00
Thomas Hallock
ebfff1a62f fix(complement-race): flip AI racers to face right in practice mode
Adds horizontal flip (scaleX(-1)) to AI racer icons in LinearTrack so they face the correct direction (right) when racing.

CircularTrack doesn't need this fix as racers are already rotated to face the direction they're traveling around the track.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 08:38:38 -05:00
semantic-release-bot
ba04d7f491 chore(release): 4.6.3 [skip ci]
## [4.6.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.2...v4.6.3) (2025-10-18)

### Bug Fixes

* **complement-race:** balance AI speeds to match original implementation ([054f0c0](054f0c0d23))
2025-10-18 13:32:32 +00:00
Thomas Hallock
054f0c0d23 fix(complement-race): balance AI speeds to match original implementation
Fixes AI opponents racing away too fast and ending the game in 2 seconds by restoring the original balanced speeds and mode-specific multipliers.

Changes:
- Reduce AI base speeds from 0.8-1.2 to 0.32 (Swift AI) and 0.2 (Math Bot)
- Add mode-specific speedMultipliers: practice (0.7), sprint (0.9), survival (1.0)
- Update AI names to match original: "Swift AI" and "Math Bot"

The original system uses much lower base speeds combined with:
- Random variance (0.6-1.4x per update)
- Rubber-banding (2x speed when >10 units behind)
- Adaptive difficulty adjustments based on player performance

This makes AI opponents challenging but fair, adapting to player skill rather than just racing ahead at fixed high speeds.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 08:31:38 -05:00
semantic-release-bot
45ff01e1fe chore(release): 4.6.2 [skip ci]
## [4.6.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.1...v4.6.2) (2025-10-18)

### Bug Fixes

* **build:** resolve Docker build failures preventing deployment ([7801dbb](7801dbb25f))
2025-10-18 13:13:31 +00:00
Thomas Hallock
7801dbb25f fix(build): resolve Docker build failures preventing deployment
Fixed two critical issues blocking deployment:

1. **TypeScript build failure**: Added @types/minimatch dependency and created
   proper tsconfig.json files for @soroban/core and @soroban/client packages.
   The DTS build was failing because TypeScript couldn't find the minimatch
   type definitions.

2. **Next.js prerendering error**: Fixed complement-race pages importing from
   wrong provider. Pages were using ./context/ComplementRaceContext but game
   components were using @/arcade-games/complement-race/Provider, causing
   "useComplementRace must be used within ComplementRaceProvider" errors
   during static page generation.

Deployment was blocked for 2 days. Container on NAS is from Oct 16th while
latest commits are from Oct 18th.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 08:12:41 -05:00
semantic-release-bot
10eb4df09c chore(release): 4.6.1 [skip ci]
## [4.6.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.0...v4.6.1) (2025-10-18)

### Code Refactoring

* **complement-race:** move AI opponents from server-side to client-side ([09e21fa](09e21fa493))
2025-10-18 13:08:37 +00:00
Thomas Hallock
09e21fa493 refactor(complement-race): move AI opponents from server-side to client-side
Migrates AI opponent system from server-side Validator to client-side Provider to align with original single-player implementation and enable sophisticated features.

Changes:
- Remove AI generation/updates from Validator (now returns empty aiOpponents array)
- Add clientAIRacers state to Provider (similar to clientMomentum/clientPosition)
- Initialize AI racers when game starts based on config.enableAI
- Handle UPDATE_AI_POSITIONS dispatch to update client-side AI state
- Map clientAIRacers to compatibleState.aiRacers for components to consume

This enables the existing useAIRacers hook to work properly with:
- Time-based position updates (200ms interval)
- Rubber-banding mechanic (AI speeds up 2x when >10 units behind)
- AI commentary system with personality-based messages
- Context detection and sound effects

AI wins are now detected client-side via useAIRacers hook instead of server-side Validator.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 08:07:38 -05:00
semantic-release-bot
0541c115c5 chore(release): 4.6.0 [skip ci]
## [4.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.5.0...v4.6.0) (2025-10-18)

### Features

* **complement-race:** restore AI opponents in practice and survival modes ([325e07d](325e07de59))
2025-10-18 12:59:01 +00:00
Thomas Hallock
325e07de59 feat(complement-race): restore AI opponents in practice and survival modes
PROBLEM:
Practice Race and Survival Circuit modes had no AI opponents visible, even
though the config had enableAI: true and aiOpponentCount: 2.

ROOT CAUSE:
The Validator's validateStartGame method (line 208) was initializing
aiOpponents as an empty array and never actually generating them, even
when config.enableAI was true.

This was likely lost during the multiplayer migration when the code was
refactored from single-player to multiplayer architecture.

FIX:
1. Added generateAIOpponents() method (lines 782-808)
   - Creates AI opponents with names like "Robo-Racer", "Calculator", etc.
   - Assigns personality types (competitive/analytical)
   - Gives each AI a random speed multiplier (0.8-1.2)

2. Call generateAIOpponents in validateStartGame (lines 209-212)
   - Only generates AI when config.enableAI is true and aiOpponentCount > 0

3. Added updateAIOpponents() method (lines 810-840)
   - Updates AI positions during the game
   - Practice mode: AI moves forward based on speed (simulates answering)
   - Survival mode: AI continuously moves forward
   - Sprint mode: AI doesn't participate (train journey is single-player)

4. Call updateAIOpponents in validateSubmitAnswer (lines 367-373)
   - AI opponents progress each time a human answers a question

5. Updated checkWinCondition (lines 904-909, 970-976)
   - Practice mode: Check if any AI reaches position 100
   - Survival mode: Check if any AI has highest position when time expires

BEHAVIOR:
- Practice Race now shows 2 AI opponents racing alongside you
- Survival Circuit now has AI competitors to beat
- AI opponents move at slightly randomized speeds for variety
- AI can win the race if they reach the goal first

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 07:58:09 -05:00
semantic-release-bot
03262dbf40 chore(release): 4.5.0 [skip ci]
## [4.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.15...v4.5.0) (2025-10-18)

### Features

* **complement-race:** add infinite win condition for Steam Sprint mode ([d8fdfee](d8fdfeef74))
2025-10-18 12:54:15 +00:00
Thomas Hallock
d8fdfeef74 feat(complement-race): add infinite win condition for Steam Sprint mode
PROBLEM:
After a few routes in Steam Sprint mode, the "victory!" page was appearing.
Steam Sprint is designed to be an infinite game that never ends.

ROOT CAUSE:
The default config had:
  winCondition: 'route-based'
  routeCount: 3

So after completing 3 routes, the validator's checkWinCondition method would
find a winner and trigger the results/victory screen (line 835 in Validator).

FIX:
1. Added 'infinite' as a new valid winCondition type (game-configs.ts:92)
2. Updated default config to use winCondition: 'infinite' (both in
   arcade-games/complement-race/index.tsx and lib/arcade/game-configs.ts)
3. Updated checkWinCondition to return null immediately when winCondition === 'infinite'
   (Validator.ts:815-818)

BEHAVIOR:
- Steam Sprint now runs indefinitely with infinite routes
- Train automatically advances to next route after completing each one
- Game never ends unless player manually quits or leaves room
- Score and deliveries continue to accumulate across all routes
- Other win conditions (route-based, score-based, time-based) still work for
  custom game modes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 07:53:14 -05:00
semantic-release-bot
005d945ca8 chore(release): 4.4.15 [skip ci]
## [4.4.15](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.14...v4.4.15) (2025-10-18)

### Bug Fixes

* **complement-race:** track previous position to detect route threshold crossing ([a6c20aa](a6c20aab3b))
2025-10-18 00:58:29 +00:00
Thomas Hallock
a6c20aab3b fix(complement-race): track previous position to detect route threshold crossing
PROBLEM:
Previous fix broke route progression - train would never advance to next route.

ROOT CAUSE:
After removing the dual game loop, I changed line 97 to:
  const trainPosition = state.trainPosition

This meant the route completion check (lines 296-299) became:
  if (
    state.trainPosition >= threshold &&
    state.trainPosition < threshold  // Same variable!
  )

This is ALWAYS FALSE because a value can't be both >= and < threshold.

The previous code worked because:
- trainPosition was the newly calculated position for this frame
- state.trainPosition was the previous frame's position
- So it could detect threshold crossing: newPos >= threshold && oldPos < threshold

FIX:
1. Added previousTrainPositionRef to track position from previous frame (line 48)
2. Updated route completion check to use previousPosition instead of state.trainPosition (line 300)
3. Store current position in ref at end of each frame (line 323)
4. Reset previousPosition when route changes (line 82)
5. Added debug logging when route completes (line 310-312)

Now the check correctly detects:
  if (
    trainPosition >= threshold &&      // Current position
    previousPosition < threshold       // Previous position
  )

This properly detects when the train crosses the exit threshold.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 19:57:34 -05:00
semantic-release-bot
627ca68cff chore(release): 4.4.14 [skip ci]
## [4.4.14](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.13...v4.4.14) (2025-10-18)

### Bug Fixes

* **complement-race:** remove dual game loop conflict preventing route progression ([84d42e2](84d42e22ac))
2025-10-18 00:54:05 +00:00
Thomas Hallock
84d42e22ac fix(complement-race): remove dual game loop conflict preventing route progression
PROBLEM:
Route progression was broken - train would never advance to next route after
completing Route 1. This was a regression after fixing passenger display issues.

ROOT CAUSE:
There were TWO conflicting game loops both managing train position:

1. Provider.tsx (lines 448-491): Updates clientPosition based on clientMomentum
2. useSteamJourney.ts (lines 87-126): Was calculating its own trainPosition and
   dispatching UPDATE_STEAM_JOURNEY

The critical issue: useSteamJourney dispatched UPDATE_STEAM_JOURNEY to update
position, but Provider IGNORES this action (line 764). This meant:
- useSteamJourney calculated a local trainPosition variable each frame
- It used this to check route completion threshold
- But state.trainPosition was stale because the dispatch never updated it
- So the route completion condition never triggered

FIX:
1. useSteamJourney.ts: Removed redundant position calculation (lines 95-125)
   - Now just reads state.trainPosition from Provider instead of calculating own
   - Game logic (boarding, delivery, route completion) uses authoritative position

2. useTrackManagement.ts: Changed trainPosition < 0 to <= 0 (lines 70, 82)
   - Provider resets clientPosition to exactly 0 (not negative)
   - This allows track and passenger display to update on route reset

3. Provider.tsx: Added debug logging for route changes and START_NEW_ROUTE
   - Helps diagnose route progression issues in console

4. Test files: Fixed TypeScript errors from API changes
   - Added missing maxCars and carSpacing parameters
   - Added missing name property to mock passengers

ARCHITECTURE NOTE:
The game now has a clear separation of concerns:
- Provider.tsx: Manages visual state (position, momentum, pressure)
- useSteamJourney.ts: Reads visual state and handles game logic
- Single source of truth for train position

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 19:53:02 -05:00
Thomas Hallock
37866ebb6d debug(complement-race): add logging for route transitions
Add temporary debug logging to diagnose why passengers aren't appearing
after route 1:

1. Log when START_NEW_ROUTE move is dispatched with route number
2. Log when Provider detects route change and how many passengers exist

This will help identify if the issue is:
- Move not being sent
- Server not generating passengers
- State update not propagating to client
- Display logic not showing passengers

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 18:18:06 -05:00
semantic-release-bot
7030794fa1 chore(release): 4.4.13 [skip ci]
## [4.4.13](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.12...v4.4.13) (2025-10-17)

### Bug Fixes

* **complement-race:** show new passengers when route changes ([ec1c8ed](ec1c8ed263))
2025-10-17 23:05:10 +00:00
Thomas Hallock
ec1c8ed263 fix(complement-race): show new passengers when route changes
Fixed bug where passengers wouldn't appear after completing a route.

Problem: When a new route started, the Provider reset trainPosition to 0,
but useTrackManagement was checking `trainPosition < 0` to detect resets.
Since 0 is not < 0, the condition failed and passengers didn't update.

Root cause:
1. Route completes, train at position ~107%
2. Client dispatches START_NEW_ROUTE with routeNumber=2
3. Server validates and broadcasts new state (route=2, new passengers)
4. Provider resets clientPosition to 0
5. useTrackManagement checks:
   - trainReset = trainPosition < 0 → FALSE (0 is not < 0!)
   - sameRoute = currentRoute === displayRouteRef.current → FALSE (2 !== 1)
6. Neither condition met, so displayPassengers doesn't update

Solution: Changed trainReset condition from `trainPosition < 0` to
`trainPosition <= 0` to treat position 0 as a reset. Also updated
the pending track application logic with the same fix.

Now passengers appear immediately when a new route starts.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 18:04:23 -05:00
22 changed files with 294 additions and 74 deletions

View File

@@ -1,3 +1,94 @@
## [4.6.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.7...v4.6.8) (2025-10-18)
### Bug Fixes
* **complement-race:** counter-flip AI speech bubbles to make text readable ([07d5607](https://github.com/antialias/soroban-abacus-flashcards/commit/07d5607218aee03e813eceff5d161a7838d66bcb))
## [4.6.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.6...v4.6.7) (2025-10-18)
### Bug Fixes
* **complement-race:** use active local players pattern from navbar ([71cdc34](https://github.com/antialias/soroban-abacus-flashcards/commit/71cdc342c97ca53b5e7e4202d4d344199e8ddd98))
## [4.6.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.5...v4.6.6) (2025-10-18)
### Bug Fixes
* **complement-race:** use local player emoji instead of first active player ([76eb051](https://github.com/antialias/soroban-abacus-flashcards/commit/76eb0517c202d1b9160b49dec0b99ff4972daff2))
## [4.6.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.4...v4.6.5) (2025-10-18)
### Bug Fixes
* **complement-race:** flip player avatar to face right in practice mode ([fa6b3b6](https://github.com/antialias/soroban-abacus-flashcards/commit/fa6b3b69d5a4a7eb70f8c18fc8c122c54c4d504a))
## [4.6.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.3...v4.6.4) (2025-10-18)
### Bug Fixes
* **complement-race:** flip AI racers to face right in practice mode ([ebfff1a](https://github.com/antialias/soroban-abacus-flashcards/commit/ebfff1a62fd104d531a8158345c8c012ec8a55d3))
## [4.6.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.2...v4.6.3) (2025-10-18)
### Bug Fixes
* **complement-race:** balance AI speeds to match original implementation ([054f0c0](https://github.com/antialias/soroban-abacus-flashcards/commit/054f0c0d235dc2b0042a0f6af48840d23a4c5ff8))
## [4.6.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.1...v4.6.2) (2025-10-18)
### Bug Fixes
* **build:** resolve Docker build failures preventing deployment ([7801dbb](https://github.com/antialias/soroban-abacus-flashcards/commit/7801dbb25fb0a33429c70f11294264f7238ce7a4))
## [4.6.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.0...v4.6.1) (2025-10-18)
### Code Refactoring
* **complement-race:** move AI opponents from server-side to client-side ([09e21fa](https://github.com/antialias/soroban-abacus-flashcards/commit/09e21fa4934c634d0ce46381ef7e40238fc134c3))
## [4.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.5.0...v4.6.0) (2025-10-18)
### Features
* **complement-race:** restore AI opponents in practice and survival modes ([325e07d](https://github.com/antialias/soroban-abacus-flashcards/commit/325e07de5929169aa333ef16f7bca5b41eeb1622))
## [4.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.15...v4.5.0) (2025-10-18)
### Features
* **complement-race:** add infinite win condition for Steam Sprint mode ([d8fdfee](https://github.com/antialias/soroban-abacus-flashcards/commit/d8fdfeef74a5d3bb9684254af1c9d64d264b46ad))
## [4.4.15](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.14...v4.4.15) (2025-10-18)
### Bug Fixes
* **complement-race:** track previous position to detect route threshold crossing ([a6c20aa](https://github.com/antialias/soroban-abacus-flashcards/commit/a6c20aab3b245d9893808d188d16a35ab80cfca9))
## [4.4.14](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.13...v4.4.14) (2025-10-18)
### Bug Fixes
* **complement-race:** remove dual game loop conflict preventing route progression ([84d42e2](https://github.com/antialias/soroban-abacus-flashcards/commit/84d42e22ac0cdd25e87e45dc698029ad7ed78559))
## [4.4.13](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.12...v4.4.13) (2025-10-17)
### Bug Fixes
* **complement-race:** show new passengers when route changes ([ec1c8ed](https://github.com/antialias/soroban-abacus-flashcards/commit/ec1c8ed263844f56477c1f709041339b42b48f4e))
## [4.4.12](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.11...v4.4.12) (2025-10-17)

View File

@@ -17,15 +17,16 @@ interface CircularTrackProps {
export function CircularTrack({ playerProgress, playerLap, aiRacers, aiLaps }: CircularTrackProps) {
const { state, dispatch } = useComplementRace()
const { players } = useGameMode()
const { players, activePlayers } = useGameMode()
const { profile: _profile } = useUserProfile()
const { playSound } = useSoundEffects()
const [celebrationCooldown, setCelebrationCooldown] = useState<Set<string>>(new Set())
// 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 current user's active local players (consistent with navbar pattern)
const activeLocalPlayers = Array.from(activePlayers)
.map((id) => players.get(id))
.filter((p): p is NonNullable<typeof p> => p !== undefined && p.isLocal !== false)
const playerEmoji = activeLocalPlayers[0]?.emoji ?? '👤'
const [dimensions, setDimensions] = useState({ width: 600, height: 400 })
// Update dimensions on mount and resize

View File

@@ -20,13 +20,14 @@ export function LinearTrack({
showFinishLine = true,
}: LinearTrackProps) {
const { state, dispatch } = useComplementRace()
const { players } = useGameMode()
const { players, activePlayers } = 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 current user's active local players (consistent with navbar pattern)
const activeLocalPlayers = Array.from(activePlayers)
.map((id) => players.get(id))
.filter((p): p is NonNullable<typeof p> => p !== undefined && p.isLocal !== false)
const playerEmoji = activeLocalPlayers[0]?.emoji ?? '👤'
// Position calculation: leftPercent = Math.min(98, (progress / raceGoal) * 96 + 2)
// 2% minimum (start), 98% maximum (near finish), 96% range for race
@@ -110,7 +111,7 @@ export function LinearTrack({
position: 'absolute',
left: `${playerPosition}%`,
top: '50%',
transform: 'translate(-50%, -50%)',
transform: 'translate(-50%, -50%) scaleX(-1)',
fontSize: '32px',
transition: 'left 0.3s ease-out',
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
@@ -132,7 +133,7 @@ export function LinearTrack({
position: 'absolute',
left: `${aiPosition}%`,
top: `${35 + index * 15}%`,
transform: 'translate(-50%, -50%)',
transform: 'translate(-50%, -50%) scaleX(-1)',
fontSize: '28px',
transition: 'left 0.2s linear',
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
@@ -141,10 +142,16 @@ export function LinearTrack({
>
{racer.icon}
{activeBubble && (
<SpeechBubble
message={activeBubble}
onHide={() => dispatch({ type: 'CLEAR_AI_COMMENT', racerId: racer.id })}
/>
<div
style={{
transform: 'scaleX(-1)', // Counter-flip bubble to make text readable
}}
>
<SpeechBubble
message={activeBubble}
onHide={() => dispatch({ type: 'CLEAR_AI_COMMENT', racerId: racer.id })}
/>
</div>
)}
</div>
)

View File

@@ -45,6 +45,7 @@ export function useSteamJourney() {
const routeExitThresholdRef = useRef<number>(107) // Default for 1 car: 100 + 7
const missedPassengersRef = useRef<Set<string>>(new Set()) // Track which passengers have been logged as missed
const pendingBoardingRef = useRef<Set<string>>(new Set()) // Track passengers with pending boarding requests across frames
const previousTrainPositionRef = useRef<number>(0) // Track previous position to detect threshold crossings
// Initialize game start time
useEffect(() => {
@@ -78,6 +79,7 @@ export function useSteamJourney() {
useEffect(() => {
pendingBoardingRef.current.clear()
missedPassengersRef.current.clear()
previousTrainPositionRef.current = 0 // Reset previous position for new route
}, [state.currentRoute])
// Momentum decay and position update loop
@@ -92,37 +94,9 @@ export function useSteamJourney() {
// Steam Sprint is infinite - no time limit
// Get decay rate based on timeout setting (skill level)
const decayRate =
MOMENTUM_DECAY_RATES[state.timeoutSetting as keyof typeof MOMENTUM_DECAY_RATES] ||
MOMENTUM_DECAY_RATES.normal
// Calculate momentum decay for this frame
const momentumLoss = (decayRate * deltaTime) / 1000
// Update momentum (don't go below 0)
const newMomentum = Math.max(0, state.momentum - momentumLoss)
// Calculate speed from momentum (% per second)
const speed = newMomentum * SPEED_MULTIPLIER
// Update train position (accumulate, never go backward)
// Allow position to go past 100% so entire train (including cars) can exit tunnel
const positionDelta = (speed * deltaTime) / 1000
const trainPosition = state.trainPosition + positionDelta
// Calculate pressure (0-150 PSI) - based on momentum as percentage of max
const maxMomentum = 100 // Theoretical max momentum
const pressure = Math.min(150, (newMomentum / maxMomentum) * 150)
// Update state
dispatch({
type: 'UPDATE_STEAM_JOURNEY',
momentum: newMomentum,
trainPosition,
pressure,
elapsedTime: elapsed,
})
// Train position, momentum, and pressure are all managed by the Provider's game loop
// This hook only reads those values and handles game logic (boarding, delivery, route completion)
const trainPosition = state.trainPosition
// Check for passengers that should board
// Passengers board when an EMPTY car reaches their station
@@ -320,10 +294,11 @@ export function useSteamJourney() {
// Check for route completion (entire train exits tunnel)
const ENTIRE_TRAIN_EXIT_THRESHOLD = routeExitThresholdRef.current
const previousPosition = previousTrainPositionRef.current
if (
trainPosition >= ENTIRE_TRAIN_EXIT_THRESHOLD &&
state.trainPosition < ENTIRE_TRAIN_EXIT_THRESHOLD
previousPosition < ENTIRE_TRAIN_EXIT_THRESHOLD
) {
// Play celebration whistle
playSound('train_whistle', 0.6)
@@ -333,6 +308,9 @@ export function useSteamJourney() {
// Auto-advance to next route
const nextRoute = state.currentRoute + 1
console.log(
`🏁 ROUTE COMPLETE: Train crossed exit threshold (${trainPosition.toFixed(1)} >= ${ENTIRE_TRAIN_EXIT_THRESHOLD}). Advancing to Route ${nextRoute}`
)
dispatch({
type: 'START_NEW_ROUTE',
routeNumber: nextRoute,
@@ -341,6 +319,9 @@ export function useSteamJourney() {
// Note: New passengers will be generated by the server when it handles START_NEW_ROUTE
}
// Update previous position for next frame
previousTrainPositionRef.current = trainPosition
}, UPDATE_INTERVAL)
return () => clearInterval(interval)

View File

@@ -67,7 +67,7 @@ export function useTrackManagement({
// Apply pending track when train resets to beginning
useEffect(() => {
if (pendingTrackData && trainPosition < 0) {
if (pendingTrackData && trainPosition <= 0) {
setTrackData(pendingTrackData)
previousRouteRef.current = currentRoute
setPendingTrackData(null)
@@ -77,9 +77,9 @@ export function useTrackManagement({
// Manage passenger display during route transitions
useEffect(() => {
// Only switch to new passengers when:
// 1. Train has reset to start position (< 0) - track has changed, OR
// 1. Train has reset to start position (<= 0) - track has changed, OR
// 2. Same route AND (in middle of track OR passengers have changed state)
const trainReset = trainPosition < 0
const trainReset = trainPosition <= 0
const sameRoute = currentRoute === displayRouteRef.current
const inMiddleOfTrack = trainPosition >= 10 && trainPosition < 90 // Avoid start/end transition zones

View File

@@ -2,7 +2,7 @@
import { PageWithNav } from '@/components/PageWithNav'
import { ComplementRaceGame } from './components/ComplementRaceGame'
import { ComplementRaceProvider } from './context/ComplementRaceContext'
import { ComplementRaceProvider } from '@/arcade-games/complement-race/Provider'
export default function ComplementRacePage() {
return (

View File

@@ -2,7 +2,7 @@
import { PageWithNav } from '@/components/PageWithNav'
import { ComplementRaceGame } from '../components/ComplementRaceGame'
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
import { ComplementRaceProvider } from '@/arcade-games/complement-race/Provider'
export default function PracticeModePage() {
return (

View File

@@ -2,7 +2,7 @@
import { PageWithNav } from '@/components/PageWithNav'
import { ComplementRaceGame } from '../components/ComplementRaceGame'
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
import { ComplementRaceProvider } from '@/arcade-games/complement-race/Provider'
export default function SprintModePage() {
return (

View File

@@ -2,7 +2,7 @@
import { PageWithNav } from '@/components/PageWithNav'
import { ComplementRaceGame } from '../components/ComplementRaceGame'
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
import { ComplementRaceProvider } from '@/arcade-games/complement-race/Provider'
export default function SurvivalModePage() {
return (

View File

@@ -29,6 +29,7 @@ describe('GameHUD', () => {
const mockPassenger: Passenger = {
id: 'passenger-1',
name: 'Test Passenger',
avatar: '👨',
originStationId: 'station-1',
destinationStationId: 'station-2',

View File

@@ -44,6 +44,7 @@ describe('usePassengerAnimations', () => {
// Create mock passengers
mockPassenger1 = {
id: 'passenger-1',
name: 'Passenger 1',
avatar: '👨',
originStationId: 'station-1',
destinationStationId: 'station-2',
@@ -54,6 +55,7 @@ describe('usePassengerAnimations', () => {
mockPassenger2 = {
id: 'passenger-2',
name: 'Passenger 2',
avatar: '👩',
originStationId: 'station-1',
destinationStationId: 'station-2',

View File

@@ -52,6 +52,7 @@ describe('useTrackManagement', () => {
mockPassengers = [
{
id: 'passenger-1',
name: 'Test Passenger',
avatar: '👨',
originStationId: 'station-1',
destinationStationId: 'station-2',
@@ -73,6 +74,8 @@ describe('useTrackManagement', () => {
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
maxCars: 3,
carSpacing: 7,
})
)
@@ -90,6 +93,8 @@ describe('useTrackManagement', () => {
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
maxCars: 3,
carSpacing: 7,
})
)
@@ -107,6 +112,8 @@ describe('useTrackManagement', () => {
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
maxCars: 3,
carSpacing: 7,
})
)
@@ -123,6 +130,8 @@ describe('useTrackManagement', () => {
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
maxCars: 3,
carSpacing: 7,
})
)
@@ -142,6 +151,8 @@ describe('useTrackManagement', () => {
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
maxCars: 3,
carSpacing: 7,
})
)
@@ -161,6 +172,8 @@ describe('useTrackManagement', () => {
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
maxCars: 3,
carSpacing: 7,
}),
{
initialProps: { route: 1, position: 0 },
@@ -187,6 +200,8 @@ describe('useTrackManagement', () => {
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
maxCars: 3,
carSpacing: 7,
}),
{
initialProps: { route: 1, position: 0 },
@@ -214,6 +229,8 @@ describe('useTrackManagement', () => {
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
maxCars: 3,
carSpacing: 7,
}),
{
initialProps: { route: 1, position: -5 },
@@ -233,6 +250,7 @@ describe('useTrackManagement', () => {
const newPassengers: Passenger[] = [
{
id: 'passenger-2',
name: 'New Passenger',
avatar: '👩',
originStationId: 'station-1',
destinationStationId: 'station-2',
@@ -273,6 +291,7 @@ describe('useTrackManagement', () => {
const newPassengers: Passenger[] = [
{
id: 'passenger-2',
name: 'New Passenger',
avatar: '👩',
originStationId: 'station-1',
destinationStationId: 'station-2',
@@ -354,6 +373,8 @@ describe('useTrackManagement', () => {
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
maxCars: 3,
carSpacing: 7,
})
)

View File

@@ -317,6 +317,19 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
const [clientMomentum, setClientMomentum] = useState(10) // Start at 10 for gentle push
const [clientPosition, setClientPosition] = useState(0)
const [clientPressure, setClientPressure] = useState(0)
const [clientAIRacers, setClientAIRacers] = useState<
Array<{
id: string
name: string
position: number
speed: number
personality: 'competitive' | 'analytical'
icon: string
lastComment: number
commentCooldown: number
previousPosition: number
}>
>([])
const lastUpdateRef = useRef(Date.now())
const gameStartTimeRef = useRef(0)
@@ -387,18 +400,13 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
// Race mechanics
raceGoal: multiplayerState.config.raceGoal,
timeLimit: multiplayerState.config.timeLimit ?? null,
speedMultiplier: 1.0,
aiRacers: multiplayerState.aiOpponents.map((ai) => ({
id: ai.id,
name: ai.name,
position: ai.position,
speed: ai.speed,
personality: ai.personality,
icon: ai.personality === 'competitive' ? '🏃‍♂️' : '🏃',
lastComment: ai.lastCommentTime,
commentCooldown: 0,
previousPosition: ai.position,
})),
speedMultiplier:
multiplayerState.config.style === 'practice'
? 0.7
: multiplayerState.config.style === 'sprint'
? 0.9
: 1.0, // Base speed multipliers by mode
aiRacers: clientAIRacers, // Use client-side AI state
// Sprint mode specific (all client-side for smooth movement)
momentum: clientMomentum, // Client-only state with continuous decay
@@ -425,7 +433,15 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
adaptiveFeedback: localUIState.adaptiveFeedback,
difficultyTracker: localUIState.difficultyTracker,
}
}, [multiplayerState, localPlayerId, localUIState, clientPosition, clientPressure])
}, [
multiplayerState,
localPlayerId,
localUIState,
clientPosition,
clientPressure,
clientMomentum,
clientAIRacers,
])
// Initialize game start time when game becomes active
useEffect(() => {
@@ -444,6 +460,43 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
}
}, [compatibleState.isGameActive, compatibleState.style])
// Initialize AI racers when game starts
useEffect(() => {
if (compatibleState.isGameActive && multiplayerState.config.enableAI) {
const count = multiplayerState.config.aiOpponentCount
if (count > 0 && clientAIRacers.length === 0) {
const aiNames = ['Swift AI', 'Math Bot', 'Speed Demon', 'Brain Bot']
const personalities: Array<'competitive' | 'analytical'> = ['competitive', 'analytical']
const newAI = []
for (let i = 0; i < Math.min(count, aiNames.length); i++) {
// Use original balanced speeds: 0.32 for Swift AI, 0.2 for Math Bot
const baseSpeed = i === 0 ? 0.32 : 0.2
newAI.push({
id: `ai-${i}`,
name: aiNames[i],
personality: personalities[i % personalities.length] as 'competitive' | 'analytical',
position: 0,
speed: baseSpeed, // Balanced speed from original single-player version
icon: personalities[i % personalities.length] === 'competitive' ? '🏃‍♂️' : '🏃',
lastComment: 0,
commentCooldown: 0,
previousPosition: 0,
})
}
setClientAIRacers(newAI)
}
} else if (!compatibleState.isGameActive) {
// Clear AI when game ends
setClientAIRacers([])
}
}, [
compatibleState.isGameActive,
multiplayerState.config.enableAI,
multiplayerState.config.aiOpponentCount,
clientAIRacers.length,
])
// Main client-side game loop: momentum decay and position calculation
useEffect(() => {
if (!compatibleState.isGameActive || compatibleState.style !== 'sprint') return
@@ -495,10 +548,13 @@ 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.currentRoute, compatibleState.style, multiplayerState.passengers.length])
// Keep lastLogRef for future debugging needs
// (removed debug logging)
@@ -713,6 +769,7 @@ 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] || '',
@@ -753,8 +810,27 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
})
break
}
case 'UPDATE_AI_POSITIONS': {
// Update client-side AI positions
if (action.positions && Array.isArray(action.positions)) {
setClientAIRacers((prevRacers) =>
prevRacers.map((racer) => {
const update = action.positions.find(
(p: { id: string; position: number }) => p.id === racer.id
)
return update
? {
...racer,
previousPosition: racer.position,
position: update.position,
}
: racer
})
)
}
break
}
// Other local actions that don't affect UI (can be ignored for now)
case 'UPDATE_AI_POSITIONS':
case 'UPDATE_MOMENTUM':
case 'UPDATE_TRAIN_POSITION':
case 'UPDATE_STEAM_JOURNEY':

View File

@@ -218,6 +218,7 @@ export class ComplementRaceValidator
routeStartTime: state.config.style === 'sprint' ? Date.now() : null,
raceStartTime: Date.now(), // Race starts immediately
gameStartTime: Date.now(),
aiOpponents: [], // AI handled client-side
}
return { valid: true, newState }
@@ -812,6 +813,11 @@ export class ComplementRaceValidator
private checkWinCondition(state: ComplementRaceState): string | null {
const { config, players } = state
// Infinite mode: Never end the game
if (config.winCondition === 'infinite') {
return null
}
// Practice mode: First to reach goal
if (config.style === 'practice') {
for (const [playerId, player] of Object.entries(players)) {
@@ -819,6 +825,7 @@ export class ComplementRaceValidator
return playerId
}
}
// AI wins handled client-side via useAIRacers hook
}
// Sprint mode: Check route-based, score-based, or time-based win conditions
@@ -870,12 +877,15 @@ export class ComplementRaceValidator
// Find player with highest position (most laps)
let maxPosition = 0
let winner: string | null = null
for (const [playerId, player] of Object.entries(players)) {
if (player.position > maxPosition) {
maxPosition = player.position
winner = playerId
}
}
// AI wins handled client-side via useAIRacers hook
return winner
}
}

View File

@@ -41,7 +41,7 @@ const defaultConfig: ComplementRaceConfig = {
passengerCount: 6,
maxConcurrentPassengers: 3,
raceGoal: 20,
winCondition: 'route-based',
winCondition: 'infinite', // Sprint mode is infinite by default (Steam Sprint)
routeCount: 3,
targetScore: 100,
timeLimit: 300,

View File

@@ -89,7 +89,7 @@ export interface ComplementRaceGameConfig {
raceGoal: number // questions to win practice mode (default 20)
// Win Conditions
winCondition: 'route-based' | 'score-based' | 'time-based'
winCondition: 'route-based' | 'score-based' | 'time-based' | 'infinite'
targetScore?: number // for score-based (e.g., 100)
timeLimit?: number // for time-based (e.g., 300 seconds)
routeCount?: number // for route-based (e.g., 3 routes)
@@ -171,7 +171,7 @@ export const DEFAULT_COMPLEMENT_RACE_CONFIG: ComplementRaceGameConfig = {
raceGoal: 20,
// Win conditions
winCondition: 'route-based',
winCondition: 'infinite', // Sprint mode is infinite by default (Steam Sprint)
routeCount: 3,
targetScore: 100,
timeLimit: 300,

View File

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

View File

@@ -22,6 +22,7 @@
"python-shell": "^5.0.0"
},
"devDependencies": {
"@types/minimatch": "^6.0.0",
"@types/node": "^20.0.0",
"tsup": "^7.0.0",
"typescript": "^5.0.0",

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View File

@@ -29,6 +29,7 @@
"author": "",
"license": "MIT",
"devDependencies": {
"@types/minimatch": "^6.0.0",
"@types/node": "^20.0.0",
"tsup": "^7.0.0",
"typescript": "^5.0.0",

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

6
pnpm-lock.yaml generated
View File

@@ -402,6 +402,9 @@ importers:
specifier: ^5.0.0
version: 5.0.0
devDependencies:
'@types/minimatch':
specifier: ^6.0.0
version: 6.0.0
'@types/node':
specifier: ^20.0.0
version: 20.19.19
@@ -417,6 +420,9 @@ importers:
packages/core/client/typescript:
devDependencies:
'@types/minimatch':
specifier: ^6.0.0
version: 6.0.0
'@types/node':
specifier: ^20.0.0
version: 20.19.19