Compare commits

...

35 Commits

Author SHA1 Message Date
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
semantic-release-bot
12f140d888 chore(release): 4.4.12 [skip ci]
## [4.4.12](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.11...v4.4.12) (2025-10-17)

### Bug Fixes

* **complement-race:** track physical car indices to prevent boarding issues ([53bbae8](53bbae84af))
* **complement-race:** update passenger display when state changes ([5116364](511636400c))
2025-10-17 22:37:03 +00:00
Thomas Hallock
53bbae84af fix(complement-race): track physical car indices to prevent boarding issues
Fixed critical bug where passengers were missing boarding opportunities
due to array index confusion after deliveries.

Problem: After a passenger was delivered, the currentBoardedPassengers
array would compact but physical car positions wouldn't change. The
occupiedCars map was using array indices instead of physical car numbers,
causing cars to incorrectly appear occupied.

Example bug scenario:
- Train has 4 cars (indices 0-3)
- Kate boards Car 0, Frank Car 1, Mia Car 2, Charlie Car 3
- Kate delivers from Car 0
- Array compacts: [Frank, Mia, Charlie] at indices [0, 1, 2]
- occupiedCars map now shows cars 0,1,2 occupied (WRONG!)
- Physical Car 0 is actually EMPTY, but Alice can't board

Solution: Added carIndex field to Passenger type to track physical car
number (0-N) independently from array position.

Changes:
- Added carIndex: number | null to Passenger type
- Updated Validator to store physical carIndex when boarding
- Updated Provider to pass carIndex in BOARD_PASSENGER moves
- Updated useSteamJourney to use passenger.carIndex for all car
  position calculations and occupancy tracking
- Reordered frame processing so DELIVERY moves dispatch before BOARDING
  moves to prevent race conditions
- Fixed configuration mismatch: client now uses server's authoritative
  maxConcurrentPassengers instead of calculating locally
- Updated visual display to use server's maxConcurrentPassengers

Also includes improved logging for debugging train configuration and
passenger movement.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 17:36:06 -05:00
Thomas Hallock
511636400c fix(complement-race): update passenger display when state changes
Fix visual desync where delivered passengers remained displayed at stations.

The displayPassengers state only updated when train was at start (< 0%)
or in middle of track (10-90%). Passengers delivered at positions > 90%
(e.g., trainPos 103-130%) weren't reflected in the UI even though server
state was correct.

Now detects when passenger states change (boarding or delivery via
claimedBy/deliveredBy fields) and updates display immediately regardless
of train position. This ensures passenger cards disappear from HUD as
soon as passengers are delivered.

Also updated test files to use multiplayer Passenger type with
claimedBy/deliveredBy/carIndex/timestamp instead of old
isBoarded/isDelivered format.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 17:36:06 -05:00
semantic-release-bot
79db410b09 chore(release): 4.4.11 [skip ci]
## [4.4.11](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.10...v4.4.11) (2025-10-17)

### Code Refactoring

* **logging:** replace per-frame debug logging with event-based logging ([fedb324](fedb32486a))
2025-10-17 13:27:02 +00:00
Thomas Hallock
fedb32486a refactor(logging): replace per-frame debug logging with event-based logging
Replaced massive per-frame debug logs (20 logs/second) with smart event-based logging:
- One summary log at game start showing passengers and their routes
- One log per boarding event (only when passenger boards)
- One log per delivery event (only when passenger delivers)

This provides actionable diagnostics without flooding the console or context window.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 08:26:10 -05:00
Thomas Hallock
183494a22e debug: enable passenger boarding debug logging 2025-10-17 08:13:31 -05:00
semantic-release-bot
325daeb0d9 chore(release): 4.4.10 [skip ci]
## [4.4.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.9...v4.4.10) (2025-10-17)

### Bug Fixes

* **complement-race:** correct passenger boarding to use multiplayer fields ([7ed1b94](7ed1b94b8f))
2025-10-17 13:07:12 +00:00
Thomas Hallock
7ed1b94b8f fix(complement-race): correct passenger boarding to use multiplayer fields
Passengers weren't boarding because the arcade room version was still checking for single-player fields (isBoarded/isDelivered) instead of multiplayer fields (claimedBy/deliveredBy).

Changes:
- Update useSteamJourney to check claimedBy/deliveredBy instead of isBoarded/isDelivered
- Update Validator to skip position validation in sprint mode (position is client-side)
- Trust client-side spatial checking for boarding/delivery in sprint mode

Sprint mode architecture:
- Client (useSteamJourney) continuously checks if train is at stations
- Client sends CLAIM_PASSENGER / DELIVER_PASSENGER moves when conditions met
- Server validates passenger availability (not position, since position is client-side)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 08:06:06 -05:00
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
35 changed files with 995 additions and 613 deletions

View File

@@ -1,3 +1,116 @@
## [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)
### Bug Fixes
* **complement-race:** track physical car indices to prevent boarding issues ([53bbae8](https://github.com/antialias/soroban-abacus-flashcards/commit/53bbae84af7317d5e12109db2054cc70ca5bea27))
* **complement-race:** update passenger display when state changes ([5116364](https://github.com/antialias/soroban-abacus-flashcards/commit/511636400c19776b58c6bddf8f7c9cc398a05236))
## [4.4.11](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.10...v4.4.11) (2025-10-17)
### Code Refactoring
* **logging:** replace per-frame debug logging with event-based logging ([fedb324](https://github.com/antialias/soroban-abacus-flashcards/commit/fedb32486ab5c6c619ebc03570b6c66529a1344e))
## [4.4.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.9...v4.4.10) (2025-10-17)
### Bug Fixes
* **complement-race:** correct passenger boarding to use multiplayer fields ([7ed1b94](https://github.com/antialias/soroban-abacus-flashcards/commit/7ed1b94b8fa620cb4f64ba43e160ef511704f3ce))
## [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)

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)
@@ -109,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) {
@@ -144,6 +142,11 @@ 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) {

View File

@@ -1,7 +1,7 @@
'use client'
import { memo } from 'react'
import type { Passenger, Station } from '../lib/gameTypes'
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
interface PassengerCardProps {
passenger: Passenger
@@ -17,24 +17,27 @@ export const PassengerCard = memo(function PassengerCard({
if (!destinationStation || !originStation) return null
// Vintage train station colors
const bgColor = passenger.isDelivered
// Arcade room multiplayer uses claimedBy/deliveredBy instead of isBoarded/isDelivered
const isBoarded = passenger.claimedBy !== null
const isDelivered = passenger.deliveredBy !== null
const bgColor = isDelivered
? '#1a3a1a' // Dark green for delivered
: !passenger.isBoarded
: !isBoarded
? '#2a2419' // Dark brown/sepia for waiting
: passenger.isUrgent
? '#3a2419' // Dark red-brown for urgent
: '#1a2a3a' // Dark blue for aboard
const accentColor = passenger.isDelivered
const accentColor = isDelivered
? '#4ade80' // Green
: !passenger.isBoarded
: !isBoarded
? '#d4af37' // Gold for waiting
: passenger.isUrgent
? '#ff6b35' // Orange-red for urgent
: '#60a5fa' // Blue for aboard
const borderColor =
passenger.isUrgent && passenger.isBoarded && !passenger.isDelivered ? '#ff6b35' : '#d4af37'
const borderColor = passenger.isUrgent && isBoarded && !isDelivered ? '#ff6b35' : '#d4af37'
return (
<div
@@ -46,13 +49,13 @@ export const PassengerCard = memo(function PassengerCard({
minWidth: '220px',
maxWidth: '280px',
boxShadow:
passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded
passenger.isUrgent && !isDelivered && isBoarded
? '0 0 16px rgba(255, 107, 53, 0.5)'
: '0 4px 12px rgba(0, 0, 0, 0.4)',
position: 'relative',
fontFamily: '"Courier New", Courier, monospace',
animation:
passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded
passenger.isUrgent && !isDelivered && isBoarded
? 'urgentFlicker 1.5s ease-in-out infinite'
: 'none',
transition: 'all 0.3s ease',
@@ -79,7 +82,7 @@ export const PassengerCard = memo(function PassengerCard({
}}
>
<div style={{ fontSize: '20px', lineHeight: '1' }}>
{passenger.isDelivered ? '✅' : passenger.avatar}
{isDelivered ? '✅' : passenger.avatar}
</div>
<div
style={{
@@ -109,7 +112,7 @@ export const PassengerCard = memo(function PassengerCard({
marginTop: '0',
}}
>
{passenger.isDelivered ? 'DLVRD' : passenger.isBoarded ? 'BOARD' : 'WAIT'}
{isDelivered ? 'DLVRD' : isBoarded ? 'BOARD' : 'WAIT'}
</div>
</div>
@@ -187,7 +190,7 @@ export const PassengerCard = memo(function PassengerCard({
</div>
{/* Points badge */}
{!passenger.isDelivered && (
{!isDelivered && (
<div
style={{
position: 'absolute',
@@ -208,7 +211,7 @@ export const PassengerCard = memo(function PassengerCard({
)}
{/* Urgent indicator */}
{passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded && (
{passenger.isUrgent && !isDelivered && isBoarded && (
<div
style={{
position: 'absolute',

View File

@@ -1,7 +1,8 @@
'use client'
import { memo } from 'react'
import type { ComplementQuestion, Passenger, Station } from '../../lib/gameTypes'
import type { ComplementQuestion } from '../../lib/gameTypes'
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
import { AbacusTarget } from '../AbacusTarget'
import { PassengerCard } from '../PassengerCard'
import { PressureGauge } from '../PressureGauge'

View File

@@ -1,7 +1,7 @@
'use client'
import { memo } from 'react'
import type { Passenger, Station } from '../../lib/gameTypes'
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
import type { Landmark } from '../../lib/landmarks'
interface RailroadTrackPathProps {
@@ -100,18 +100,19 @@ export const RailroadTrackPath = memo(
{stationPositions.map((pos, index) => {
const station = stations[index]
// Find passengers waiting at this station (exclude currently boarding)
// Arcade room multiplayer uses claimedBy/deliveredBy instead of isBoarded/isDelivered
const waitingPassengers = passengers.filter(
(p) =>
p.originStationId === station?.id &&
!p.isBoarded &&
!p.isDelivered &&
p.claimedBy === null &&
p.deliveredBy === null &&
!boardingAnimations.has(p.id)
)
// Find passengers delivered at this station (exclude currently disembarking)
const deliveredPassengers = passengers.filter(
(p) =>
p.destinationStationId === station?.id &&
p.isDelivered &&
p.deliveredBy !== null &&
!disembarkingAnimations.has(p.id)
)

View File

@@ -14,7 +14,6 @@ import type { ComplementQuestion } from '../../lib/gameTypes'
import { useSteamJourney } from '../../hooks/useSteamJourney'
import { useTrackManagement } from '../../hooks/useTrackManagement'
import { useTrainTransforms } from '../../hooks/useTrainTransforms'
import { calculateMaxConcurrentPassengers } from '../../lib/passengerGenerator'
import { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
import { getRouteTheme } from '../../lib/routeThemes'
import { GameHUD } from './GameHUD'
@@ -94,9 +93,6 @@ 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()
@@ -113,12 +109,9 @@ export function SteamTrainJourney({
const pathRef = useRef<SVGPathElement>(null)
const [trackGenerator] = useState(() => new RailroadTrackGenerator(800, 600))
// Calculate the number of train cars dynamically based on max concurrent passengers
const maxCars = useMemo(() => {
const maxPassengers = calculateMaxConcurrentPassengers(state.passengers, state.stations)
// Ensure at least 1 car, even if no passengers
return Math.max(1, maxPassengers)
}, [state.passengers, state.stations])
// Use server's authoritative maxConcurrentPassengers calculation
// This ensures visual display matches game logic and console logs
const maxCars = Math.max(1, state.maxConcurrentPassengers || 3)
const carSpacing = 7 // Distance between cars (in % of track)
@@ -170,13 +163,14 @@ export function SteamTrainJourney({
const routeTheme = getRouteTheme(state.currentRoute)
// Memoize filtered passenger lists to avoid recalculating on every render
// Arcade room multiplayer uses claimedBy/deliveredBy instead of isBoarded/isDelivered
const boardedPassengers = useMemo(
() => displayPassengers.filter((p) => p.isBoarded && !p.isDelivered),
() => displayPassengers.filter((p) => p.claimedBy !== null && p.deliveredBy === null),
[displayPassengers]
)
const nonDeliveredPassengers = useMemo(
() => displayPassengers.filter((p) => !p.isDelivered),
() => displayPassengers.filter((p) => p.deliveredBy === null),
[displayPassengers]
)

View File

@@ -2,7 +2,7 @@
import { memo } from 'react'
import type { BoardingAnimation, DisembarkingAnimation } from '../../hooks/usePassengerAnimations'
import type { Passenger } from '../../lib/gameTypes'
import type { Passenger } from '@/arcade-games/complement-race/types'
interface TrainCarTransform {
x: number

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, test, vi } from 'vitest'
import type { Passenger, Station } from '../../../lib/gameTypes'
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
import { GameHUD } from '../GameHUD'
// Mock child components
@@ -33,9 +33,11 @@ describe('GameHUD', () => {
avatar: '👨',
originStationId: 'station-1',
destinationStationId: 'station-2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
claimedBy: null,
deliveredBy: null,
carIndex: null,
timestamp: Date.now(),
}
const defaultProps = {

View File

@@ -1,6 +1,6 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import type { Passenger, Station } from '../../lib/gameTypes'
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
import { useTrackManagement } from '../useTrackManagement'
@@ -47,7 +47,7 @@ describe('useTrackManagement - Passenger Display', () => {
{ id: 'station3', name: 'Station 3', icon: '🏪', emoji: '🏪', position: 80 },
]
// Mock passengers - initial set
// Mock passengers - initial set (multiplayer format)
mockPassengers = [
{
id: 'p1',
@@ -55,9 +55,11 @@ describe('useTrackManagement - Passenger Display', () => {
avatar: '👩',
originStationId: 'station1',
destinationStationId: 'station2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
claimedBy: null,
deliveredBy: null,
carIndex: null,
timestamp: Date.now(),
},
{
id: 'p2',
@@ -65,9 +67,11 @@ describe('useTrackManagement - Passenger Display', () => {
avatar: '👨',
originStationId: 'station2',
destinationStationId: 'station3',
isBoarded: false,
isDelivered: false,
isUrgent: false,
claimedBy: null,
deliveredBy: null,
carIndex: null,
timestamp: Date.now(),
},
]
@@ -111,18 +115,18 @@ describe('useTrackManagement - Passenger Display', () => {
// Initially 2 passengers
expect(result.current.displayPassengers).toHaveLength(2)
expect(result.current.displayPassengers[0].isBoarded).toBe(false)
expect(result.current.displayPassengers[0].claimedBy).toBe(null)
// Board first passenger
const boardedPassengers = mockPassengers.map((p) =>
p.id === 'p1' ? { ...p, isBoarded: true } : p
p.id === 'p1' ? { ...p, claimedBy: 'player1', carIndex: 0 } : p
)
rerender({ passengers: boardedPassengers, position: 25 })
// Should show updated passengers
expect(result.current.displayPassengers).toHaveLength(2)
expect(result.current.displayPassengers[0].isBoarded).toBe(true)
expect(result.current.displayPassengers[0].claimedBy).toBe('player1')
})
test('passengers do NOT update during route transition (train moving)', () => {
@@ -153,9 +157,11 @@ describe('useTrackManagement - Passenger Display', () => {
avatar: '👴',
originStationId: 'station1',
destinationStationId: 'station3',
isBoarded: false,
isDelivered: false,
isUrgent: false,
claimedBy: null,
deliveredBy: null,
carIndex: null,
timestamp: Date.now(),
},
]
@@ -196,9 +202,11 @@ describe('useTrackManagement - Passenger Display', () => {
avatar: '👴',
originStationId: 'station1',
destinationStationId: 'station3',
isBoarded: false,
isDelivered: false,
isUrgent: false,
claimedBy: null,
deliveredBy: null,
carIndex: null,
timestamp: Date.now(),
},
]
@@ -239,9 +247,11 @@ describe('useTrackManagement - Passenger Display', () => {
avatar: '👴',
originStationId: 'station1',
destinationStationId: 'station3',
isBoarded: false,
isDelivered: false,
isUrgent: false,
claimedBy: null,
deliveredBy: null,
carIndex: null,
timestamp: Date.now(),
},
]
@@ -316,18 +326,18 @@ describe('useTrackManagement - Passenger Display', () => {
// Initially 2 passengers, neither delivered
expect(result.current.displayPassengers).toHaveLength(2)
expect(result.current.displayPassengers[0].isDelivered).toBe(false)
expect(result.current.displayPassengers[0].deliveredBy).toBe(null)
// Deliver first passenger
const deliveredPassengers = mockPassengers.map((p) =>
p.id === 'p1' ? { ...p, isBoarded: true, isDelivered: true } : p
p.id === 'p1' ? { ...p, claimedBy: 'player1', carIndex: 0, deliveredBy: 'player1' } : p
)
rerender({ passengers: deliveredPassengers, position: 55 })
// Should show updated passengers immediately
expect(result.current.displayPassengers).toHaveLength(2)
expect(result.current.displayPassengers[0].isDelivered).toBe(true)
expect(result.current.displayPassengers[0].deliveredBy).toBe('player1')
})
test('multiple rapid passenger updates during same route', () => {
@@ -350,25 +360,27 @@ describe('useTrackManagement - Passenger Display', () => {
expect(result.current.displayPassengers).toHaveLength(2)
// Board p1
let updated = mockPassengers.map((p) => (p.id === 'p1' ? { ...p, isBoarded: true } : p))
let updated = mockPassengers.map((p) =>
p.id === 'p1' ? { ...p, claimedBy: 'player1', carIndex: 0 } : p
)
rerender({ passengers: updated, position: 26 })
expect(result.current.displayPassengers[0].isBoarded).toBe(true)
expect(result.current.displayPassengers[0].claimedBy).toBe('player1')
// Board p2
updated = updated.map((p) => (p.id === 'p2' ? { ...p, isBoarded: true } : p))
updated = updated.map((p) => (p.id === 'p2' ? { ...p, claimedBy: 'player1', carIndex: 1 } : p))
rerender({ passengers: updated, position: 52 })
expect(result.current.displayPassengers[1].isBoarded).toBe(true)
expect(result.current.displayPassengers[1].claimedBy).toBe('player1')
// Deliver p1
updated = updated.map((p) => (p.id === 'p1' ? { ...p, isDelivered: true } : p))
updated = updated.map((p) => (p.id === 'p1' ? { ...p, deliveredBy: 'player1' } : p))
rerender({ passengers: updated, position: 53 })
expect(result.current.displayPassengers[0].isDelivered).toBe(true)
expect(result.current.displayPassengers[0].deliveredBy).toBe('player1')
// All updates should have been reflected
expect(result.current.displayPassengers[0].isBoarded).toBe(true)
expect(result.current.displayPassengers[0].isDelivered).toBe(true)
expect(result.current.displayPassengers[1].isBoarded).toBe(true)
expect(result.current.displayPassengers[1].isDelivered).toBe(false)
expect(result.current.displayPassengers[0].claimedBy).toBe('player1')
expect(result.current.displayPassengers[0].deliveredBy).toBe('player1')
expect(result.current.displayPassengers[1].claimedBy).toBe('player1')
expect(result.current.displayPassengers[1].deliveredBy).toBe(null)
})
test('EDGE CASE: new passengers at position 0 with old route', () => {
@@ -402,9 +414,11 @@ describe('useTrackManagement - Passenger Display', () => {
avatar: '👴',
originStationId: 'station1',
destinationStationId: 'station3',
isBoarded: false,
isDelivered: false,
isUrgent: false,
claimedBy: null,
deliveredBy: null,
carIndex: null,
timestamp: Date.now(),
},
]
@@ -445,9 +459,11 @@ describe('useTrackManagement - Passenger Display', () => {
avatar: '👴',
originStationId: 'station1',
destinationStationId: 'station3',
isBoarded: false,
isDelivered: false,
isUrgent: false,
claimedBy: null,
deliveredBy: null,
carIndex: null,
timestamp: Date.now(),
},
]
@@ -483,9 +499,11 @@ describe('useTrackManagement - Passenger Display', () => {
avatar: '👴',
originStationId: 'station1',
destinationStationId: 'station3',
isBoarded: false,
isDelivered: false,
isUrgent: false,
claimedBy: null,
deliveredBy: null,
carIndex: null,
timestamp: Date.now(),
},
]

View File

@@ -1,6 +1,6 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import type { Passenger, Station } from '../../lib/gameTypes'
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
import { useTrackManagement } from '../useTrackManagement'
@@ -60,9 +60,11 @@ describe('useTrackManagement', () => {
avatar: '👨',
originStationId: 'station-1',
destinationStationId: 'station-2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
claimedBy: null,
deliveredBy: null,
carIndex: null,
timestamp: Date.now(),
},
]
@@ -155,6 +157,8 @@ describe('useTrackManagement', () => {
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
maxCars: 3,
carSpacing: 7,
})
)
@@ -174,6 +178,8 @@ describe('useTrackManagement', () => {
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
maxCars: 3,
carSpacing: 7,
}),
{
initialProps: { route: 1, position: 0 },
@@ -200,6 +206,8 @@ describe('useTrackManagement', () => {
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
maxCars: 3,
carSpacing: 7,
}),
{
initialProps: { route: 1, position: 0 },
@@ -227,6 +235,8 @@ describe('useTrackManagement', () => {
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
maxCars: 3,
carSpacing: 7,
}),
{
initialProps: { route: 1, position: -5 },
@@ -250,9 +260,11 @@ describe('useTrackManagement', () => {
avatar: '👩',
originStationId: 'station-1',
destinationStationId: 'station-2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
claimedBy: null,
deliveredBy: null,
carIndex: null,
timestamp: Date.now(),
},
]
@@ -287,12 +299,15 @@ describe('useTrackManagement', () => {
const newPassengers: Passenger[] = [
{
id: 'passenger-2',
name: 'Passenger 2',
avatar: '👩',
originStationId: 'station-1',
destinationStationId: 'station-2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
claimedBy: null,
deliveredBy: null,
carIndex: null,
timestamp: Date.now(),
},
]
@@ -328,7 +343,9 @@ describe('useTrackManagement', () => {
})
test('updates passengers immediately during same route', () => {
const updatedPassengers: Passenger[] = [{ ...mockPassengers[0], isBoarded: true }]
const updatedPassengers: Passenger[] = [
{ ...mockPassengers[0], claimedBy: 'player1', carIndex: 0 },
]
const { result, rerender } = renderHook(
({ passengers, position }) =>
@@ -368,6 +385,8 @@ describe('useTrackManagement', () => {
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
maxCars: 3,
carSpacing: 7,
})
)

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef } from 'react'
import { useComplementRace } from '@/arcade-games/complement-race/Provider'
import { calculateMaxConcurrentPassengers, generatePassengers } from '../lib/passengerGenerator'
import { useSoundEffects } from './useSoundEffects'
/**
@@ -44,26 +43,44 @@ export function useSteamJourney() {
const gameStartTimeRef = useRef<number>(0)
const lastUpdateRef = useRef<number>(0)
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 and generate initial passengers
// Initialize game start time
useEffect(() => {
if (state.isGameActive && state.style === 'sprint' && gameStartTimeRef.current === 0) {
gameStartTimeRef.current = Date.now()
lastUpdateRef.current = Date.now()
// Generate initial passengers if none exist
if (state.passengers.length === 0) {
const newPassengers = generatePassengers(state.stations)
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
// Calculate and store exit threshold for this route
const CAR_SPACING = 7
const maxPassengers = calculateMaxConcurrentPassengers(newPassengers, state.stations)
const maxCars = Math.max(1, maxPassengers)
routeExitThresholdRef.current = 100 + maxCars * CAR_SPACING
}
}
}, [state.isGameActive, state.style, state.stations, state.passengers.length, dispatch])
}, [state.isGameActive, state.style, state.stations, state.passengers])
// Calculate exit threshold when route changes or config updates
useEffect(() => {
if (state.passengers.length > 0 && state.stations.length > 0) {
const CAR_SPACING = 7
// Use server-calculated maxConcurrentPassengers
const maxCars = Math.max(1, state.maxConcurrentPassengers || 3)
routeExitThresholdRef.current = 100 + maxCars * CAR_SPACING
}
}, [state.currentRoute, state.passengers, state.stations, state.maxConcurrentPassengers])
// Clean up pendingBoardingRef when passengers are claimed/delivered or route changes
useEffect(() => {
// Remove passengers from pending set if they've been claimed or delivered
state.passengers.forEach((passenger) => {
if (passenger.claimedBy !== null || passenger.deliveredBy !== null) {
pendingBoardingRef.current.delete(passenger.id)
}
})
}, [state.passengers])
// Clear all pending boarding requests when route changes
useEffect(() => {
pendingBoardingRef.current.clear()
missedPassengersRef.current.clear()
previousTrainPositionRef.current = 0 // Reset previous position for new route
}, [state.currentRoute])
// Momentum decay and position update loop
useEffect(() => {
@@ -77,116 +94,48 @@ 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
const CAR_SPACING = 7 // Must match SteamTrainJourney component
const maxPassengers = calculateMaxConcurrentPassengers(state.passengers, state.stations)
const maxCars = Math.max(1, maxPassengers)
const currentBoardedPassengers = state.passengers.filter((p) => p.isBoarded && !p.isDelivered)
// Use server-calculated maxConcurrentPassengers (updates per route based on passenger layout)
const maxCars = Math.max(1, state.maxConcurrentPassengers || 3)
// Debug logging flag - enable when debugging passenger boarding issues
// TO ENABLE: Change this to true, save, and the logs will appear in the browser console
// When you see passengers getting left behind, copy the entire console log and paste into Claude Code
const DEBUG_PASSENGER_BOARDING = false
if (DEBUG_PASSENGER_BOARDING) {
console.log('\n'.repeat(3))
console.log('='.repeat(80))
console.log('🚂 PASSENGER BOARDING DEBUG LOG')
console.log('='.repeat(80))
console.log('ISSUE: Passengers are getting left behind at stations')
console.log('PURPOSE: This log captures all state during boarding/delivery logic')
console.log('USAGE: Copy this entire log and paste into Claude Code for debugging')
console.log('='.repeat(80))
console.log('\n📊 CURRENT FRAME STATE:')
console.log(` Train Position: ${trainPosition.toFixed(2)}`)
console.log(` Speed: ${speed.toFixed(2)}% per second`)
console.log(` Momentum: ${newMomentum.toFixed(2)}`)
console.log(` Max Cars: ${maxCars}`)
console.log(` Car Spacing: ${CAR_SPACING}`)
console.log(` Distance Tolerance: 5`)
console.log('\n🚉 STATIONS:')
state.stations.forEach((station) => {
console.log(` ${station.emoji} ${station.name} (ID: ${station.id})`)
console.log(` Position: ${station.position}`)
})
console.log('\n👥 ALL PASSENGERS:')
state.passengers.forEach((p, idx) => {
const origin = state.stations.find((s) => s.id === p.originStationId)
const dest = state.stations.find((s) => s.id === p.destinationStationId)
console.log(` [${idx}] ${p.name} (ID: ${p.id})`)
// Debug: Log train configuration at start (only once per route)
if (trainPosition < 1 && state.passengers.length > 0) {
const lastLoggedRoute = (window as any).__lastLoggedRoute || 0
if (lastLoggedRoute !== state.currentRoute) {
console.log(
` Status: ${p.isDelivered ? 'DELIVERED' : p.isBoarded ? 'BOARDED' : 'WAITING'}`
`\n🚆 ROUTE ${state.currentRoute} START - Train has ${maxCars} cars (server maxConcurrentPassengers: ${state.maxConcurrentPassengers}) for ${state.passengers.length} passengers`
)
console.log(
` Route: ${origin?.emoji} ${origin?.name} (pos ${origin?.position}) → ${dest?.emoji} ${dest?.name} (pos ${dest?.position})`
)
console.log(` Urgent: ${p.isUrgent}`)
})
console.log('\n🚃 CAR POSITIONS:')
for (let i = 0; i < maxCars; i++) {
const carPos = Math.max(0, trainPosition - (i + 1) * CAR_SPACING)
console.log(` Car ${i}: position ${carPos.toFixed(2)}`)
state.passengers.forEach((p) => {
const origin = state.stations.find((s) => s.id === p.originStationId)
const dest = state.stations.find((s) => s.id === p.destinationStationId)
console.log(
` 📍 ${p.name}: ${origin?.emoji} ${origin?.name} (${origin?.position}) → ${dest?.emoji} ${dest?.name} (${dest?.position}) ${p.isUrgent ? '⚡' : ''}`
)
})
console.log('') // Blank line for readability
;(window as any).__lastLoggedRoute = state.currentRoute
}
console.log('\n🔍 CURRENTLY BOARDED PASSENGERS:')
currentBoardedPassengers.forEach((p, carIndex) => {
const carPos = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
const dest = state.stations.find((s) => s.id === p.destinationStationId)
const distToDest = Math.abs(carPos - (dest?.position || 0))
console.log(` Car ${carIndex}: ${p.name}`)
console.log(` Car position: ${carPos.toFixed(2)}`)
console.log(` Destination: ${dest?.emoji} ${dest?.name} (pos ${dest?.position})`)
console.log(` Distance to dest: ${distToDest.toFixed(2)}`)
console.log(` Will deliver: ${distToDest < 5 ? 'YES' : 'NO'}`)
})
}
const currentBoardedPassengers = state.passengers.filter(
(p) => p.claimedBy !== null && p.deliveredBy === null
)
// FIRST: Identify which passengers will be delivered in this frame
const passengersToDeliver = new Set<string>()
currentBoardedPassengers.forEach((passenger, carIndex) => {
if (!passenger || passenger.isDelivered) return
currentBoardedPassengers.forEach((passenger) => {
if (!passenger || passenger.deliveredBy !== null || passenger.carIndex === null) return
const station = state.stations.find((s) => s.id === passenger.destinationStationId)
if (!station) return
// Calculate this passenger's car position
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
// Calculate this passenger's car position using PHYSICAL carIndex
const carPosition = Math.max(0, trainPosition - (passenger.carIndex + 1) * CAR_SPACING)
const distance = Math.abs(carPosition - station.position)
// If this car is at the destination station (within 5% tolerance), mark for delivery
@@ -195,159 +144,161 @@ export function useSteamJourney() {
}
})
// Build a map of which cars are occupied (excluding passengers being delivered this frame)
// Build a map of which cars are occupied (using PHYSICAL car index, not array index!)
// This is critical: passenger.carIndex stores the physical car (0-N) they're seated in
const occupiedCars = new Map<number, (typeof currentBoardedPassengers)[0]>()
currentBoardedPassengers.forEach((passenger, arrayIndex) => {
currentBoardedPassengers.forEach((passenger) => {
// Don't count a car as occupied if its passenger is being delivered this frame
if (!passengersToDeliver.has(passenger.id)) {
occupiedCars.set(arrayIndex, passenger)
if (!passengersToDeliver.has(passenger.id) && passenger.carIndex !== null) {
occupiedCars.set(passenger.carIndex, passenger) // Use physical carIndex, NOT array index!
}
})
if (DEBUG_PASSENGER_BOARDING) {
console.log('\n📦 PASSENGERS TO DELIVER THIS FRAME:')
if (passengersToDeliver.size === 0) {
console.log(' None')
} else {
passengersToDeliver.forEach((id) => {
const p = state.passengers.find((passenger) => passenger.id === id)
console.log(` - ${p?.name} (ID: ${id})`)
})
}
console.log('\n🚗 OCCUPIED CARS (after excluding deliveries):')
if (occupiedCars.size === 0) {
console.log(' All cars are empty')
} else {
occupiedCars.forEach((passenger, carIndex) => {
console.log(` Car ${carIndex}: ${passenger.name}`)
})
}
console.log('\n🔄 BOARDING ATTEMPTS:')
}
// Track which cars are assigned in THIS frame to prevent double-boarding
const carsAssignedThisFrame = new Set<number>()
// Find waiting passengers whose origin station has an empty car nearby
state.passengers.forEach((passenger) => {
if (passenger.isBoarded || passenger.isDelivered) return
const station = state.stations.find((s) => s.id === passenger.originStationId)
if (!station) return
if (DEBUG_PASSENGER_BOARDING) {
console.log(
`\n Passenger: ${passenger.name} waiting at ${station.emoji} ${station.name} (pos ${station.position})`
)
}
// Check if any empty car is at this station
// Cars are at positions: trainPosition - 7, trainPosition - 14, etc.
let boarded = false
for (let carIndex = 0; carIndex < maxCars; carIndex++) {
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
const distance = Math.abs(carPosition - station.position)
if (DEBUG_PASSENGER_BOARDING) {
const isOccupied = occupiedCars.has(carIndex)
const isAssigned = carsAssignedThisFrame.has(carIndex)
const inRange = distance < 5
const occupant = occupiedCars.get(carIndex)
console.log(` Car ${carIndex} @ pos ${carPosition.toFixed(2)}:`)
console.log(` Distance to station: ${distance.toFixed(2)}`)
console.log(` In range (<5): ${inRange}`)
console.log(
` Occupied: ${isOccupied}${isOccupied ? ` (by ${occupant?.name})` : ''}`
)
console.log(` Assigned this frame: ${isAssigned}`)
console.log(` Can board: ${!isOccupied && !isAssigned && inRange}`)
}
// Skip if this car already has a passenger OR was assigned this frame
if (occupiedCars.has(carIndex) || carsAssignedThisFrame.has(carIndex)) continue
const distance2 = Math.abs(carPosition - station.position)
// If car is at or near station (within 5% tolerance for fast trains), board this passenger
// Increased tolerance to ensure fast-moving trains don't miss passengers
if (distance2 < 5) {
if (DEBUG_PASSENGER_BOARDING) {
console.log(` ✅ BOARDING ${passenger.name} onto Car ${carIndex}`)
}
dispatch({
type: 'BOARD_PASSENGER',
passengerId: passenger.id,
})
// Mark this car as assigned in this frame
carsAssignedThisFrame.add(carIndex)
boarded = true
return // Board this passenger and move on
}
}
if (DEBUG_PASSENGER_BOARDING && !boarded) {
console.log(`${passenger.name} NOT BOARDED - no suitable car found`)
}
})
if (DEBUG_PASSENGER_BOARDING) {
console.log('\n🎯 DELIVERY ATTEMPTS:')
}
// Check for deliverable passengers
// Passengers disembark when THEIR car reaches their destination
currentBoardedPassengers.forEach((passenger, carIndex) => {
if (!passenger || passenger.isDelivered) return
// PRIORITY 1: Process deliveries FIRST (dispatch DELIVER moves before BOARD moves)
// This ensures the server frees up cars before processing new boarding requests
currentBoardedPassengers.forEach((passenger) => {
if (!passenger || passenger.deliveredBy !== null || passenger.carIndex === null) return
const station = state.stations.find((s) => s.id === passenger.destinationStationId)
if (!station) return
// Calculate this passenger's car position
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
// Calculate this passenger's car position using PHYSICAL carIndex
const carPosition = Math.max(0, trainPosition - (passenger.carIndex + 1) * CAR_SPACING)
const distance = Math.abs(carPosition - station.position)
// If this car is at the destination station (within 5% tolerance), deliver
if (distance < 5) {
if (DEBUG_PASSENGER_BOARDING) {
console.log(
` ✅ DELIVERING ${passenger.name} from Car ${carIndex} to ${station.emoji} ${station.name}`
)
console.log(
` Car position: ${carPosition.toFixed(2)}, Station: ${station.position}, Distance: ${distance.toFixed(2)}`
)
}
const points = passenger.isUrgent ? 20 : 10
console.log(
`🎯 DELIVERY: ${passenger.name} delivered from Car ${passenger.carIndex} to ${station.emoji} ${station.name} (+${points} pts) (trainPos=${trainPosition.toFixed(1)}, carPos=${carPosition.toFixed(1)}, stationPos=${station.position})`
)
dispatch({
type: 'DELIVER_PASSENGER',
passengerId: passenger.id,
points,
})
} else if (DEBUG_PASSENGER_BOARDING) {
}
})
// Debug: Log car states periodically at stations
const isAtStation = state.stations.some((s) => Math.abs(trainPosition - s.position) < 3)
if (isAtStation && Math.floor(trainPosition) !== Math.floor(state.trainPosition)) {
const nearStation = state.stations.find((s) => Math.abs(trainPosition - s.position) < 3)
console.log(
`\n🚃 Train arriving at ${nearStation?.emoji} ${nearStation?.name} (trainPos=${trainPosition.toFixed(1)}) - ${maxCars} cars total:`
)
for (let i = 0; i < maxCars; i++) {
const carPos = Math.max(0, trainPosition - (i + 1) * CAR_SPACING)
const occupant = occupiedCars.get(i)
if (occupant) {
const dest = state.stations.find((s) => s.id === occupant.destinationStationId)
console.log(
` Car ${i}: @ ${carPos.toFixed(1)}% - ${occupant.name}${dest?.emoji} ${dest?.name}`
)
} else {
console.log(` Car ${i}: @ ${carPos.toFixed(1)}% - EMPTY`)
}
}
}
// Track which cars are assigned in THIS frame to prevent double-boarding
const carsAssignedThisFrame = new Set<number>()
// Track which passengers are assigned in THIS frame to prevent same passenger boarding multiple cars
const passengersAssignedThisFrame = new Set<string>()
// PRIORITY 2: Process boardings AFTER deliveries
// Find waiting passengers whose origin station has an empty car nearby
state.passengers.forEach((passenger) => {
// Skip if already claimed or delivered (optimistic update marks immediately)
if (passenger.claimedBy !== null || passenger.deliveredBy !== null) return
// Skip if already assigned in this frame OR has a pending boarding request from previous frames
if (
passengersAssignedThisFrame.has(passenger.id) ||
pendingBoardingRef.current.has(passenger.id)
)
return
const station = state.stations.find((s) => s.id === passenger.originStationId)
if (!station) return
// Don't allow boarding if locomotive has passed too far beyond this station
// Station stays open until the LAST car has passed (accounting for train length)
const STATION_CLOSURE_BUFFER = 10 // Extra buffer beyond the last car
const lastCarOffset = maxCars * CAR_SPACING // Distance from locomotive to last car
const stationClosureThreshold = lastCarOffset + STATION_CLOSURE_BUFFER
if (trainPosition > station.position + stationClosureThreshold) {
console.log(
` ${passenger.name} in Car ${carIndex} heading to ${station.emoji} ${station.name}`
`❌ MISSED: ${passenger.name} at ${station.emoji} ${station.name} - train too far past (trainPos=${trainPosition.toFixed(1)}, station=${station.position}, threshold=${stationClosureThreshold})`
)
return
}
// Check if any empty car is at this station
// Cars are at positions: trainPosition - 7, trainPosition - 14, etc.
let closestCarDistance = 999
let closestCarReason = ''
for (let carIndex = 0; carIndex < maxCars; carIndex++) {
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
const distance = Math.abs(carPosition - station.position)
if (distance < closestCarDistance) {
closestCarDistance = distance
if (occupiedCars.has(carIndex)) {
const occupant = occupiedCars.get(carIndex)
closestCarReason = `Car ${carIndex} occupied by ${occupant?.name}`
} else if (carsAssignedThisFrame.has(carIndex)) {
closestCarReason = `Car ${carIndex} just assigned`
} else if (distance >= 5) {
closestCarReason = `Car ${carIndex} too far (dist=${distance.toFixed(1)})`
} else {
closestCarReason = 'available'
}
}
// Skip if this car already has a passenger OR was assigned this frame
if (occupiedCars.has(carIndex) || carsAssignedThisFrame.has(carIndex)) continue
// If car is at or near station (within 5% tolerance for fast trains), board this passenger
if (distance < 5) {
console.log(
`🚂 BOARDING: ${passenger.name} boarding Car ${carIndex} at ${station.emoji} ${station.name} (trainPos=${trainPosition.toFixed(1)}, carPos=${carPosition.toFixed(1)}, stationPos=${station.position})`
)
// Mark as pending BEFORE dispatch to prevent duplicate boarding attempts across frames
pendingBoardingRef.current.add(passenger.id)
dispatch({
type: 'BOARD_PASSENGER',
passengerId: passenger.id,
carIndex, // Pass physical car index to server
})
// Mark this car and passenger as assigned in this frame
carsAssignedThisFrame.add(carIndex)
passengersAssignedThisFrame.add(passenger.id)
return // Board this passenger and move on
}
}
// If we get here, passenger wasn't boarded - log why
if (closestCarDistance < 10) {
// Only log if train is somewhat near
console.log(
` Car position: ${carPosition.toFixed(2)}, Station: ${station.position}, Distance: ${distance.toFixed(2)}`
`⏸️ WAITING: ${passenger.name} at ${station.emoji} ${station.name} - ${closestCarReason} (trainPos=${trainPosition.toFixed(1)}, maxCars=${maxCars})`
)
}
})
if (DEBUG_PASSENGER_BOARDING) {
console.log(`\n${'='.repeat(80)}`)
console.log('END OF DEBUG LOG')
console.log('='.repeat(80))
}
// Check for route completion (entire train exits tunnel)
// Use stored threshold (stable for entire route)
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)
@@ -357,52 +308,24 @@ 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,
stations: state.stations,
})
// Generate new passengers
const newPassengers = generatePassengers(state.stations)
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
// Calculate and store new exit threshold for next route
const newMaxPassengers = calculateMaxConcurrentPassengers(newPassengers, state.stations)
const newMaxCars = Math.max(1, newMaxPassengers)
routeExitThresholdRef.current = 100 + newMaxCars * CAR_SPACING
// 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)
}, [
state.isGameActive,
state.style,
state.momentum,
state.trainPosition,
state.timeoutSetting,
state.passengers,
state.stations,
state.currentRoute,
dispatch,
playSound,
])
// Auto-regenerate passengers when all are delivered
useEffect(() => {
if (!state.isGameActive || state.style !== 'sprint') return
// Check if all passengers are delivered
const allDelivered = state.passengers.length > 0 && state.passengers.every((p) => p.isDelivered)
if (allDelivered) {
// Generate new passengers after a short delay
setTimeout(() => {
const newPassengers = generatePassengers(state.stations)
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
}, 1000)
}
}, [state.isGameActive, state.style, state.passengers, state.stations, dispatch])
}, [state.isGameActive, state.style, state.timeoutSetting, dispatch, playSound])
// Add momentum on correct answer
useEffect(() => {

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import type { Passenger, Station } from '../lib/gameTypes'
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
import { generateLandmarks, type Landmark } from '../lib/landmarks'
import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator'
@@ -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,22 +77,34 @@ 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
// 2. Same route AND train is in middle of track (10-90%) - gameplay updates like boarding/delivering
const trainReset = trainPosition < 0
// 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 sameRoute = currentRoute === displayRouteRef.current
const inMiddleOfTrack = trainPosition >= 10 && trainPosition < 90 // Avoid start/end transition zones
// Detect if passenger states have changed (boarding or delivery)
// This allows updates even when train is past 90% threshold
const passengerStatesChanged =
sameRoute &&
passengers.some((p) => {
const oldPassenger = displayPassengers.find((dp) => dp.id === p.id)
return (
oldPassenger &&
(oldPassenger.claimedBy !== p.claimedBy || oldPassenger.deliveredBy !== p.deliveredBy)
)
})
if (trainReset) {
// Train reset - update to new route's passengers
setDisplayPassengers(passengers)
displayRouteRef.current = currentRoute
} else if (sameRoute && inMiddleOfTrack) {
// Same route and train in middle of track - update passengers for gameplay changes (boarding/delivery)
} else if (sameRoute && (inMiddleOfTrack || passengerStatesChanged)) {
// Same route and either in middle of track OR passenger states changed - update for gameplay
setDisplayPassengers(passengers)
}
// Otherwise, keep displaying old passengers until train resets
}, [passengers, trainPosition, currentRoute])
}, [passengers, displayPassengers, trainPosition, currentRoute])
// Generate ties and rails when path is ready
useEffect(() => {

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

@@ -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,
@@ -29,6 +38,7 @@ interface CompatibleGameState {
style: string
timeoutSetting: string
complementDisplay: string
maxConcurrentPassengers: number
// Current question (extracted from currentQuestions[localPlayerId])
currentQuestion: any | null
@@ -93,7 +103,7 @@ interface ComplementRaceContextValue {
lastError: string | null
startGame: () => void
submitAnswer: (answer: number, responseTime: number) => void
claimPassenger: (passengerId: string) => void
claimPassenger: (passengerId: string, carIndex: number) => void
deliverPassenger: (passengerId: string) => void
nextQuestion: () => void
endGame: () => void
@@ -102,6 +112,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)
@@ -119,12 +130,71 @@ export function useComplementRace() {
/**
* Optimistic move application (client-side prediction)
* For now, just return current state - server will validate and send back authoritative state
* Apply moves immediately on client for responsive UI, server will confirm or reject
*/
function applyMoveOptimistically(state: ComplementRaceState, move: GameMove): ComplementRaceState {
// Simple optimistic updates can be added here later
// For now, rely on server validation
return state
const typedMove = move as ComplementRaceMove
switch (typedMove.type) {
case 'CLAIM_PASSENGER': {
// Optimistically mark passenger as claimed and assign to car
const passengerId = typedMove.data.passengerId
const carIndex = typedMove.data.carIndex
const updatedPassengers = state.passengers.map((p) =>
p.id === passengerId ? { ...p, claimedBy: typedMove.playerId, carIndex } : p
)
// Optimistically add to player's passenger list
const updatedPlayers = { ...state.players }
const player = updatedPlayers[typedMove.playerId]
if (player) {
updatedPlayers[typedMove.playerId] = {
...player,
passengers: [...player.passengers, passengerId],
}
}
return {
...state,
passengers: updatedPassengers,
players: updatedPlayers,
}
}
case 'DELIVER_PASSENGER': {
// Optimistically mark passenger as delivered and award points
const passengerId = typedMove.data.passengerId
const passenger = state.passengers.find((p) => p.id === passengerId)
if (!passenger) return state
const points = passenger.isUrgent ? 20 : 10
const updatedPassengers = state.passengers.map((p) =>
p.id === passengerId ? { ...p, deliveredBy: typedMove.playerId } : p
)
// Optimistically remove from player's passenger list and update score
const updatedPlayers = { ...state.players }
const player = updatedPlayers[typedMove.playerId]
if (player) {
updatedPlayers[typedMove.playerId] = {
...player,
passengers: player.passengers.filter((id) => id !== passengerId),
deliveredPassengers: player.deliveredPassengers + 1,
score: player.score + points,
}
}
return {
...state,
passengers: updatedPassengers,
players: updatedPlayers,
}
}
default:
// For other moves, rely on server validation
return state
}
}
/**
@@ -240,6 +310,45 @@ 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 [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)
// 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
@@ -264,6 +373,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
style: multiplayerState.config.style,
timeoutSetting: multiplayerState.config.timeoutSetting,
complementDisplay: multiplayerState.config.complementDisplay,
maxConcurrentPassengers: multiplayerState.config.maxConcurrentPassengers,
// Current question
currentQuestion: localPlayerId
@@ -291,22 +401,12 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
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,
})),
aiRacers: clientAIRacers, // Use client-side AI state
// 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,11 +428,129 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
adaptiveFeedback: localUIState.adaptiveFeedback,
difficultyTracker: localUIState.difficultyTracker,
}
}, [multiplayerState, localPlayerId, localUIState])
}, [
multiplayerState,
localPlayerId,
localUIState,
clientPosition,
clientPressure,
clientMomentum,
clientAIRacers,
])
console.log(
`🚂 Sprint: momentum=${compatibleState.momentum} pos=${compatibleState.trainPosition} pressure=${compatibleState.pressure}`
)
// 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])
// 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 = ['Robo-Racer', 'Calculator', 'Speed Demon', 'Brain Bot']
const personalities: Array<'competitive' | 'analytical'> = ['competitive', 'analytical']
const newAI = []
for (let i = 0; i < Math.min(count, aiNames.length); i++) {
newAI.push({
id: `ai-${i}`,
name: aiNames[i],
personality: personalities[i % personalities.length] as 'competitive' | 'analytical',
position: 0,
speed: 0.8 + Math.random() * 0.4, // Speed multiplier 0.8-1.2
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
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') {
console.log(
`[Provider] Route changed to ${currentRoute}, resetting position. Passengers: ${multiplayerState.passengers.length}`
)
setClientPosition(0)
setClientMomentum(10) // Reset to starting momentum (gentle push)
}
}, [multiplayerState.currentRoute, compatibleState.style, multiplayerState.passengers.length])
// Keep lastLogRef for future debugging needs
// (removed debug logging)
// Action creators
const startGame = useCallback(() => {
@@ -378,7 +596,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
)
const claimPassenger = useCallback(
(passengerId: string) => {
(passengerId: string, carIndex: number) => {
const currentPlayerId = activePlayers.find((id) => {
const player = players.get(id)
return player?.isLocal
@@ -390,7 +608,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
type: 'CLAIM_PASSENGER',
playerId: currentPlayerId,
userId: viewerId || '',
data: { passengerId },
data: { passengerId, carIndex },
} as ComplementRaceMove)
},
[activePlayers, players, viewerId, sendMove]
@@ -532,8 +750,8 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
break
case 'BOARD_PASSENGER':
case 'CLAIM_PASSENGER':
if (action.passengerId !== undefined) {
claimPassenger(action.passengerId)
if (action.passengerId !== undefined && action.carIndex !== undefined) {
claimPassenger(action.passengerId, action.carIndex)
}
break
case 'DELIVER_PASSENGER':
@@ -541,6 +759,18 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
deliverPassenger(action.passengerId)
}
break
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] || '',
userId: viewerId || '',
data: { routeNumber: action.routeNumber },
} as ComplementRaceMove)
}
break
// Local UI state actions
case 'UPDATE_INPUT':
setLocalUIState((prev) => ({ ...prev, currentInput: action.input || '' }))
@@ -573,15 +803,33 @@ 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':
case 'UPDATE_DIFFICULTY_TRACKER':
case 'UPDATE_AI_SPEEDS':
case 'GENERATE_PASSENGERS':
case 'START_NEW_ROUTE':
case 'GENERATE_PASSENGERS': // Passengers generated server-side when route starts
case 'COMPLETE_ROUTE':
case 'HIDE_ROUTE_CELEBRATION':
case 'COMPLETE_LAP':
@@ -601,9 +849,28 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
claimPassenger,
deliverPassenger,
multiplayerState.questionStartTime,
sendMove,
activePlayers,
viewerId,
]
)
// 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,
@@ -619,6 +886,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
setConfig,
clearError,
exitSession,
boostMomentum, // Client-side momentum control
}
return (

View File

@@ -76,12 +76,6 @@ export class ComplementRaceValidator
implements GameValidator<ComplementRaceState, ComplementRaceMove>
{
validateMove(state: ComplementRaceState, move: ComplementRaceMove): ValidationResult {
console.log('[ComplementRace] Validating move:', {
type: move.type,
playerId: move.playerId,
gamePhase: state.gamePhase,
})
switch (move.type) {
case 'START_GAME':
return this.validateStartGame(state, move.data.activePlayers, move.data.playerMetadata)
@@ -104,7 +98,12 @@ export class ComplementRaceValidator
return this.validateUpdateInput(state, move.playerId, move.data.input)
case 'CLAIM_PASSENGER':
return this.validateClaimPassenger(state, move.playerId, move.data.passengerId)
return this.validateClaimPassenger(
state,
move.playerId,
move.data.passengerId,
move.data.carIndex
)
case 'DELIVER_PASSENGER':
return this.validateDeliverPassenger(state, move.playerId, move.data.passengerId)
@@ -168,8 +167,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,
@@ -191,8 +189,25 @@ export class ComplementRaceValidator
? this.generatePassengers(state.config.passengerCount, state.stations)
: []
// Calculate maxConcurrentPassengers based on initial passenger layout (sprint mode only)
let updatedConfig = state.config
if (state.config.style === 'sprint' && passengers.length > 0) {
const maxConcurrentPassengers = Math.max(
1,
this.calculateMaxConcurrentPassengers(passengers, state.stations)
)
console.log(
`[Game Start] Calculated maxConcurrentPassengers: ${maxConcurrentPassengers} for ${passengers.length} passengers`
)
updatedConfig = {
...state.config,
maxConcurrentPassengers,
}
}
const newState: ComplementRaceState = {
...state,
config: updatedConfig,
gamePhase: 'playing', // Go directly to playing (countdown can be added later)
activePlayers,
playerMetadata: playerMetadata as typeof state.playerMetadata,
@@ -203,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 }
@@ -317,17 +333,9 @@ export class ComplementRaceValidator
updatedPlayer.position = Math.min(100, player.position + 100 / state.config.raceGoal)
}
} else if (state.config.style === 'sprint') {
// Sprint: Update momentum AND position
if (correct) {
updatedPlayer.momentum = Math.min(100, player.momentum + 15)
} else {
updatedPlayer.momentum = Math.max(0, player.momentum - 10)
}
// Move train based on momentum (momentum/20 = position change per answer)
// Higher momentum = faster movement
const moveDistance = updatedPlayer.momentum / 20
updatedPlayer.position = Math.min(100, player.position + moveDistance)
// 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
@@ -396,7 +404,8 @@ export class ComplementRaceValidator
private validateClaimPassenger(
state: ComplementRaceState,
playerId: string,
passengerId: string
passengerId: string,
carIndex: number
): ValidationResult {
if (state.config.style !== 'sprint') {
return { valid: false, error: 'Passengers only available in sprint mode' }
@@ -423,22 +432,27 @@ export class ComplementRaceValidator
return { valid: false, error: 'Passenger already claimed' }
}
// Check if player is at the origin station (within 5% tolerance)
const originStation = state.stations.find((s) => s.id === passenger.originStationId)
if (!originStation) {
return { valid: false, error: 'Origin station not found' }
// Sprint mode: Position is client-side, trust client's spatial checking
// (Client checks position in useSteamJourney before sending CLAIM move)
// Other modes: Validate position server-side
if (state.config.style !== 'sprint') {
const originStation = state.stations.find((s) => s.id === passenger.originStationId)
if (!originStation) {
return { valid: false, error: 'Origin station not found' }
}
const distance = Math.abs(player.position - originStation.position)
if (distance > 5) {
return { valid: false, error: 'Not at origin station' }
}
}
const distance = Math.abs(player.position - originStation.position)
if (distance > 5) {
return { valid: false, error: 'Not at origin station' }
}
// Claim passenger
// Claim passenger and assign to physical car
const updatedPassengers = [...state.passengers]
updatedPassengers[passengerIndex] = {
...passenger,
claimedBy: playerId,
carIndex, // Store which physical car (0-N) the passenger is seated in
}
const newState: ComplementRaceState = {
@@ -486,15 +500,19 @@ export class ComplementRaceValidator
return { valid: false, error: 'Passenger already delivered' }
}
// Check if player is at destination station (within 5% tolerance)
const destStation = state.stations.find((s) => s.id === passenger.destinationStationId)
if (!destStation) {
return { valid: false, error: 'Destination station not found' }
}
// Sprint mode: Position is client-side, trust client's spatial checking
// (Client checks position in useSteamJourney before sending DELIVER move)
// Other modes: Validate position server-side
if (state.config.style !== 'sprint') {
const destStation = state.stations.find((s) => s.id === passenger.destinationStationId)
if (!destStation) {
return { valid: false, error: 'Destination station not found' }
}
const distance = Math.abs(player.position - destStation.position)
if (distance > 5) {
return { valid: false, error: 'Not at destination station' }
const distance = Math.abs(player.position - destStation.position)
if (distance > 5) {
return { valid: false, error: 'Not at destination station' }
}
}
// Deliver passenger and award points
@@ -527,12 +545,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
}
}
@@ -540,12 +558,26 @@ export class ComplementRaceValidator
// Generate new passengers
const newPassengers = this.generatePassengers(state.config.passengerCount, state.stations)
// Calculate maxConcurrentPassengers based on the new route's passenger layout
const maxConcurrentPassengers = Math.max(
1,
this.calculateMaxConcurrentPassengers(newPassengers, state.stations)
)
console.log(
`[Route ${routeNumber}] Calculated maxConcurrentPassengers: ${maxConcurrentPassengers} for ${newPassengers.length} passengers`
)
const newState: ComplementRaceState = {
...state,
currentRoute: routeNumber,
routeStartTime: Date.now(),
players: resetPlayers,
passengers: newPassengers,
config: {
...state.config,
maxConcurrentPassengers, // Update config with calculated value
},
}
return { valid: true, newState }
@@ -666,37 +698,126 @@ export class ComplementRaceValidator
private generatePassengers(count: number, stations: Station[]): Passenger[] {
const passengers: Passenger[] = []
const usedCombos = new Set<string>()
for (let i = 0; i < count; i++) {
// Pick random origin and destination (must be different)
const originIndex = Math.floor(Math.random() * stations.length)
let destIndex = Math.floor(Math.random() * stations.length)
while (destIndex === originIndex) {
destIndex = Math.floor(Math.random() * stations.length)
let name: string
let avatar: string
let comboKey: string
// Keep trying until we get a unique name/avatar combo
do {
const nameIndex = Math.floor(Math.random() * PASSENGER_NAMES.length)
const avatarIndex = Math.floor(Math.random() * PASSENGER_AVATARS.length)
name = PASSENGER_NAMES[nameIndex]
avatar = PASSENGER_AVATARS[avatarIndex]
comboKey = `${name}-${avatar}`
} while (usedCombos.has(comboKey) && usedCombos.size < 100) // Prevent infinite loop
usedCombos.add(comboKey)
// Pick origin and destination stations
// KEY: Destination must be AHEAD of origin (higher position on track)
// This ensures passengers travel forward, creating better overlap
let originStation: Station
let destinationStation: Station
if (Math.random() < 0.4 || stations.length < 3) {
// 40% chance to start at depot (first station)
originStation = stations[0]
// Pick any station ahead as destination
const stationsAhead = stations.slice(1)
destinationStation = stationsAhead[Math.floor(Math.random() * stationsAhead.length)]
} else {
// Start at a random non-depot, non-final station
const nonDepotStations = stations.slice(1, -1) // Exclude depot and final station
originStation = nonDepotStations[Math.floor(Math.random() * nonDepotStations.length)]
// Pick a station ahead of origin (higher position)
const stationsAhead = stations.filter((s) => s.position > originStation.position)
destinationStation = stationsAhead[Math.floor(Math.random() * stationsAhead.length)]
}
const nameIndex = Math.floor(Math.random() * PASSENGER_NAMES.length)
const avatarIndex = Math.floor(Math.random() * PASSENGER_AVATARS.length)
// 30% chance of urgent
const isUrgent = Math.random() < 0.3
passengers.push({
const passenger = {
id: `p-${Date.now()}-${i}-${Math.random().toString(36).substr(2, 9)}`,
name: PASSENGER_NAMES[nameIndex],
avatar: PASSENGER_AVATARS[avatarIndex],
originStationId: stations[originIndex].id,
destinationStationId: stations[destIndex].id,
isUrgent: Math.random() < 0.3, // 30% chance of urgent
name,
avatar,
originStationId: originStation.id,
destinationStationId: destinationStation.id,
isUrgent,
claimedBy: null,
deliveredBy: null,
carIndex: null, // Not boarded yet
timestamp: Date.now(),
})
}
passengers.push(passenger)
console.log(
`[Passenger ${i + 1}/${count}] ${name} waiting at ${originStation.emoji} ${originStation.name} (pos ${originStation.position}) → ${destinationStation.emoji} ${destinationStation.name} (pos ${destinationStation.position}) ${isUrgent ? '⚡ URGENT' : ''}`
)
}
console.log(`[Generated ${passengers.length} passengers total]`)
return passengers
}
/**
* Calculate the maximum number of passengers that will be on the train
* concurrently at any given moment during the route
*/
private calculateMaxConcurrentPassengers(passengers: Passenger[], stations: Station[]): number {
// Create events for boarding and delivery
interface StationEvent {
position: number
isBoarding: boolean // true = board, false = delivery
}
const events: StationEvent[] = []
for (const passenger of passengers) {
const originStation = stations.find((s) => s.id === passenger.originStationId)
const destStation = stations.find((s) => s.id === passenger.destinationStationId)
if (originStation && destStation) {
events.push({ position: originStation.position, isBoarding: true })
events.push({ position: destStation.position, isBoarding: false })
}
}
// Sort events by position, with deliveries before boardings at the same position
events.sort((a, b) => {
if (a.position !== b.position) return a.position - b.position
// At same position, deliveries happen before boarding
return a.isBoarding ? 1 : -1
})
// Track current passenger count and maximum
let currentCount = 0
let maxCount = 0
for (const event of events) {
if (event.isBoarding) {
currentCount++
maxCount = Math.max(maxCount, currentCount)
} else {
currentCount--
}
}
return maxCount
}
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)) {
@@ -704,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
@@ -755,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

@@ -41,6 +41,7 @@ export interface Passenger {
isUrgent: boolean // Urgent passengers worth 2x points
claimedBy: string | null // playerId who picked up this passenger (null = unclaimed)
deliveredBy: string | null // playerId who delivered (null = not delivered yet)
carIndex: number | null // Physical car index (0-N) where passenger is seated (null = not boarded)
timestamp: number // When passenger spawned
}
@@ -61,8 +62,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
@@ -143,7 +143,7 @@ export type ComplementRaceMove = BaseGameMove &
// Playing phase
| { type: 'SUBMIT_ANSWER'; data: { answer: number; responseTime: number } }
| { type: 'UPDATE_INPUT'; data: { input: string } } // Show "thinking" indicator
| { type: 'CLAIM_PASSENGER'; data: { passengerId: string } } // Sprint mode: pickup
| { type: 'CLAIM_PASSENGER'; data: { passengerId: string; carIndex: number } } // Sprint mode: pickup
| { type: 'DELIVER_PASSENGER'; data: { passengerId: string } } // Sprint mode: delivery
// Game flow

View File

@@ -107,32 +107,28 @@ export function useArcadeSession<TState>(
exitSession: socketExitSession,
} = useArcadeSocket({
onSessionState: (data) => {
console.log('[ArcadeSession] Syncing with server state')
optimistic.syncWithServer(data.gameState as TState, data.version)
},
onMoveAccepted: (data) => {
console.log('[ArcadeSession] Move accepted by server')
optimistic.handleMoveAccepted(data.gameState as TState, data.version, data.move)
},
onMoveRejected: (data) => {
console.log('[ArcadeSession] Move rejected by server:', data.error)
console.log(`[ArcadeSession] Move rejected: ${data.error}`)
optimistic.handleMoveRejected(data.error, data.move)
},
onSessionEnded: () => {
console.log('[ArcadeSession] Session ended')
optimistic.reset()
},
onNoActiveSession: () => {
console.log('[ArcadeSession] No active session found')
// Silent - normal state
},
onError: (data) => {
console.error('[ArcadeSession] Error:', data.error)
// Users can handle errors via the onMoveRejected callback
console.error(`[ArcadeSession] Error: ${data.error}`)
},
})

View File

@@ -62,22 +62,19 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
})
socketInstance.on('session-state', (data) => {
console.log('[ArcadeSocket] Received session state', data)
eventsRef.current.onSessionState?.(data)
})
socketInstance.on('no-active-session', () => {
console.log('[ArcadeSocket] No active session')
eventsRef.current.onNoActiveSession?.()
})
socketInstance.on('move-accepted', (data) => {
console.log('[ArcadeSocket] Move accepted', data)
eventsRef.current.onMoveAccepted?.(data)
})
socketInstance.on('move-rejected', (data) => {
console.log('[ArcadeSocket] Move rejected', data)
console.log(`[ArcadeSocket] Move rejected: ${data.error}`)
eventsRef.current.onMoveRejected?.(data)
})
@@ -124,12 +121,7 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
console.warn('[ArcadeSocket] Cannot send move - socket not connected')
return
}
const payload = { userId, move, roomId }
console.log(
'[ArcadeSocket] Sending game-move event with payload:',
JSON.stringify(payload, null, 2)
)
socket.emit('game-move', payload)
socket.emit('game-move', { userId, move, roomId })
},
[socket]
)

View File

@@ -451,7 +451,6 @@ export function useRoomData() {
gameName: string | null
gameConfig?: Record<string, unknown>
}) => {
console.log('[useRoomData] Room game changed:', data)
if (data.roomId === roomData?.id) {
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
if (!prev) return null
@@ -683,18 +682,6 @@ async function updateGameConfigApi(params: {
roomId: string
gameConfig: Record<string, unknown>
}): Promise<void> {
console.log(
'[updateGameConfigApi] Sending PATCH to server:',
JSON.stringify(
{
url: `/api/arcade/rooms/${params.roomId}/settings`,
gameConfig: params.gameConfig,
},
null,
2
)
)
const response = await fetch(`/api/arcade/rooms/${params.roomId}/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -705,11 +692,8 @@ async function updateGameConfigApi(params: {
if (!response.ok) {
const errorData = await response.json()
console.error('[updateGameConfigApi] Server error:', JSON.stringify(errorData, null, 2))
throw new Error(errorData.error || 'Failed to update game config')
}
console.log('[updateGameConfigApi] Server responded OK')
}
/**
@@ -730,10 +714,6 @@ export function useUpdateGameConfig() {
gameConfig: variables.gameConfig,
}
})
console.log(
'[useUpdateGameConfig] Updated cache with new gameConfig:',
JSON.stringify(variables.gameConfig, null, 2)
)
},
})
}

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

@@ -79,11 +79,6 @@ export async function createArcadeSession(
// Check if session already exists for this room (roomId is PRIMARY KEY)
const existingRoomSession = await getArcadeSessionByRoom(options.roomId)
if (existingRoomSession) {
console.log('[Session Manager] Room session already exists, returning existing:', {
roomId: options.roomId,
sessionUserId: existingRoomSession.userId,
version: existingRoomSession.version,
})
return existingRoomSession
}
@@ -93,7 +88,6 @@ export async function createArcadeSession(
})
if (!user) {
console.log('[Session Manager] Creating new user with guestId:', options.userId)
const [newUser] = await db
.insert(schema.users)
.values({
@@ -102,9 +96,6 @@ export async function createArcadeSession(
})
.returning()
user = newUser
console.log('[Session Manager] Created user with id:', user.id)
} else {
console.log('[Session Manager] Found existing user with id:', user.id)
}
const newSession: schema.NewArcadeSession = {
@@ -121,12 +112,6 @@ export async function createArcadeSession(
version: 1,
}
console.log('[Session Manager] Creating new session:', {
roomId: options.roomId,
userId: user.id,
gameName: options.gameName,
})
try {
const [session] = await db.insert(schema.arcadeSessions).values(newSession).returning()
return session
@@ -134,10 +119,6 @@ export async function createArcadeSession(
// Handle PRIMARY KEY constraint violation (UNIQUE constraint on roomId)
// This can happen if two users try to create a session for the same room simultaneously
if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) {
console.log(
'[Session Manager] Session already exists (race condition), fetching existing session for room:',
options.roomId
)
const existingSession = await getArcadeSessionByRoom(options.roomId)
if (existingSession) {
return existingSession
@@ -180,7 +161,6 @@ export async function getArcadeSession(guestId: string): Promise<schema.ArcadeSe
})
if (!room) {
console.log('[Session Manager] Deleting session with non-existent room:', session.roomId)
await deleteArcadeSessionByRoom(session.roomId)
return undefined
}
@@ -220,16 +200,6 @@ export async function applyGameMove(
// Get the validator for this game
const validator = getValidator(session.currentGame as GameName)
console.log('[SessionManager] About to validate move:', {
gameName: session.currentGame,
moveType: move.type,
playerId: move.playerId,
moveData: move.type === 'SET_CONFIG' ? (move as any).data : undefined,
gameStateCurrentPlayer: (session.gameState as any)?.currentPlayer,
gameStateActivePlayers: (session.gameState as any)?.activePlayers,
gameStatePhase: (session.gameState as any)?.gamePhase,
})
// Fetch player ownership for authorization checks (room-based games)
let playerOwnership: PlayerOwnershipMap | undefined
let internalUserId: string | undefined
@@ -247,8 +217,6 @@ export async function applyGameMove(
// Use centralized ownership utility
playerOwnership = await buildPlayerOwnershipMap(session.roomId)
console.log('[SessionManager] Player ownership map:', playerOwnership)
console.log('[SessionManager] Internal userId for authorization:', internalUserId)
} catch (error) {
console.error('[SessionManager] Failed to fetch player ownership:', error)
}
@@ -260,11 +228,6 @@ export async function applyGameMove(
playerOwnership,
})
console.log('[SessionManager] Validation result:', {
valid: validationResult.valid,
error: validationResult.error,
})
if (!validationResult.valid) {
return {
success: false,
@@ -373,10 +336,6 @@ export async function updateSessionActivePlayers(
// Only update if game is in setup phase (not started yet)
const gameState = session.gameState as any
if (gameState.gamePhase !== 'setup') {
console.log('[Session Manager] Cannot update activePlayers - game already started:', {
roomId,
gamePhase: gameState.gamePhase,
})
return false
}
@@ -397,12 +356,6 @@ export async function updateSessionActivePlayers(
})
.where(eq(schema.arcadeSessions.roomId, roomId))
console.log('[Session Manager] Updated session activePlayers:', {
roomId,
playerIds,
count: playerIds.length,
})
return true
}

View File

@@ -41,7 +41,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
})
io.on('connection', (socket) => {
console.log('🔌 Client connected:', socket.id)
let currentUserId: string | null = null
// Join arcade session room
@@ -50,12 +49,10 @@ export function initializeSocketServer(httpServer: HTTPServer) {
async ({ userId, roomId }: { userId: string; roomId?: string }) => {
currentUserId = userId
socket.join(`arcade:${userId}`)
console.log(`👤 User ${userId} joined arcade room`)
// If this session is part of a room, also join the game room for multi-user sync
if (roomId) {
socket.join(`game:${roomId}`)
console.log(`🎮 User ${userId} joined game room ${roomId}`)
}
// Send current session state if exists
@@ -68,19 +65,14 @@ export function initializeSocketServer(httpServer: HTTPServer) {
// If no session exists for this room, create one in setup phase
// This allows users to send SET_CONFIG moves before starting the game
if (!session && roomId) {
console.log('[join-arcade-session] Creating initial session for room:', roomId)
// Get the room to determine game type and config
const room = await getRoomById(roomId)
if (room) {
// Fetch all active player IDs from room members (respects isActive flag)
const roomPlayerIds = await getRoomPlayerIds(roomId)
console.log('[join-arcade-session] Room active players:', roomPlayerIds)
// Get initial state from the correct validator based on game type
console.log('[join-arcade-session] Room game name:', room.gameName)
const validator = getValidator(room.gameName as GameName)
console.log('[join-arcade-session] Got validator for:', room.gameName)
// Get game-specific config from database (type-safe)
const gameConfig = await getGameConfig(roomId, room.gameName as GameName)
@@ -94,23 +86,10 @@ export function initializeSocketServer(httpServer: HTTPServer) {
activePlayers: roomPlayerIds, // Include all room members' active players
roomId: room.id,
})
console.log('[join-arcade-session] Created initial session:', {
roomId,
sessionId: session.userId,
gamePhase: (session.gameState as any).gamePhase,
activePlayersCount: roomPlayerIds.length,
})
}
}
if (session) {
console.log('[join-arcade-session] Found session:', {
userId,
roomId,
version: session.version,
sessionUserId: session.userId,
})
socket.emit('session-state', {
gameState: session.gameState,
currentGame: session.currentGame,
@@ -119,10 +98,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
version: session.version,
})
} else {
console.log('[join-arcade-session] No active session found for:', {
userId,
roomId,
})
socket.emit('no-active-session')
}
} catch (error) {
@@ -134,15 +109,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
// Handle game moves
socket.on('game-move', async (data: { userId: string; move: GameMove; roomId?: string }) => {
console.log('🎮 Game move received:', {
userId: data.userId,
moveType: data.move.type,
playerId: data.move.playerId,
timestamp: data.move.timestamp,
roomId: data.roomId,
fullMove: JSON.stringify(data.move, null, 2),
})
try {
// Special handling for START_GAME - create session if it doesn't exist
if (data.move.type === 'START_GAME') {
@@ -152,12 +118,9 @@ export function initializeSocketServer(httpServer: HTTPServer) {
: await getArcadeSession(data.userId)
if (!existingSession) {
console.log('🎯 Creating new session for START_GAME')
// activePlayers must be provided in the START_GAME move data
const activePlayers = (data.move.data as any)?.activePlayers
if (!activePlayers || activePlayers.length === 0) {
console.error('❌ START_GAME move missing activePlayers')
socket.emit('move-rejected', {
error: 'START_GAME requires at least one active player',
move: data.move,
@@ -186,7 +149,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
existingRoom.status !== 'finished'
) {
room = existingRoom
console.log('🏠 Using existing room:', room.code)
break
}
}
@@ -205,7 +167,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
},
ttlMinutes: 60,
})
console.log('🏠 Created new room:', room.code)
}
// Now create the session linked to the room
@@ -218,8 +179,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
roomId: room.id,
})
console.log('✅ Session created successfully with room association')
// Notify all connected clients about the new session
const newSession = await getArcadeSession(data.userId)
if (newSession) {
@@ -230,7 +189,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
activePlayers: newSession.activePlayers,
version: newSession.version,
})
console.log('📢 Emitted session-state to notify clients of new session')
}
}
}
@@ -251,7 +209,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
// If this is a room-based session, ALSO broadcast to all users in the room
if (result.session.roomId) {
io!.to(`game:${result.session.roomId}`).emit('move-accepted', moveAcceptedData)
console.log(`📢 Broadcasted move to game room ${result.session.roomId}`)
}
// Update activity timestamp
@@ -275,8 +232,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
// Handle session exit
socket.on('exit-arcade-session', async ({ userId }: { userId: string }) => {
console.log('🚪 User exiting arcade session:', userId)
try {
await deleteArcadeSession(userId)
io!.to(`arcade:${userId}`).emit('session-ended')
@@ -298,8 +253,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
// Room: Join
socket.on('join-room', async ({ roomId, userId }: { roomId: string; userId: string }) => {
console.log(`🏠 User ${userId} joining room ${roomId}`)
try {
// Join the socket room
socket.join(`room:${roomId}`)
@@ -323,10 +276,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
const sessionUpdated = await updateSessionActivePlayers(roomId, roomPlayerIds)
if (sessionUpdated) {
console.log(`🎮 Updated session activePlayers for room ${roomId}:`, {
playerCount: roomPlayerIds.length,
})
// Broadcast updated session state to all users in the game room
const updatedSession = await getArcadeSessionByRoom(roomId)
if (updatedSession) {
@@ -337,7 +286,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
activePlayers: updatedSession.activePlayers,
version: updatedSession.version,
})
console.log(`📢 Broadcasted updated session state to game room ${roomId}`)
}
}
@@ -355,8 +303,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
members,
memberPlayers: memberPlayersObj,
})
console.log(`✅ User ${userId} joined room ${roomId}`)
} catch (error) {
console.error('Error joining room:', error)
socket.emit('room-error', { error: 'Failed to join room' })
@@ -365,11 +311,9 @@ export function initializeSocketServer(httpServer: HTTPServer) {
// User Channel: Join (for moderation events)
socket.on('join-user-channel', async ({ userId }: { userId: string }) => {
console.log(`👤 User ${userId} joining user-specific channel`)
try {
// Join user-specific channel for moderation notifications
socket.join(`user:${userId}`)
console.log(`✅ User ${userId} joined user channel`)
} catch (error) {
console.error('Error joining user channel:', error)
}
@@ -377,8 +321,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
// Room: Leave
socket.on('leave-room', async ({ roomId, userId }: { roomId: string; userId: string }) => {
console.log(`🚪 User ${userId} leaving room ${roomId}`)
try {
// Leave the socket room
socket.leave(`room:${roomId}`)
@@ -403,8 +345,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
members,
memberPlayers: memberPlayersObj,
})
console.log(`✅ User ${userId} left room ${roomId}`)
} catch (error) {
console.error('Error leaving room:', error)
}
@@ -412,8 +352,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
// Room: Players updated
socket.on('players-updated', async ({ roomId, userId }: { roomId: string; userId: string }) => {
console.log(`🎯 Players updated for user ${userId} in room ${roomId}`)
try {
// Get updated player data
const memberPlayers = await getRoomActivePlayers(roomId)
@@ -429,11 +367,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
const sessionUpdated = await updateSessionActivePlayers(roomId, roomPlayerIds)
if (sessionUpdated) {
console.log(`🎮 Updated session activePlayers after player toggle:`, {
roomId,
playerCount: roomPlayerIds.length,
})
// Broadcast updated session state to all users in the game room
const updatedSession = await getArcadeSessionByRoom(roomId)
if (updatedSession) {
@@ -444,7 +377,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
activePlayers: updatedSession.activePlayers,
version: updatedSession.version,
})
console.log(`📢 Broadcasted updated session state to game room ${roomId}`)
}
}
@@ -453,8 +385,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
roomId,
memberPlayers: memberPlayersObj,
})
console.log(`✅ Broadcasted player updates for room ${roomId}`)
} catch (error) {
console.error('Error updating room players:', error)
socket.emit('room-error', { error: 'Failed to update players' })
@@ -462,16 +392,11 @@ export function initializeSocketServer(httpServer: HTTPServer) {
})
socket.on('disconnect', () => {
console.log('🔌 Client disconnected:', socket.id)
if (currentUserId) {
// Don't delete session on disconnect - it persists across devices
console.log(`👤 User ${currentUserId} disconnected but session persists`)
}
// Don't delete session on disconnect - it persists across devices
})
})
// Store in globalThis to make accessible across module boundaries
globalThis.__socketIO = io
console.log('✅ Socket.IO initialized on /api/socket')
return io
}

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "4.4.3",
"version": "4.6.2",
"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