Compare commits

...

68 Commits

Author SHA1 Message Date
semantic-release-bot
b77ff78cfc chore(release): 4.7.1 [skip ci]
## [4.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.7.0...v4.7.1) (2025-10-18)

### Code Refactoring

* **arcade:** remove number-guesser and math-sprint games ([9fb9786](9fb9786e54))
2025-10-18 17:20:44 +00:00
Thomas Hallock
9fb9786e54 refactor(arcade): remove number-guesser and math-sprint games
Cleaned up arcade room game selection by removing two games:
- Removed number-guesser game and all its code
- Removed math-sprint game and all its code

Changes:
- Removed from game registry (game-registry.ts)
- Removed from validator registry (validators.ts)
- Removed type definitions and default configs (game-configs.ts)
- Deleted all game directories and files

Remaining games:
- Memory Quiz (Soroban Lightning)
- Matching (Memory Pairs Battle)
- Complement Race (Speed Complements)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 12:19:45 -05:00
semantic-release-bot
72bb2eb58b chore(release): 4.7.0 [skip ci]
## [4.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.10...v4.7.0) (2025-10-18)

### Features

* **complement-race:** enable adaptive AI difficulty in arcade ([55010d2](55010d2bcd))
2025-10-18 14:26:08 +00:00
Thomas Hallock
55010d2bcd feat(complement-race): enable adaptive AI difficulty in arcade
Implemented adaptive AI speed adjustment based on player performance:
- Added UPDATE_AI_SPEEDS handler to update clientAIRacers speeds
- Added UPDATE_DIFFICULTY_TRACKER handler to update local state
- Now matches solo game behavior where AI speeds adapt to challenge

AI speeds adjust based on:
- Player success rate (0.5x to 1.6x multiplier)
- Average response time (faster players get faster AI)
- Current streak (hot streaks increase challenge)
- Learning mode (no adaptation until sufficient data)

This provides dynamic difficulty balancing, making the game
engaging for both beginners and advanced players.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 09:25:02 -05:00
semantic-release-bot
f735e5d3ba chore(release): 4.6.10 [skip ci]
## [4.6.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.9...v4.6.10) (2025-10-18)

### Bug Fixes

* **complement-race:** improve AI speech bubble positioning ([6e436db](6e436db5e7))
* **docker:** remove reference to deleted @soroban/client package ([2953ef8](2953ef8917))
2025-10-18 14:21:51 +00:00
Thomas Hallock
6e436db5e7 fix(complement-race): improve AI speech bubble positioning
Speech bubbles now:
- Position 15px above AI racers instead of directly over them
- Use zIndex 20 to appear above all racers (player: 10, AI: 5)

This fixes two UX issues:
1. Bubbles were covering AI racer emojis, making them invisible
2. Bubbles appeared under player avatar when racers were close

Applied to both LinearTrack (practice) and CircularTrack (survival)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 09:20:40 -05:00
Thomas Hallock
2953ef8917 fix(docker): remove reference to deleted @soroban/client package
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 09:20:40 -05:00
semantic-release-bot
7675e59868 chore(release): 4.6.9 [skip ci]
## [4.6.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.8...v4.6.9) (2025-10-18)

### Bug Fixes

* **complement-race:** add missing AI commentary cooldown updates ([357aa30](357aa30618))

### Code Refactoring

* remove dead Python bridge and unused packages ([22426f6](22426f677f))
2025-10-18 14:15:20 +00:00
Thomas Hallock
357aa30618 fix(complement-race): add missing AI commentary cooldown updates
Fixed critical bug where AI racers would spam speech bubbles every 200ms
instead of respecting the 2-6 second cooldown between comments.

Issue:
The TRIGGER_AI_COMMENTARY action was only updating activeSpeechBubbles
but not updating the AI racer's lastComment timestamp and cooldown value.
This caused getAICommentary() cooldown check to always pass since
lastComment stayed at 0.

Fix:
Added setClientAIRacers() call to update racer.lastComment and
racer.commentCooldown (random 2-6 seconds) when commentary is triggered,
matching the pattern from the original single-player version.

Impact:
- AI commentary now properly throttled (one comment per 2-6 seconds)
- Speech bubbles readable instead of rapidly changing
- Matches original solo game behavior

Provider.tsx:803-814

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 09:14:05 -05:00
Thomas Hallock
22426f677f refactor: remove dead Python bridge and unused packages
Removed abandoned SVG generation code that was never used in production:

**Deleted Files:**
- packages/core/src/bridge.py (302 lines) - Python-shell bridge for SVG generation
- packages/core/client/node/src/soroban-generator-bridge.ts - TypeScript wrapper
- packages/core/client/typescript/ - Entire unused @soroban/client package
- packages/core/client/browser/ - Empty package

**Dependencies Removed:**
- python-shell - Only used by abandoned bridge code
- @types/minimatch - Only needed by removed TypeScript packages
- @soroban/client from apps/web

**Code Cleanup:**
- Simplified packages/core/client/node/src/index.ts exports
- Removed SorobanGeneratorBridge, BridgeFlashcardConfig, BridgeFlashcardResult exports

**Impact:**
- ~800 lines of dead TypeScript code removed
- 302 lines of unused Python code removed
- 2 npm dependencies removed
- Build verified successful - no functionality affected

**What Remains Active:**
- generate.py - PDF generation via Typst CLI (actively used by /api/generate)
- soroban-generator.ts - CLI wrapper for PDF generation
- api.py - Optional FastAPI server
- generate_examples.py - Documentation image generator
- Web app uses @soroban/abacus-react for all SVG rendering

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 09:11:59 -05:00
semantic-release-bot
cf997b9cbc chore(release): 4.6.8 [skip ci]
## [4.6.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.7...v4.6.8) (2025-10-18)

### Bug Fixes

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

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

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

LinearTrack.tsx:145-154

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

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

### Bug Fixes

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

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

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

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

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

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

### Bug Fixes

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

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

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

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

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

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

### Bug Fixes

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

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

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

### Bug Fixes

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

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

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

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

### Bug Fixes

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

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

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

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

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

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

### Bug Fixes

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

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

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

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

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

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

### Code Refactoring

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

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

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

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

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

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

### Features

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

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

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

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

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

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

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

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

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

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

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

### Features

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

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

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

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

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

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

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

### Bug Fixes

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

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

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

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

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

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

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

This properly detects when the train crosses the exit threshold.

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

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

### Bug Fixes

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### Bug Fixes

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

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

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

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

Now passengers appear immediately when a new route starts.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 18:04:23 -05:00
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
semantic-release-bot
52019a24c2 chore(release): 4.4.3 [skip ci]
## [4.4.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.2...v4.4.3) (2025-10-17)

### Bug Fixes

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

### Code Refactoring

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

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

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

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

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

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

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

### Code Refactoring

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 06:59:51 -05:00
semantic-release-bot
230860b8a1 chore(release): 4.4.1 [skip ci]
## [4.4.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.0...v4.4.1) (2025-10-17)

### Bug Fixes

* **complement-race:** clear input state on question transitions ([5872030](587203056a))

### Documentation

* **complement-race:** add Phase 9 for multiplayer visual features ([131c54b](131c54b562))
2025-10-17 11:57:25 +00:00
Thomas Hallock
587203056a fix(complement-race): clear input state on question transitions
Fixed bug where previous answer appeared in complement box instead of "?".

Root cause: Provider's localUIState.currentInput wasn't being cleared when
NEXT_QUESTION was dispatched. The sequence was:
1. User types answer (e.g. "5")
2. UPDATE_INPUT sets localUIState.currentInput = "5"
3. Answer correct → NEXT_QUESTION dispatched
4. Server generates new question
5. But localUIState.currentInput still "5" 

Solution: Clear localUIState.currentInput in NEXT_QUESTION case.

Added comprehensive debug logging:
- GameDisplay: Render state, keyboard events, answer validation
- Provider: Dispatch actions, input clearing
- Context reducer: All action types, NEXT_QUESTION flow, UPDATE_INPUT

This logging will help identify any remaining state synchronization issues.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 06:56:20 -05:00
Thomas Hallock
131c54b562 docs(complement-race): add Phase 9 for multiplayer visual features
**Phase 9: Multiplayer Visual Features (REQUIRED FOR FULL SUPPORT)**

Current status: 70% complete - backend fully functional, frontend needs
multiplayer visualization.

Added detailed implementation plans with code examples:
- 9.1 Ghost Trains (Sprint Mode) - 2-3 hours
- 9.2 Multi-Lane Track (Practice Mode) - 3-4 hours
- 9.3 Multiplayer Results Screen - 1-2 hours
- 9.4 Visual Lobby/Ready System - 2-3 hours
- 9.5 AI Opponents Display - 4-6 hours
- 9.6 Event Feed (Optional) - 3-4 hours

Updated sections:
- Implementation Order: Marked phases 1-3 complete, added Phase 4 (Visuals)
- Success Criteria: Split into Backend (complete), Visuals (in progress), Testing
- Next Steps: Prioritized visual features as immediate work

Total estimated time for Phase 9: 15-20 hours

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 12:36:38 -05:00
semantic-release-bot
ed42651319 chore(release): 4.4.0 [skip ci]
## [4.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.3.1...v4.4.0) (2025-10-16)

### Features

* **complement-race:** add mini app navigation bar ([ed0ef2d](ed0ef2d3b8))
2025-10-16 17:17:45 +00:00
Thomas Hallock
ed0ef2d3b8 feat(complement-race): add mini app navigation bar
Adds PageWithNav wrapper to complement-race game for consistency with
other arcade games.

## Changes
- Created `GameComponent.tsx` wrapper that includes PageWithNav
- Wraps existing ComplementRaceGame with navigation bar
- Updates game title and emoji based on selected style:
  - Practice mode: "Complement Race" 🏁
  - Sprint mode: "Steam Sprint" 🚂
  - Survival mode: "Endless Circuit" ♾️
- Provides exit session and new game callbacks
- Emphasizes player selection during setup phase

## Integration
- Updated index.tsx to use new GameComponent instead of direct ComplementRaceGame
- Maintains all existing game functionality
- Navigation bar now matches other arcade games (matching, number-guesser)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 12:16:39 -05:00
91 changed files with 2542 additions and 5587 deletions

View File

@@ -1,3 +1,230 @@
## [4.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.7.0...v4.7.1) (2025-10-18)
### Code Refactoring
* **arcade:** remove number-guesser and math-sprint games ([9fb9786](https://github.com/antialias/soroban-abacus-flashcards/commit/9fb9786e54928e81ecf226b36d343a73143fb674))
## [4.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.10...v4.7.0) (2025-10-18)
### Features
* **complement-race:** enable adaptive AI difficulty in arcade ([55010d2](https://github.com/antialias/soroban-abacus-flashcards/commit/55010d2bcd953718d8fea428b1f7f613a193779c))
## [4.6.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.9...v4.6.10) (2025-10-18)
### Bug Fixes
* **complement-race:** improve AI speech bubble positioning ([6e436db](https://github.com/antialias/soroban-abacus-flashcards/commit/6e436db5e709d944ebffed6936ea1f8e4bd2e19e))
* **docker:** remove reference to deleted @soroban/client package ([2953ef8](https://github.com/antialias/soroban-abacus-flashcards/commit/2953ef8917f7b13f6eb562eb7d58d14179a718da))
## [4.6.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.8...v4.6.9) (2025-10-18)
### Bug Fixes
* **complement-race:** add missing AI commentary cooldown updates ([357aa30](https://github.com/antialias/soroban-abacus-flashcards/commit/357aa30618f80d659ae515f94b7b9254bb458910))
### Code Refactoring
* remove dead Python bridge and unused packages ([22426f6](https://github.com/antialias/soroban-abacus-flashcards/commit/22426f677f9b127441377b95571f0066a0990d3f))
## [4.6.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.7...v4.6.8) (2025-10-18)
### Bug Fixes
* **complement-race:** counter-flip AI speech bubbles to make text readable ([07d5607](https://github.com/antialias/soroban-abacus-flashcards/commit/07d5607218aee03e813eceff5d161a7838d66bcb))
## [4.6.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.6...v4.6.7) (2025-10-18)
### Bug Fixes
* **complement-race:** use active local players pattern from navbar ([71cdc34](https://github.com/antialias/soroban-abacus-flashcards/commit/71cdc342c97ca53b5e7e4202d4d344199e8ddd98))
## [4.6.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.5...v4.6.6) (2025-10-18)
### Bug Fixes
* **complement-race:** use local player emoji instead of first active player ([76eb051](https://github.com/antialias/soroban-abacus-flashcards/commit/76eb0517c202d1b9160b49dec0b99ff4972daff2))
## [4.6.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.4...v4.6.5) (2025-10-18)
### Bug Fixes
* **complement-race:** flip player avatar to face right in practice mode ([fa6b3b6](https://github.com/antialias/soroban-abacus-flashcards/commit/fa6b3b69d5a4a7eb70f8c18fc8c122c54c4d504a))
## [4.6.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.3...v4.6.4) (2025-10-18)
### Bug Fixes
* **complement-race:** flip AI racers to face right in practice mode ([ebfff1a](https://github.com/antialias/soroban-abacus-flashcards/commit/ebfff1a62fd104d531a8158345c8c012ec8a55d3))
## [4.6.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.2...v4.6.3) (2025-10-18)
### Bug Fixes
* **complement-race:** balance AI speeds to match original implementation ([054f0c0](https://github.com/antialias/soroban-abacus-flashcards/commit/054f0c0d235dc2b0042a0f6af48840d23a4c5ff8))
## [4.6.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.1...v4.6.2) (2025-10-18)
### Bug Fixes
* **build:** resolve Docker build failures preventing deployment ([7801dbb](https://github.com/antialias/soroban-abacus-flashcards/commit/7801dbb25fb0a33429c70f11294264f7238ce7a4))
## [4.6.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.0...v4.6.1) (2025-10-18)
### Code Refactoring
* **complement-race:** move AI opponents from server-side to client-side ([09e21fa](https://github.com/antialias/soroban-abacus-flashcards/commit/09e21fa4934c634d0ce46381ef7e40238fc134c3))
## [4.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.5.0...v4.6.0) (2025-10-18)
### Features
* **complement-race:** restore AI opponents in practice and survival modes ([325e07d](https://github.com/antialias/soroban-abacus-flashcards/commit/325e07de5929169aa333ef16f7bca5b41eeb1622))
## [4.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.15...v4.5.0) (2025-10-18)
### Features
* **complement-race:** add infinite win condition for Steam Sprint mode ([d8fdfee](https://github.com/antialias/soroban-abacus-flashcards/commit/d8fdfeef74a5d3bb9684254af1c9d64d264b46ad))
## [4.4.15](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.14...v4.4.15) (2025-10-18)
### Bug Fixes
* **complement-race:** track previous position to detect route threshold crossing ([a6c20aa](https://github.com/antialias/soroban-abacus-flashcards/commit/a6c20aab3b245d9893808d188d16a35ab80cfca9))
## [4.4.14](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.13...v4.4.14) (2025-10-18)
### Bug Fixes
* **complement-race:** remove dual game loop conflict preventing route progression ([84d42e2](https://github.com/antialias/soroban-abacus-flashcards/commit/84d42e22ac0cdd25e87e45dc698029ad7ed78559))
## [4.4.13](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.12...v4.4.13) (2025-10-17)
### Bug Fixes
* **complement-race:** show new passengers when route changes ([ec1c8ed](https://github.com/antialias/soroban-abacus-flashcards/commit/ec1c8ed263844f56477c1f709041339b42b48f4e))
## [4.4.12](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.11...v4.4.12) (2025-10-17)
### 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)
### Bug Fixes
* **complement-race:** train now moves in sprint mode ([54b46e7](https://github.com/antialias/soroban-abacus-flashcards/commit/54b46e771e654721e7fabb1f45ecd45daf8e447f))
### Code Refactoring
* simplify train debug logs to strings only ([334a49c](https://github.com/antialias/soroban-abacus-flashcards/commit/334a49c92e112c852c483b5dbe3a3d0aef8a5c03))
## [4.4.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.1...v4.4.2) (2025-10-17)
### Code Refactoring
* **complement-race:** remove verbose logging, keep only train debug logs ([86af2fe](https://github.com/antialias/soroban-abacus-flashcards/commit/86af2fe902b3d3790b7b4659fdc698caed8e4dd9))
## [4.4.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.0...v4.4.1) (2025-10-17)
### Bug Fixes
* **complement-race:** clear input state on question transitions ([5872030](https://github.com/antialias/soroban-abacus-flashcards/commit/587203056a1e1692348805eb0de909d81d16e158))
### Documentation
* **complement-race:** add Phase 9 for multiplayer visual features ([131c54b](https://github.com/antialias/soroban-abacus-flashcards/commit/131c54b5627ceeac7ca3653f683c32822a2007af))
## [4.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.3.1...v4.4.0) (2025-10-16)
### Features
* **complement-race:** add mini app navigation bar ([ed0ef2d](https://github.com/antialias/soroban-abacus-flashcards/commit/ed0ef2d3b87324470d06b3246652967544caec26))
## [4.3.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.3.0...v4.3.1) (2025-10-16)

View File

@@ -13,7 +13,6 @@ WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/web/package.json ./apps/web/
COPY packages/core/client/node/package.json ./packages/core/client/node/
COPY packages/core/client/typescript/package.json ./packages/core/client/typescript/
COPY packages/abacus-react/package.json ./packages/abacus-react/
COPY packages/templates/package.json ./packages/templates/

View File

@@ -1,9 +1,12 @@
# Speed Complement Race - Multiplayer Migration Plan
**Status**: In Progress
**Status**: Phase 1-8 Complete (70%) - **Multiplayer Visuals Remaining**
**Created**: 2025-10-16
**Updated**: 2025-10-16 (Post-Review)
**Goal**: Migrate Speed Complement Race from standalone single-player game to modular multiplayer arcade room game
**Current State**: ✅ Backend/Server Complete | ⚠️ Frontend Needs Multiplayer UI
---
## Overview
@@ -587,7 +590,7 @@ export default function ComplementRacePage({
---
## Phase 8: Testing & Validation
## Phase 8: Testing & Validation ⚠️ PENDING
### 8.1 Unit Tests
- [ ] ComplementRaceValidator logic
@@ -627,31 +630,751 @@ export default function ComplementRacePage({
---
## Phase 9: Multiplayer Visual Features ⚠️ REQUIRED FOR FULL SUPPORT
**Status**: Backend complete, frontend needs multiplayer visualization
**Goal**: Make multiplayer visible to players - currently only local player is shown on screen
### 9.1 Ghost Trains (Sprint Mode) 🚨 HIGH PRIORITY
**File**: `src/app/arcade/complement-race/components/SteamTrainJourney.tsx`
**Current State**: Only the local player's train is rendered
**Required Change**: Render all other players' trains with ghost effect
**Implementation** (Est: 2-3 hours):
```typescript
// In SteamTrainJourney.tsx
import { useComplementRace } from '@/arcade-games/complement-race/Provider'
export function SteamTrainJourney() {
const { state } = useComplementRace()
const { localPlayerId } = useArcadeSession()
// Existing local player train (keep as-is)
const localPlayer = state.players[localPlayerId]
return (
<div className="steam-track">
{/* Existing local player train - keep full opacity */}
<Train
position={localPlayer.position}
momentum={localPlayer.momentum}
passengers={localPlayer.claimedPassengers}
color="blue"
opacity={1.0}
isLocalPlayer={true}
/>
{/* NEW: Ghost trains for other players */}
{Object.entries(state.players)
.filter(([playerId]) => playerId !== localPlayerId)
.map(([playerId, player]) => (
<GhostTrain
key={playerId}
position={player.position}
color={player.color}
opacity={0.35}
name={player.name}
passengerCount={player.claimedPassengers?.length || 0}
/>
))}
{/* Existing stations and passengers */}
<Stations />
<Passengers />
</div>
)
}
```
**New Component: GhostTrain**:
```typescript
// src/app/arcade/complement-race/components/GhostTrain.tsx
interface GhostTrainProps {
position: number // 0-100%
color: string // player color
opacity: number // 0.35 for ghost effect
name: string // player name
passengerCount: number
}
export function GhostTrain({ position, color, opacity, name, passengerCount }: GhostTrainProps) {
return (
<div
className="ghost-train"
style={{
position: 'absolute',
left: `${position}%`,
opacity,
filter: 'blur(1px)', // subtle blur for ghost effect
transform: 'translateX(-50%)',
}}
>
<div className={css({ fontSize: '2rem' })}>🚂</div>
<div className={css({
fontSize: '0.7rem',
color,
fontWeight: 'bold'
})}>
{name}
{passengerCount > 0 && ` (${passengerCount}👥)`}
</div>
</div>
)
}
```
**Visual Design**:
- Local player: Full opacity (100%), vibrant colors, clear
- Other players: 30-40% opacity, subtle blur, labeled with name
- Show passenger count on ghost trains
- No collision detection needed (trains pass through each other)
**Checklist**:
- [ ] Create GhostTrain component
- [ ] Update SteamTrainJourney to render all players
- [ ] Test with 2 players (local + 1 ghost)
- [ ] Test with 4 players (local + 3 ghosts)
- [ ] Verify position updates in real-time
- [ ] Verify ghost effect (opacity, blur)
---
### 9.2 Multi-Lane Track (Practice Mode) 🚨 HIGH PRIORITY
**File**: `src/app/arcade/complement-race/components/LinearTrack.tsx`
**Current State**: Single horizontal lane showing only local player
**Required Change**: Stack 2-4 lanes vertically, one per player
**Implementation** (Est: 3-4 hours):
```typescript
// In LinearTrack.tsx
import { useComplementRace } from '@/arcade-games/complement-race/Provider'
import { useArcadeSession } from '@/lib/arcade/game-sdk'
export function LinearTrack() {
const { state } = useComplementRace()
const { localPlayerId } = useArcadeSession()
const players = Object.entries(state.players)
const laneHeight = 120 // pixels per lane
return (
<div className="track-container">
{players.map(([playerId, player], index) => {
const isLocalPlayer = playerId === localPlayerId
return (
<Lane
key={playerId}
yOffset={index * laneHeight}
isLocalPlayer={isLocalPlayer}
>
{/* Player racer */}
<Racer
position={player.position}
color={player.color}
name={player.name}
opacity={isLocalPlayer ? 1.0 : 0.35}
isLocalPlayer={isLocalPlayer}
/>
{/* Track markers (start/finish) */}
<StartLine />
<FinishLine position={state.raceGoal} />
{/* Progress bar */}
<ProgressBar
progress={player.position}
color={player.color}
/>
</Lane>
)
})}
</div>
)
}
```
**New Component: Lane**:
```typescript
// src/app/arcade/complement-race/components/Lane.tsx
interface LaneProps {
yOffset: number
isLocalPlayer: boolean
children: React.ReactNode
}
export function Lane({ yOffset, isLocalPlayer, children }: LaneProps) {
return (
<div
className={css({
position: 'relative',
height: '120px',
width: '100%',
transform: `translateY(${yOffset}px)`,
borderBottom: '2px dashed',
borderColor: isLocalPlayer ? 'blue.500' : 'gray.300',
backgroundColor: isLocalPlayer ? 'blue.50' : 'gray.50',
transition: 'all 0.3s ease',
})}
>
{children}
</div>
)
}
```
**Layout Design**:
```
┌──────────────────────────────────────────┐
│ 🏁 [========🏃‍♂️=========> ] Player 1 │ ← Local player (highlighted)
├──────────────────────────────────────────┤
│ 🏁 [=======>🏃‍♀️ ] Player 2 │ ← Ghost (low opacity)
├──────────────────────────────────────────┤
│ 🏁 [===========>🤖 ] AI Bot 1 │ ← AI (low opacity)
├──────────────────────────────────────────┤
│ 🏁 [=====>🏃 ] Player 3 │ ← Ghost (low opacity)
└──────────────────────────────────────────┘
```
**Features**:
- Each lane is color-coded per player
- Local player's lane has brighter background
- Progress bars show position clearly
- Names/avatars next to each racer
- Smooth position interpolation for animations
**Checklist**:
- [ ] Create Lane component
- [ ] Create Racer component (or update existing)
- [ ] Update LinearTrack to render multiple lanes
- [ ] Test with 2 players
- [ ] Test with 4 players (2 human + 2 AI)
- [ ] Verify position updates synchronized
- [ ] Verify local player lane is emphasized
---
### 9.3 Multiplayer Results Screen 🚨 HIGH PRIORITY
**File**: `src/app/arcade/complement-race/components/GameResults.tsx`
**Current State**: Shows only local player stats
**Required Change**: Show leaderboard with all players
**Implementation** (Est: 1-2 hours):
```typescript
// In GameResults.tsx
import { useComplementRace } from '@/arcade-games/complement-race/Provider'
import { useArcadeSession } from '@/lib/arcade/game-sdk'
export function GameResults() {
const { state, playAgain } = useComplementRace()
const { localPlayerId, isMultiplayer } = useArcadeSession()
// Calculate leaderboard
const leaderboard = Object.entries(state.players)
.map(([id, player]) => ({ id, ...player }))
.sort((a, b) => b.score - a.score)
const winner = leaderboard[0]
const localPlayerRank = leaderboard.findIndex(p => p.id === localPlayerId) + 1
return (
<div className="results-container">
{/* Winner Announcement */}
<div className="winner-banner">
<h1>🏆 {winner.name} Wins!</h1>
<p>{winner.score} points</p>
</div>
{/* Full Leaderboard */}
{isMultiplayer && (
<div className="leaderboard">
<h2>Final Standings</h2>
{leaderboard.map((player, index) => (
<LeaderboardRow
key={player.id}
rank={index + 1}
player={player}
isLocalPlayer={player.id === localPlayerId}
/>
))}
</div>
)}
{/* Local Player Summary */}
<div className="player-summary">
<h3>Your Performance</h3>
<StatCard label="Rank" value={`${localPlayerRank} / ${leaderboard.length}`} />
<StatCard label="Score" value={state.players[localPlayerId].score} />
<StatCard label="Accuracy" value={`${calculateAccuracy(state.players[localPlayerId])}%`} />
<StatCard label="Best Streak" value={state.players[localPlayerId].bestStreak} />
</div>
{/* Actions */}
<div className="actions">
<Button onClick={playAgain}>Play Again</Button>
<Button onClick={exitToLobby}>Back to Lobby</Button>
</div>
</div>
)
}
```
**New Component: LeaderboardRow**:
```typescript
interface LeaderboardRowProps {
rank: number
player: PlayerState
isLocalPlayer: boolean
}
export function LeaderboardRow({ rank, player, isLocalPlayer }: LeaderboardRowProps) {
const medalEmoji = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : ''
return (
<div
className={css({
display: 'flex',
alignItems: 'center',
padding: '1rem',
backgroundColor: isLocalPlayer ? 'blue.100' : 'white',
borderLeft: isLocalPlayer ? '4px solid blue.500' : 'none',
borderRadius: '8px',
marginBottom: '0.5rem',
})}
>
<div className="rank">{medalEmoji || rank}</div>
<div className="player-name">{player.name}</div>
<div className="score">{player.score} pts</div>
<div className="stats">
{player.correctAnswers}/{player.totalQuestions} correct
</div>
</div>
)
}
```
**Checklist**:
- [ ] Update GameResults.tsx to show leaderboard
- [ ] Create LeaderboardRow component
- [ ] Add winner announcement
- [ ] Highlight local player in leaderboard
- [ ] Show individual stats per player
- [ ] Test with 2 players
- [ ] Test with 4 players
- [ ] Verify "Play Again" works in multiplayer
---
### 9.4 Visual Lobby/Ready System ⚠️ MEDIUM PRIORITY
**File**: `src/app/arcade/complement-race/components/GameLobby.tsx` (NEW)
**Current State**: Game auto-starts, no visual ready check
**Required Change**: Show lobby with player list and ready indicators
**Implementation** (Est: 2-3 hours):
```typescript
// NEW FILE: src/app/arcade/complement-race/components/GameLobby.tsx
import { useComplementRace } from '@/arcade-games/complement-race/Provider'
import { useArcadeSession } from '@/lib/arcade/game-sdk'
export function GameLobby() {
const { state, setReady } = useComplementRace()
const { localPlayerId, isHost } = useArcadeSession()
const players = Object.entries(state.players)
const allReady = players.every(([_, p]) => p.isReady)
const canStart = players.length >= 1 && allReady
return (
<div className="lobby-container">
<h1>Waiting for Players...</h1>
{/* Player List */}
<div className="player-list">
{players.map(([playerId, player]) => (
<PlayerCard
key={playerId}
player={player}
isLocalPlayer={playerId === localPlayerId}
isReady={player.isReady}
/>
))}
{/* Empty slots */}
{Array.from({ length: state.config.maxPlayers - players.length }).map((_, i) => (
<EmptySlot key={`empty-${i}`} />
))}
</div>
{/* Ready Toggle */}
<div className="ready-controls">
<Button
onClick={() => setReady(!state.players[localPlayerId].isReady)}
variant={state.players[localPlayerId].isReady ? 'success' : 'default'}
>
{state.players[localPlayerId].isReady ? '✓ Ready' : 'Ready Up'}
</Button>
</div>
{/* Start Game (host only) */}
{isHost && (
<div className="host-controls">
<Button
onClick={startGame}
disabled={!canStart}
>
{canStart ? 'Start Game' : 'Waiting for all players...'}
</Button>
</div>
)}
{/* Countdown when starting */}
{state.gamePhase === 'countdown' && (
<Countdown seconds={state.countdownSeconds} />
)}
</div>
)
}
```
**Component: PlayerCard**:
```typescript
interface PlayerCardProps {
player: PlayerState
isLocalPlayer: boolean
isReady: boolean
}
export function PlayerCard({ player, isLocalPlayer, isReady }: PlayerCardProps) {
return (
<div
className={css({
display: 'flex',
alignItems: 'center',
padding: '1rem',
borderRadius: '8px',
backgroundColor: isLocalPlayer ? 'blue.100' : 'gray.100',
border: isReady ? '2px solid green.500' : '2px solid gray.300',
})}
>
<Avatar color={player.color} />
<div className="player-info">
<div className="name">
{player.name}
{isLocalPlayer && ' (You)'}
</div>
<div className="status">
{isReady ? '✓ Ready' : '⏳ Not ready'}
</div>
</div>
</div>
)
}
```
**Integration**: Update Provider to handle lobby phase
```typescript
// In Provider.tsx - add to context
const setReady = useCallback((ready: boolean) => {
emitMove({
type: 'set-ready',
ready,
})
}, [emitMove])
return (
<ComplementRaceContext.Provider value={{
state,
// ... other methods
setReady,
}}>
{children}
</ComplementRaceContext.Provider>
)
```
**Checklist**:
- [ ] Create GameLobby.tsx component
- [ ] Create PlayerCard component
- [ ] Add setReady to Provider context
- [ ] Update Validator to handle 'set-ready' move
- [ ] Show lobby before game starts
- [ ] Test ready/unready toggling
- [ ] Test "Start Game" (host only)
- [ ] Verify countdown before game starts
---
### 9.5 AI Opponents Display ⚠️ MEDIUM PRIORITY
**Current State**: AI opponents defined in types but not populated
**Files to Update**:
1. `src/arcade-games/complement-race/Validator.ts` - AI logic
2. Track components (LinearTrack, SteamTrainJourney) - AI rendering
**Implementation** (Est: 4-6 hours):
#### Step 1: Populate AI Opponents in Validator
```typescript
// In Validator.ts - validateStartGame method
validateStartGame(config: ComplementRaceConfig) {
const humanPlayerCount = this.activePlayers.length
const aiCount = config.enableAI
? Math.min(config.aiOpponentCount, config.maxPlayers - humanPlayerCount)
: 0
// Create AI players
const aiOpponents: AIOpponent[] = []
const aiPersonalities = ['speedy', 'steady', 'chaotic']
for (let i = 0; i < aiCount; i++) {
const aiId = `ai-${i}`
aiOpponents.push({
id: aiId,
name: `Bot ${i + 1}`,
color: ['purple', 'orange', 'pink'][i],
personality: aiPersonalities[i % aiPersonalities.length],
difficulty: config.timeoutSetting,
})
// Add to players map
state.players[aiId] = {
id: aiId,
name: `Bot ${i + 1}`,
score: 0,
streak: 0,
bestStreak: 0,
position: 0,
isReady: true, // AI always ready
correctAnswers: 0,
totalQuestions: 0,
isAI: true,
}
}
state.aiOpponents = aiOpponents
return state
}
```
#### Step 2: Update AI Positions (per frame)
```typescript
// In Validator.ts - new method
updateAIPositions(state: ComplementRaceState, deltaTime: number) {
state.aiOpponents.forEach((ai) => {
const aiPlayer = state.players[ai.id]
// AI answers questions at interval based on difficulty
const answerInterval = this.getAIAnswerInterval(ai.difficulty, ai.personality)
const timeSinceLastAnswer = Date.now() - (aiPlayer.lastAnswerTime || 0)
if (timeSinceLastAnswer > answerInterval) {
// AI answers question
const correct = this.shouldAIAnswerCorrectly(ai.personality)
if (correct) {
aiPlayer.score += 100
aiPlayer.streak += 1
aiPlayer.position += 1
aiPlayer.correctAnswers += 1
} else {
aiPlayer.streak = 0
}
aiPlayer.totalQuestions += 1
aiPlayer.lastAnswerTime = Date.now()
// Generate new question for AI
state.currentQuestions[ai.id] = this.generateQuestion(state.config)
}
})
return state
}
// Helper: AI answer timing based on difficulty
private getAIAnswerInterval(difficulty: string, personality: string) {
const baseInterval = {
'preschool': 8000,
'kindergarten': 6000,
'relaxed': 5000,
'slow': 4000,
'normal': 3000,
'fast': 2000,
'expert': 1500,
}[difficulty] || 3000
// Personality modifiers
const modifier = {
'speedy': 0.8, // 20% faster
'steady': 1.0, // Normal
'chaotic': 0.9 + Math.random() * 0.4, // Random 90-130%
}[personality] || 1.0
return baseInterval * modifier
}
// Helper: AI accuracy based on personality
private shouldAIAnswerCorrectly(personality: string): boolean {
const accuracy = {
'speedy': 0.85, // Fast but less accurate
'steady': 0.95, // Very accurate
'chaotic': 0.70, // Unpredictable
}[personality] || 0.85
return Math.random() < accuracy
}
```
#### Step 3: Render AI in UI
**Already handled by 9.1 and 9.2** - Since AI opponents are in `state.players`, they'll render automatically as ghost trains/lanes!
**Checklist**:
- [ ] Implement AI population in validateStartGame
- [ ] Implement updateAIPositions logic
- [ ] Add AI answer timing system
- [ ] Add AI personality behaviors
- [ ] Test with 1 human + 2 AI
- [ ] Test with 2 human + 1 AI
- [ ] Verify AI appears in results screen
- [ ] Verify AI doesn't dominate human players
---
### 9.6 Event Feed (Optional Polish)
**Priority**: LOW (nice to have)
**File**: `src/app/arcade/complement-race/components/EventFeed.tsx` (NEW)
**Implementation** (Est: 3-4 hours):
```typescript
// NEW FILE: EventFeed.tsx
interface GameEvent {
id: string
type: 'passenger-claimed' | 'passenger-delivered' | 'wrong-answer' | 'overtake'
playerId: string
playerName: string
playerColor: string
timestamp: number
data?: any
}
export function EventFeed() {
const [events, setEvents] = useState<GameEvent[]>([])
// Listen for game events
useEffect(() => {
// Subscribe to validator broadcasts
socket.on('game:event', (event: GameEvent) => {
setEvents((prev) => [event, ...prev].slice(0, 10)) // Keep last 10
})
}, [])
return (
<div className="event-feed">
{events.map((event) => (
<EventItem key={event.id} event={event} />
))}
</div>
)
}
```
**Checklist**:
- [ ] Create EventFeed component
- [ ] Update Validator to emit events
- [ ] Add event types (claim, deliver, overtake)
- [ ] Position feed in UI (corner overlay)
- [ ] Auto-dismiss old events
- [ ] Test with multiple players
---
## Phase 9 Summary
**Total Estimated Time**: 15-20 hours
**Priority Breakdown**:
- 🚨 **HIGH** (8-9 hours): Ghost trains, multi-lane track, results screen
- ⚠️ **MEDIUM** (8-12 hours): Lobby system, AI opponents
-**LOW** (3-4 hours): Event feed
**Completion Criteria**:
- [ ] Can see all players' trains/positions in real-time
- [ ] Multiplayer leaderboard shows all players
- [ ] Lobby shows player list with ready indicators
- [ ] AI opponents appear and compete
- [ ] All animations smooth with multiple players
- [ ] Zero visual glitches with 4 players
**Once Phase 9 is complete**:
- Multiplayer will be FULLY functional
- Overall implementation: 100% complete
- Ready for Phase 8 (Testing & Validation)
---
## Implementation Order
### Priority 1: Foundation (Days 1-2)
### Priority 1: Foundation (COMPLETE)
1. ✓ Define ComplementRaceGameConfig
2. ✓ Disable debug logging
3. ✓ Create ComplementRaceValidator skeleton
4. ✓ Register in modular system
### Priority 2: Core Multiplayer (Days 3-5)
### Priority 2: Core Multiplayer (COMPLETE)
5. ✓ Implement validator methods
6. ✓ Socket server integration
7. ✓ Create RoomComplementRaceProvider
7. ✓ Create RoomComplementRaceProvider (State Adapter Pattern)
8. ✓ Update arcade room store
### Priority 3: UI Updates (Days 6-8)
9. ✓ Add lobby/waiting phase
10. ✓ Update track visualization for multiplayer
11.Update settings UI
12.Update results screen
### Priority 3: Basic UI Integration (COMPLETE)
9. ✓ Add navigation bar (PageWithNav)
10. ✓ Update settings UI
11.Config persistence
12.Registry integration
### Priority 4: Polish & Test (Days 9-10)
13. ✓ Write tests
14. ✓ Manual testing
15. ✓ Bug fixes
16. ✓ Performance optimization
### 🚨 Priority 4: Multiplayer Visuals (CRITICAL - NEXT)
13. [ ] Ghost trains (Sprint Mode)
14. [ ] Multi-lane track (Practice Mode)
15. [ ] Multiplayer results screen
16. [ ] Visual lobby with ready checks
17. [ ] AI opponent display
### Priority 5: Testing & Polish (FINAL)
18. [ ] Write tests (unit, integration, E2E)
19. [ ] Manual testing with 2-4 players
20. [ ] Bug fixes
21. [ ] Performance optimization
22. [ ] Event feed (optional)
---
@@ -682,29 +1405,61 @@ Use these as architectural reference:
## Success Criteria
- [ ] Complement Race appears in arcade room game selector
- [ ] Can create room with complement-race
- [ ] Multiple players can join and see each other
- [ ] Settings persist across page refreshes
- [ ] Real-time race progress updates work
- [ ] All three modes work in multiplayer
- [ ] AI opponents work with human players
### ✅ Backend & Infrastructure (COMPLETE)
- [x] Complement Race appears in arcade room game selector
- [x] Can create room with complement-race
- [x] Settings persist across page refreshes
- [x] Socket server integration working
- [x] Validator handles all game logic
- [x] Zero TypeScript errors
- [x] Pre-commit checks pass
### ⚠️ Multiplayer Visuals (IN PROGRESS - Phase 9)
- [ ] **Sprint Mode**: Can see other players' trains (ghost effect)
- [ ] **Practice Mode**: Multi-lane track shows all players
- [ ] **Survival Mode**: Circular track with multiple players
- [ ] Real-time position updates visible on screen
- [ ] Multiplayer results screen shows full leaderboard
- [ ] Visual lobby with player list and ready indicators
- [ ] AI opponents visible in all game modes
### Testing & Polish (PENDING)
- [ ] 2-player multiplayer test (all 3 modes)
- [ ] 4-player multiplayer test (all 3 modes)
- [ ] AI + human players test
- [ ] Single-player mode still works (backward compat)
- [ ] All animations and sounds intact
- [ ] Zero TypeScript errors
- [ ] Pre-commit checks pass
- [ ] No console errors in production
- [ ] Smooth performance with 4 players
- [ ] Event feed for competitive tension (optional)
### Current Status: 70% Complete
**What Works**: Backend, state management, config persistence, navigation
**What's Missing**: Multiplayer visualization (ghost trains, multi-lane tracks, lobby UI)
---
## Next Steps
1. Start with Phase 1: Configuration & Types
2. Move to Phase 2: Validator skeleton
3. Test each phase before moving to next
4. Deploy to staging environment early
5. Get user feedback on multiplayer mechanics
**Immediate Priority**: Phase 9 - Multiplayer Visual Features
### Quick Wins (Do These First)
1. **Ghost Trains** (2-3 hours) - Make Sprint mode multiplayer visible
2. **Multi-Lane Track** (3-4 hours) - Make Practice mode multiplayer visible
3. **Results Screen** (1-2 hours) - Show full leaderboard
### After Quick Wins
4. **Visual Lobby** (2-3 hours) - Add ready check system
5. **AI Opponents** (4-6 hours) - Populate and display AI players
### Then Testing
6. Manual testing with 2+ players
7. Bug fixes and polish
8. Unit/integration tests
9. Performance optimization
---
**Let's ship it! 🚀**
**Current State**: Backend is rock-solid. Now we need to make multiplayer **visible** to players! 🎮
**Let's complete multiplayer support! 🚀**

View File

@@ -0,0 +1,508 @@
# Complement Race Multiplayer Implementation Review
**Date**: 2025-10-16
**Reviewer**: Comprehensive analysis comparing migration plan vs actual implementation
---
## Executive Summary
**Core Architecture**: CORRECT - Uses proper useArcadeSession pattern
**Validator Implementation**: COMPLETE - All game logic implemented
**State Management**: CORRECT - Proper state adapter for UI compatibility
⚠️ **Multiplayer Features**: PARTIALLY IMPLEMENTED - Core structure present, some features need completion
**Visual Multiplayer**: MISSING - Ghost trains, multi-lane tracks not yet implemented
**Overall Status**: **70% Complete** - Solid foundation, needs visual multiplayer features
---
## Phase-by-Phase Assessment
### Phase 1: Configuration & Type System ✅ COMPLETE
**Plan Requirements**:
- Define ComplementRaceGameConfig
- Disable debug logging
- Set up type system
**Actual Implementation**:
```typescript
// ✅ CORRECT: Full config interface in types.ts
export interface ComplementRaceConfig {
style: 'practice' | 'sprint' | 'survival'
mode: 'friends5' | 'friends10' | 'mixed'
complementDisplay: 'number' | 'abacus' | 'random'
timeoutSetting: 'preschool' | ... | 'expert'
enableAI: boolean
aiOpponentCount: number
maxPlayers: number
routeDuration: number
enablePassengers: boolean
passengerCount: number
maxConcurrentPassengers: number
raceGoal: number
winCondition: 'route-based' | 'score-based' | 'time-based'
routeCount: number
targetScore: number
timeLimit: number
}
```
**Debug logging disabled** (DEBUG_PASSENGER_BOARDING = false)
**DEFAULT_COMPLEMENT_RACE_CONFIG defined** in game-configs.ts
**All types properly defined** in types.ts
**Grade**: ✅ A+ - Exceeds requirements
---
### Phase 2: Validator Implementation ✅ COMPLETE
**Plan Requirements**:
- Create ComplementRaceValidator class
- Implement all move validation methods
- Handle scoring, questions, and game state
**Actual Implementation**:
**✅ All Required Methods Implemented**:
- `validateStartGame` - Initialize multiplayer game
- `validateSubmitAnswer` - Validate answers, update scores
- `validateClaimPassenger` - Sprint mode passenger pickup
- `validateDeliverPassenger` - Sprint mode passenger delivery
- `validateSetReady` - Lobby ready system
- `validateSetConfig` - Host-only config changes
- `validateStartNewRoute` - Route transitions
- `validateNextQuestion` - Generate new questions
- `validateEndGame` - Finish game
- `validatePlayAgain` - Restart
**✅ Helper Methods**:
- `generateQuestion` - Random question generation
- `calculateAnswerScore` - Scoring with speed/streak bonuses
- `generatePassengers` - Sprint mode passenger spawning
- `checkWinCondition` - All three win conditions (practice, sprint, survival)
- `calculateLeaderboard` - Sort players by score
**✅ State Structure** matches plan:
```typescript
interface ComplementRaceState {
config: ComplementRaceConfig
gamePhase: 'setup' | 'lobby' | 'countdown' | 'playing' | 'results'
activePlayers: string[]
playerMetadata: Record<string, {...}>
players: Record<playerId, PlayerState>
currentQuestions: Record<playerId, ComplementQuestion>
passengers: Passenger[]
stations: Station[]
// ... timing, race state, etc.
}
```
**Grade**: ✅ A - Fully functional
---
### Phase 3: Socket Server Integration ✅ COMPLETE
**Plan Requirements**:
- Register in validators.ts
- Socket event handling
- Real-time synchronization
**Actual Implementation**:
**Registered in validators.ts**:
```typescript
import { complementRaceValidator } from '@/arcade-games/complement-race/Validator'
export const VALIDATORS = {
matching: matchingGameValidator,
'number-guesser': numberGuesserValidator,
'complement-race': complementRaceValidator, // ✅ CORRECT
}
```
**Registered in game-registry.ts**:
```typescript
import { complementRaceGame } from '@/arcade-games/complement-race'
const GAME_REGISTRY = {
matching: matchingGame,
'number-guesser': numberGuesserGame,
'complement-race': complementRaceGame, // ✅ CORRECT
}
```
**Uses standard useArcadeSession pattern** - Socket integration automatic via SDK
**Grade**: ✅ A - Proper integration
---
### Phase 4: Room Provider & Configuration ✅ COMPLETE (with adaptation)
**Plan Requirement**: Create RoomComplementRaceProvider with socket sync
**Actual Implementation**: **State Adapter Pattern** (Better Solution!)
Instead of creating a separate RoomProvider, we:
1. ✅ Used standard **useArcadeSession** pattern in Provider.tsx
2. ✅ Created **state transformation layer** to bridge multiplayer ↔ single-player UI
3. ✅ Preserved ALL existing UI components without changes
4. ✅ Config merging from roomData works correctly
**Key Innovation**:
```typescript
// Transform multiplayer state to look like single-player state
const compatibleState = useMemo((): CompatibleGameState => {
const localPlayer = localPlayerId ? multiplayerState.players[localPlayerId] : null
return {
// Extract local player's data
currentQuestion: multiplayerState.currentQuestions[localPlayerId],
score: localPlayer?.score || 0,
streak: localPlayer?.streak || 0,
// ... etc
}
}, [multiplayerState, localPlayerId])
```
This is **better than the plan** because:
- No code duplication
- Reuses existing components
- Clean separation of concerns
- Easy to maintain
**Grade**: ✅ A+ - Superior solution
---
### Phase 5: Multiplayer Game Logic ⚠️ PARTIALLY COMPLETE
**Plan Requirements** vs **Implementation**:
#### 5.1 Sprint Mode: Passenger Rush ✅ IMPLEMENTED
- ✅ Shared passenger pool (all players see same passengers)
- ✅ First-come-first-served claiming (`claimedBy` field)
- ✅ Delivery points (10 regular, 20 urgent)
- ✅ Capacity limits (maxConcurrentPassengers)
-**MISSING**: Ghost train visualization (30-40% opacity)
-**MISSING**: Real-time "race for passenger" alerts
**Status**: **Server logic complete, visual features missing**
#### 5.2 Practice Mode: Simultaneous Questions ⚠️ NEEDS WORK
- ✅ Question generation per player works
- ✅ Answer validation works
- ✅ Position tracking works
-**MISSING**: Multi-lane track visualization
-**MISSING**: "First correct answer" bonus logic
-**MISSING**: Visual feedback for other players answering
**Status**: **Backend works, frontend needs multiplayer UI**
#### 5.3 Survival Mode ⚠️ NEEDS WORK
- ✅ Position/lap tracking logic exists
-**MISSING**: Circular track with multiple players
-**MISSING**: Lap counter display
-**MISSING**: Time limit enforcement
**Status**: **Basic structure, needs multiplayer visuals**
#### 5.4 AI Opponent Scaling ❌ NOT IMPLEMENTED
- ❌ AI opponents defined in types but not populated
- ❌ No AI update logic in validator
-`aiOpponents` array stays empty
**Status**: **Needs implementation**
#### 5.5 Live Updates & Broadcasts ❌ NOT IMPLEMENTED
- ❌ No event feed component
- ❌ No "race for passenger" alerts
- ❌ No live leaderboard overlay
- ❌ No player action announcements
**Status**: **Needs implementation**
**Phase 5 Grade**: ⚠️ C+ - Core logic works, visual features missing
---
### Phase 6: UI Updates for Multiplayer ❌ MOSTLY MISSING
**Plan Requirements** vs **Implementation**:
#### 6.1 Track Visualization ❌ NOT UPDATED
- ❌ Practice: No multi-lane track (still shows single player)
- ❌ Sprint: No ghost trains (only local train visible)
- ❌ Survival: No multi-player circular track
**Current State**: UI still shows **single-player view only**
#### 6.2 Settings UI ✅ COMPLETE
- ✅ GameControls.tsx has all settings
- ✅ Max players, AI settings, game mode all configurable
- ✅ Settings persist via arcade room store
#### 6.3 Lobby/Waiting Room ⚠️ PARTIAL
- ⚠️ Uses "controls" phase as lobby (functional but not ideal)
- ❌ No visual "ready check" system
- ❌ No player list with ready indicators
- ❌ Auto-starts game immediately instead of countdown
**Should Add**: Proper lobby phase with visual ready checks
#### 6.4 Results Screen ⚠️ PARTIAL
- ✅ GameResults.tsx exists
- ❌ No multiplayer leaderboard (still shows single-player stats)
- ❌ No per-player breakdown
- ❌ No "Play Again" for room
**Phase 6 Grade**: ❌ D - Major UI work needed
---
### Phase 7: Registry & Routing ✅ COMPLETE
**Plan Requirements**:
- Update game registry
- Update validators
- Update routing
**Actual Implementation**:
- ✅ Registered in validators.ts
- ✅ Registered in game-registry.ts
- ✅ Registered in game-configs.ts
- ✅ defineGame() properly exports modular game
- ✅ GameComponent wrapper with PageWithNav
- ✅ GameSelector.tsx shows game (maxPlayers: 4)
**Grade**: ✅ A - Fully integrated
---
### Phase 8: Testing & Validation ❌ NOT DONE
All testing checkboxes remain unchecked:
- [ ] Unit tests
- [ ] Integration tests
- [ ] E2E tests
- [ ] Manual testing checklist
**Grade**: ❌ F - No tests yet
---
## Critical Gaps Analysis
### 🚨 HIGH PRIORITY (Breaks Multiplayer Experience)
1. **Ghost Train Visualization** (Sprint Mode)
- **What's Missing**: Other players' trains not visible
- **Impact**: Can't see opponents, ruins competitive feel
- **Where to Fix**: `SteamTrainJourney.tsx` component
- **How**: Render semi-transparent trains for other players using `state.players`
2. **Multi-Lane Track** (Practice Mode)
- **What's Missing**: Only shows single lane
- **Impact**: Players can't see each other racing
- **Where to Fix**: `LinearTrack.tsx` component
- **How**: Stack 2-4 lanes vertically, render player in each
3. **Real-time Position Updates**
- **What's Missing**: Player positions update but UI doesn't reflect it
- **Impact**: Appears like single-player game
- **Where to Fix**: Track components need to read `state.players[playerId].position`
### ⚠️ MEDIUM PRIORITY (Reduces Polish)
4. **AI Opponents Missing**
- **What's Missing**: aiOpponents array never populated
- **Impact**: Can't play solo with AI in multiplayer mode
- **Where to Fix**: Validator needs AI update logic
5. **Lobby/Ready System**
- **What's Missing**: Visual ready check before game starts
- **Impact**: Game starts immediately, no coordination
- **Where to Fix**: Add GameLobby.tsx component
6. **Multiplayer Results Screen**
- **What's Missing**: Leaderboard with all players
- **Impact**: Can't see who won in multiplayer
- **Where to Fix**: `GameResults.tsx` needs multiplayer mode
### ✅ LOW PRIORITY (Nice to Have)
7. **Event Feed** - Live action announcements
8. **Race Alerts** - "Player 2 is catching up!" notifications
9. **Spectator Mode** - Watch after finishing
---
## Architectural Correctness Review
### ✅ What We Got RIGHT
1. **State Adapter Pattern****BRILLIANT SOLUTION**
- Preserves existing UI without rewrite
- Clean separation: multiplayer state ↔ single-player UI
- Easy to maintain and extend
- Better than migration plan's suggestion
2. **Validator Implementation****SOLID**
- Comprehensive move validation
- Proper win condition checks
- Passenger management logic correct
- Scoring system matches requirements
3. **Type Safety****EXCELLENT**
- Full TypeScript coverage
- Proper interfaces for all entities
- No `any` types (except necessary places)
4. **Registry Integration****PERFECT**
- Follows existing patterns
- Properly registered everywhere
- defineGame() usage correct
5. **Config Persistence****WORKS**
- Room-based config saving
- Merge with defaults
- All settings persist
### ⚠️ What Needs ATTENTION
1. **Multiplayer UI** - Currently shows only local player
2. **AI Integration** - Logic missing for AI opponents
3. **Lobby System** - No visual ready check
4. **Testing** - Zero test coverage
---
## Success Criteria Checklist
From migration plan's "Success Criteria":
- ✅ Complement Race appears in arcade room game selector
- ✅ Can create room with complement-race
- ⚠️ Multiple players can join and see each other (**backend yes, visual no**)
- ✅ Settings persist across page refreshes
- ⚠️ Real-time race progress updates work (**data yes, display no**)
- ❌ All three modes work in multiplayer (**need visual updates**)
- ❌ AI opponents work with human players (**not implemented**)
- ✅ Single-player mode still works (backward compat)
- ✅ All animations and sounds intact
- ✅ Zero TypeScript errors
- ✅ Pre-commit checks pass
- ✅ No console errors in production
**Score**: **9/12 (75%)**
---
## Recommendations
### Immediate Next Steps (To Complete Multiplayer)
1. **Implement Ghost Trains** (2-3 hours)
```typescript
// In SteamTrainJourney.tsx
{Object.entries(state.players).map(([playerId, player]) => {
if (playerId === localPlayerId) return null // Skip local player
return (
<Train
key={playerId}
position={player.position}
color={player.color}
opacity={0.35} // Ghost effect
label={player.name}
/>
)
})}
```
2. **Add Multi-Lane Track** (3-4 hours)
```typescript
// In LinearTrack.tsx
const lanes = Object.values(state.players)
return lanes.map((player, index) => (
<Lane key={player.id} yOffset={index * 100}>
<Player position={player.position} />
</Lane>
))
```
3. **Create GameLobby.tsx** (2-3 hours)
- Show connected players
- Ready checkboxes
- Start when all ready
4. **Update GameResults.tsx** (1-2 hours)
- Show leaderboard from `state.leaderboard`
- Display all player scores
- Highlight winner
### Future Enhancements
5. **AI Opponents** (4-6 hours)
- Implement `updateAIPositions()` in validator
- Update AI positions based on difficulty
- Show AI players in UI
6. **Event Feed** (3-4 hours)
- Create EventFeed component
- Broadcast passenger claims/deliveries
- Show overtakes and milestones
7. **Testing** (8-10 hours)
- Unit tests for validator
- E2E tests for multiplayer flow
- Manual testing checklist
---
## Conclusion
### Overall Grade: **B (70%)**
**Strengths**:
-**Excellent architecture** - State adapter is ingenious
-**Complete backend logic** - Validator fully functional
-**Proper integration** - Follows all patterns correctly
-**Type safety** - Zero TypeScript errors
**Weaknesses**:
-**Missing multiplayer visuals** - Can't see other players
-**No AI opponents** - Can't test solo
-**Minimal lobby** - Auto-starts instead of ready check
-**No tests** - Untested code
### Is Multiplayer Working?
**Backend**: ✅ YES - All server logic functional
**Frontend**: ❌ NO - UI shows single-player only
**Can you play multiplayer?** Technically yes, but you won't see other players on screen. It's like racing blindfolded - your opponent's moves are tracked, but you can't see them.
### What Would Make This Complete?
**Minimum Viable Multiplayer** (8-10 hours of work):
1. Ghost trains in sprint mode
2. Multi-lane tracks in practice mode
3. Multiplayer leaderboard in results
4. Lobby with ready checks
**Full Polish** (20-25 hours total):
- Above + AI opponents
- Above + event feed
- Above + comprehensive testing
---
**Status**: **FOUNDATION SOLID, VISUALS PENDING** 🏗️
The architecture is sound, the hard parts (validator, state management) are done correctly. What remains is "just" UI work to make multiplayer visible to players. The fact that we chose the state adapter pattern means this UI work won't require changing any existing game logic - just rendering multiple players instead of one.
**Verdict**: **Ship-ready for single-player, needs visual work for multiplayer** 🚀

View File

@@ -92,7 +92,9 @@
"Bash(ls:*)",
"Bash(do if [ -f \"$file\" ])",
"Bash(! echo \"$file\")",
"Bash(then sed -i '' \"s|from ''''../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" sed -i '' \"s|from ''''../../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" fi done)"
"Bash(then sed -i '' \"s|from ''''../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" sed -i '' \"s|from ''''../../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" fi done)",
"Bash(pnpm install)",
"Bash(pnpm exec turbo build --filter=@soroban/web)"
],
"deny": [],
"ask": []

View File

@@ -48,7 +48,6 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@react-spring/web": "^10.0.2",
"@soroban/abacus-react": "workspace:*",
"@soroban/client": "workspace:*",
"@soroban/core": "workspace:*",
"@soroban/templates": "workspace:*",
"@tanstack/react-form": "^0.19.0",

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

@@ -17,15 +17,16 @@ interface CircularTrackProps {
export function CircularTrack({ playerProgress, playerLap, aiRacers, aiLaps }: CircularTrackProps) {
const { state, dispatch } = useComplementRace()
const { players } = useGameMode()
const { players, activePlayers } = useGameMode()
const { profile: _profile } = useUserProfile()
const { playSound } = useSoundEffects()
const [celebrationCooldown, setCelebrationCooldown] = useState<Set<string>>(new Set())
// Get the first active player's emoji
const activePlayers = Array.from(players.values()).filter((p) => p.id)
const firstActivePlayer = activePlayers[0]
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
// Get the current user's active local players (consistent with navbar pattern)
const activeLocalPlayers = Array.from(activePlayers)
.map((id) => players.get(id))
.filter((p): p is NonNullable<typeof p> => p !== undefined && p.isLocal !== false)
const playerEmoji = activeLocalPlayers[0]?.emoji ?? '👤'
const [dimensions, setDimensions] = useState({ width: 600, height: 400 })
// Update dimensions on mount and resize
@@ -400,7 +401,11 @@ export function CircularTrack({ playerProgress, playerLap, aiRacers, aiLaps }: C
{activeBubble && (
<div
style={{
transform: `rotate(${-aiPos.angle}deg)`, // Counter-rotate bubble
position: 'absolute',
bottom: '100%', // Position above the AI racer
left: '50%',
transform: `translate(-50%, -15px) rotate(${-aiPos.angle}deg)`, // Offset 15px above, counter-rotate bubble
zIndex: 20, // Above player (10) and AI racers (5)
}}
>
<SpeechBubble

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

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

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,6 +93,7 @@ export function SteamTrainJourney({
currentInput,
}: SteamTrainJourneyProps) {
const { state } = useComplementRace()
const { getSkyGradient, getTimeOfDayPeriod } = useSteamJourney()
const _skyGradient = getSkyGradient()
const period = getTimeOfDayPeriod()
@@ -109,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)
@@ -166,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
@@ -290,23 +400,18 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
// Race mechanics
raceGoal: multiplayerState.config.raceGoal,
timeLimit: multiplayerState.config.timeLimit ?? null,
speedMultiplier: 1.0,
aiRacers: multiplayerState.aiOpponents.map((ai) => ({
id: ai.id,
name: ai.name,
position: ai.position,
speed: ai.speed,
personality: ai.personality,
icon: ai.personality === 'competitive' ? '🏃‍♂️' : '🏃',
lastComment: ai.lastCommentTime,
commentCooldown: 0,
previousPosition: ai.position,
})),
speedMultiplier:
multiplayerState.config.style === 'practice'
? 0.7
: multiplayerState.config.style === 'sprint'
? 0.9
: 1.0, // Base speed multipliers by mode
aiRacers: clientAIRacers, // Use client-side AI state
// Sprint mode specific
momentum: localPlayer?.momentum || 0,
trainPosition: localPlayer?.position || 0,
pressure: localPlayer?.momentum ? Math.min(100, localPlayer.momentum + 10) : 0,
// Sprint mode specific (all client-side for smooth movement)
momentum: clientMomentum, // Client-only state with continuous decay
trainPosition: clientPosition, // Client-calculated from momentum
pressure: clientPressure, // Client-calculated from momentum (0-150 PSI)
elapsedTime: multiplayerState.gameStartTime ? Date.now() - multiplayerState.gameStartTime : 0,
lastCorrectAnswerTime: localPlayer?.lastAnswerTime || Date.now(),
currentRoute: multiplayerState.currentRoute,
@@ -328,7 +433,131 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
adaptiveFeedback: localUIState.adaptiveFeedback,
difficultyTracker: localUIState.difficultyTracker,
}
}, [multiplayerState, localPlayerId, localUIState])
}, [
multiplayerState,
localPlayerId,
localUIState,
clientPosition,
clientPressure,
clientMomentum,
clientAIRacers,
])
// 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 = ['Swift AI', 'Math Bot', 'Speed Demon', 'Brain Bot']
const personalities: Array<'competitive' | 'analytical'> = ['competitive', 'analytical']
const newAI = []
for (let i = 0; i < Math.min(count, aiNames.length); i++) {
// Use original balanced speeds: 0.32 for Swift AI, 0.2 for Math Bot
const baseSpeed = i === 0 ? 0.32 : 0.2
newAI.push({
id: `ai-${i}`,
name: aiNames[i],
personality: personalities[i % personalities.length] as 'competitive' | 'analytical',
position: 0,
speed: baseSpeed, // Balanced speed from original single-player version
icon: personalities[i % personalities.length] === 'competitive' ? '🏃‍♂️' : '🏃',
lastComment: 0,
commentCooldown: 0,
previousPosition: 0,
})
}
setClientAIRacers(newAI)
}
} else if (!compatibleState.isGameActive) {
// Clear AI when game ends
setClientAIRacers([])
}
}, [
compatibleState.isGameActive,
multiplayerState.config.enableAI,
multiplayerState.config.aiOpponentCount,
clientAIRacers.length,
])
// Main client-side game loop: momentum decay and position calculation
useEffect(() => {
if (!compatibleState.isGameActive || compatibleState.style !== 'sprint') return
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(() => {
@@ -374,7 +603,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
@@ -386,7 +615,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]
@@ -482,8 +711,6 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
// Compatibility dispatch function for existing UI components
const dispatch = useCallback(
(action: { type: string; [key: string]: any }) => {
console.log('[ComplementRaceProvider] dispatch called (compatibility layer):', action.type)
// Map old reducer actions to new action creators
switch (action.type) {
case 'START_COUNTDOWN':
@@ -497,6 +724,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
}
break
case 'NEXT_QUESTION':
setLocalUIState((prev) => ({ ...prev, currentInput: '' }))
nextQuestion()
break
case 'END_RACE':
@@ -529,8 +757,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':
@@ -538,6 +766,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 || '' }))
@@ -560,6 +800,18 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
newBubbles.set(action.racerId, action.message)
return { ...prev, activeSpeechBubbles: newBubbles }
})
// Update racer's lastComment time and cooldown to prevent spam
setClientAIRacers((prevRacers) =>
prevRacers.map((racer) =>
racer.id === action.racerId
? {
...racer,
lastComment: Date.now(),
commentCooldown: Math.random() * 4000 + 2000, // 2-6 seconds
}
: racer
)
)
break
}
case 'CLEAR_AI_COMMENT': {
@@ -570,15 +822,55 @@ 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
}
case 'UPDATE_AI_SPEEDS': {
// Update client-side AI speeds (adaptive difficulty)
if (action.racers && Array.isArray(action.racers)) {
setClientAIRacers((prevRacers) =>
prevRacers.map((racer) => {
const update = action.racers.find(
(r: { id: string; speed: number }) => r.id === racer.id
)
return update
? {
...racer,
speed: update.speed,
}
: racer
})
)
}
break
}
case 'UPDATE_DIFFICULTY_TRACKER': {
// Update local difficulty tracker state
setLocalUIState((prev) => ({ ...prev, difficultyTracker: action.tracker }))
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':
@@ -598,9 +890,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,
@@ -616,6 +927,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,12 +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
if (correct) {
updatedPlayer.momentum = Math.min(100, player.momentum + 15)
} else {
updatedPlayer.momentum = Math.max(0, player.momentum - 10)
}
// Sprint: All momentum/position handled client-side for smooth 20fps movement
// Server only tracks scoring, passengers, and game progression
// No server-side position updates needed
} else if (state.config.style === 'survival') {
// Survival: Always move forward, speed based on accuracy
const moveDistance = correct ? 5 : 2
@@ -391,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' }
@@ -418,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 = {
@@ -481,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
@@ -522,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
}
}
@@ -535,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 }
@@ -661,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)) {
@@ -699,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
@@ -750,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

@@ -0,0 +1,59 @@
/**
* Complement Race Game Component with Navigation
* Wraps the existing ComplementRaceGame with PageWithNav for arcade play
*/
'use client'
import { useRouter } from 'next/navigation'
import { PageWithNav } from '@/components/PageWithNav'
import { ComplementRaceGame } from '@/app/arcade/complement-race/components/ComplementRaceGame'
import { useComplementRace } from '../Provider'
export function GameComponent() {
const router = useRouter()
const { state, exitSession, goToSetup } = useComplementRace()
// Get display name based on style
const getNavTitle = () => {
switch (state.style) {
case 'sprint':
return 'Steam Sprint'
case 'survival':
return 'Endless Circuit'
case 'practice':
default:
return 'Complement Race'
}
}
// Get emoji based on style
const getNavEmoji = () => {
switch (state.style) {
case 'sprint':
return '🚂'
case 'survival':
return '♾️'
case 'practice':
default:
return '🏁'
}
}
return (
<PageWithNav
navTitle={getNavTitle()}
navEmoji={getNavEmoji()}
emphasizePlayerSelection={state.gamePhase === 'controls'}
onExitSession={() => {
exitSession()
router.push('/arcade')
}}
onNewGame={() => {
goToSetup()
}}
>
<ComplementRaceGame />
</PageWithNav>
)
}

View File

@@ -7,7 +7,7 @@ import { defineGame } from '@/lib/arcade/game-sdk'
import type { GameManifest } from '@/lib/arcade/game-sdk'
import { complementRaceValidator } from './Validator'
import { ComplementRaceProvider } from './Provider'
import { ComplementRaceGame } from '@/app/arcade/complement-race/components/ComplementRaceGame'
import { GameComponent } from './components/GameComponent'
import type { ComplementRaceConfig, ComplementRaceState, ComplementRaceMove } from './types'
// Game manifest
@@ -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,
@@ -69,7 +69,7 @@ export const complementRaceGame = defineGame<
>({
manifest,
Provider: ComplementRaceProvider,
GameComponent: ComplementRaceGame,
GameComponent,
validator: complementRaceValidator,
defaultConfig,
validateConfig: validateComplementRaceConfig,

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

@@ -1,199 +0,0 @@
/**
* Math Sprint Provider
*
* Context provider for Math Sprint game state management.
* Demonstrates free-for-all gameplay with TEAM_MOVE pattern.
*/
'use client'
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
import {
buildPlayerMetadata,
useArcadeSession,
useGameMode,
useRoomData,
useUpdateGameConfig,
useViewerId,
} from '@/lib/arcade/game-sdk'
import { TEAM_MOVE } from '@/lib/arcade/validation/types'
import type { Difficulty, MathSprintState } from './types'
/**
* Context value provided to child components
*/
interface MathSprintContextValue {
state: MathSprintState
lastError: string | null
startGame: () => void
submitAnswer: (answer: number) => void
nextQuestion: () => void
resetGame: () => void
setConfig: (field: 'difficulty' | 'questionsPerRound' | 'timePerQuestion', value: any) => void
clearError: () => void
exitSession: () => void
}
const MathSprintContext = createContext<MathSprintContextValue | null>(null)
/**
* Hook to access Math Sprint context
*/
export function useMathSprint() {
const context = useContext(MathSprintContext)
if (!context) {
throw new Error('useMathSprint must be used within MathSprintProvider')
}
return context
}
/**
* Math Sprint Provider Component
*/
export function MathSprintProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
const { activePlayers: activePlayerIds, players } = useGameMode()
const { mutate: updateGameConfig } = useUpdateGameConfig()
// Get active players as array (keep Set iteration order)
const activePlayers = Array.from(activePlayerIds)
// Merge saved config from room with defaults
const gameConfig = useMemo(() => {
const allGameConfigs = roomData?.gameConfig as Record<string, unknown> | null | undefined
const savedConfig = allGameConfigs?.['math-sprint'] as Record<string, unknown> | undefined
return {
difficulty: (savedConfig?.difficulty as Difficulty) || 'medium',
questionsPerRound: (savedConfig?.questionsPerRound as number) || 10,
timePerQuestion: (savedConfig?.timePerQuestion as number) || 30,
}
}, [roomData?.gameConfig])
// Initial state with merged config
const initialState = useMemo<MathSprintState>(
() => ({
gamePhase: 'setup',
activePlayers: [],
playerMetadata: {},
difficulty: gameConfig.difficulty,
questionsPerRound: gameConfig.questionsPerRound,
timePerQuestion: gameConfig.timePerQuestion,
currentQuestionIndex: 0,
questions: [],
scores: {},
correctAnswersCount: {},
answers: [],
questionStartTime: 0,
questionAnswered: false,
winnerId: null,
}),
[gameConfig]
)
// Arcade session integration
const { state, sendMove, exitSession, lastError, clearError } = useArcadeSession<MathSprintState>(
{
userId: viewerId || '',
roomId: roomData?.id,
initialState,
applyMove: (state) => state, // Server handles all state updates
}
)
// Action: Start game
const startGame = useCallback(() => {
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined)
sendMove({
type: 'START_GAME',
playerId: TEAM_MOVE, // Free-for-all: no specific turn owner
userId: viewerId || '',
data: { activePlayers, playerMetadata },
})
}, [activePlayers, players, viewerId, sendMove])
// Action: Submit answer
const submitAnswer = useCallback(
(answer: number) => {
// Find this user's player ID from game state
const myPlayerId = state.activePlayers.find((pid) => {
return state.playerMetadata[pid]?.userId === viewerId
})
if (!myPlayerId) {
console.error('[MathSprint] No player found for current user')
return
}
sendMove({
type: 'SUBMIT_ANSWER',
playerId: myPlayerId, // Specific player answering
userId: viewerId || '',
data: { answer },
})
},
[state.activePlayers, state.playerMetadata, viewerId, sendMove]
)
// Action: Next question
const nextQuestion = useCallback(() => {
sendMove({
type: 'NEXT_QUESTION',
playerId: TEAM_MOVE, // Any player can advance
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
// Action: Reset game
const resetGame = useCallback(() => {
sendMove({
type: 'RESET_GAME',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
// Action: Set config
const setConfig = useCallback(
(field: 'difficulty' | 'questionsPerRound' | 'timePerQuestion', value: any) => {
sendMove({
type: 'SET_CONFIG',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: { field, value },
})
// Persist to database for next session
if (roomData?.id) {
updateGameConfig({
roomId: roomData.id,
gameConfig: {
...roomData.gameConfig,
'math-sprint': {
...(roomData.gameConfig?.['math-sprint'] || {}),
[field]: value,
},
},
})
}
},
[viewerId, sendMove, updateGameConfig, roomData]
)
const contextValue: MathSprintContextValue = {
state,
lastError,
startGame,
submitAnswer,
nextQuestion,
resetGame,
setConfig,
clearError,
exitSession,
}
return <MathSprintContext.Provider value={contextValue}>{children}</MathSprintContext.Provider>
}

View File

@@ -1,329 +0,0 @@
/**
* Math Sprint Validator
*
* Server-side validation for Math Sprint game.
* Generates questions, validates answers, awards points.
*/
import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk'
import type {
Difficulty,
MathSprintConfig,
MathSprintMove,
MathSprintState,
Operation,
Question,
} from './types'
export class MathSprintValidator implements GameValidator<MathSprintState, MathSprintMove> {
/**
* Validate a game move
*/
validateMove(
state: MathSprintState,
move: MathSprintMove,
context?: { userId?: string }
): ValidationResult {
switch (move.type) {
case 'START_GAME':
return this.validateStartGame(state, move.data.activePlayers, move.data.playerMetadata)
case 'SUBMIT_ANSWER':
return this.validateSubmitAnswer(
state,
move.playerId,
Number(move.data.answer),
move.timestamp
)
case 'NEXT_QUESTION':
return this.validateNextQuestion(state)
case 'RESET_GAME':
return this.validateResetGame(state)
case 'SET_CONFIG':
return this.validateSetConfig(state, move.data.field, move.data.value)
default:
return { valid: false, error: 'Unknown move type' }
}
}
/**
* Check if game is complete
*/
isGameComplete(state: MathSprintState): boolean {
return state.gamePhase === 'results'
}
/**
* Get initial state for new game
*/
getInitialState(config: unknown): MathSprintState {
const { difficulty, questionsPerRound, timePerQuestion } = config as MathSprintConfig
return {
gamePhase: 'setup',
activePlayers: [],
playerMetadata: {},
difficulty: difficulty || 'medium',
questionsPerRound: questionsPerRound || 10,
timePerQuestion: timePerQuestion || 30,
currentQuestionIndex: 0,
questions: [],
scores: {},
correctAnswersCount: {},
answers: [],
questionStartTime: 0,
questionAnswered: false,
winnerId: null,
}
}
// ============================================================================
// Validation Methods
// ============================================================================
private validateStartGame(
state: MathSprintState,
activePlayers: string[],
playerMetadata: Record<string, any>
): ValidationResult {
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Game already started' }
}
if (activePlayers.length < 2) {
return { valid: false, error: 'Need at least 2 players' }
}
// Generate questions
const questions = this.generateQuestions(state.difficulty, state.questionsPerRound)
const newState: MathSprintState = {
...state,
gamePhase: 'playing',
activePlayers,
playerMetadata,
questions,
currentQuestionIndex: 0,
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
correctAnswersCount: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
answers: [],
questionStartTime: Date.now(),
questionAnswered: false,
winnerId: null,
}
return { valid: true, newState }
}
private validateSubmitAnswer(
state: MathSprintState,
playerId: string,
answer: number,
timestamp: number
): ValidationResult {
if (state.gamePhase !== 'playing') {
return { valid: false, error: 'Game not in progress' }
}
if (!state.activePlayers.includes(playerId)) {
return { valid: false, error: 'Player not in game' }
}
if (state.questionAnswered) {
return { valid: false, error: 'Question already answered correctly' }
}
// Check if player already answered this question
const alreadyAnswered = state.answers.some((a) => a.playerId === playerId)
if (alreadyAnswered) {
return { valid: false, error: 'You already answered this question' }
}
const currentQuestion = state.questions[state.currentQuestionIndex]
const correct = answer === currentQuestion.correctAnswer
const answerRecord = {
playerId,
answer,
timestamp,
correct,
}
const newAnswers = [...state.answers, answerRecord]
let newState = { ...state, answers: newAnswers }
// If correct, award points and mark question as answered
if (correct) {
newState = {
...newState,
questionAnswered: true,
winnerId: playerId,
scores: {
...state.scores,
[playerId]: state.scores[playerId] + 10,
},
correctAnswersCount: {
...state.correctAnswersCount,
[playerId]: state.correctAnswersCount[playerId] + 1,
},
}
}
return { valid: true, newState }
}
private validateNextQuestion(state: MathSprintState): ValidationResult {
if (state.gamePhase !== 'playing') {
return { valid: false, error: 'Game not in progress' }
}
if (!state.questionAnswered) {
return { valid: false, error: 'Current question not answered yet' }
}
const isLastQuestion = state.currentQuestionIndex >= state.questions.length - 1
if (isLastQuestion) {
// Game complete, go to results
const newState: MathSprintState = {
...state,
gamePhase: 'results',
}
return { valid: true, newState }
}
// Move to next question
const newState: MathSprintState = {
...state,
currentQuestionIndex: state.currentQuestionIndex + 1,
answers: [],
questionStartTime: Date.now(),
questionAnswered: false,
winnerId: null,
}
return { valid: true, newState }
}
private validateResetGame(state: MathSprintState): ValidationResult {
const newState = this.getInitialState({
difficulty: state.difficulty,
questionsPerRound: state.questionsPerRound,
timePerQuestion: state.timePerQuestion,
})
return { valid: true, newState }
}
private validateSetConfig(state: MathSprintState, field: string, value: any): ValidationResult {
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Cannot change config during game' }
}
const newState = {
...state,
[field]: value,
}
return { valid: true, newState }
}
// ============================================================================
// Question Generation
// ============================================================================
private generateQuestions(difficulty: Difficulty, count: number): Question[] {
const questions: Question[] = []
for (let i = 0; i < count; i++) {
const operation = this.randomOperation()
const question = this.generateQuestion(difficulty, operation, `q-${i}`)
questions.push(question)
}
return questions
}
private generateQuestion(difficulty: Difficulty, operation: Operation, id: string): Question {
let operand1: number
let operand2: number
let correctAnswer: number
switch (difficulty) {
case 'easy':
operand1 = this.randomInt(1, 10)
operand2 = this.randomInt(1, 10)
break
case 'medium':
operand1 = this.randomInt(10, 50)
operand2 = this.randomInt(1, 20)
break
case 'hard':
operand1 = this.randomInt(10, 100)
operand2 = this.randomInt(10, 50)
break
}
switch (operation) {
case 'addition':
correctAnswer = operand1 + operand2
break
case 'subtraction':
// Ensure positive result
if (operand1 < operand2) {
;[operand1, operand2] = [operand2, operand1]
}
correctAnswer = operand1 - operand2
break
case 'multiplication':
// Smaller numbers for multiplication
if (difficulty === 'hard') {
operand1 = this.randomInt(2, 20)
operand2 = this.randomInt(2, 12)
} else {
operand1 = this.randomInt(2, 10)
operand2 = this.randomInt(2, 10)
}
correctAnswer = operand1 * operand2
break
}
const operationSymbol = this.getOperationSymbol(operation)
const displayText = `${operand1} ${operationSymbol} ${operand2} = ?`
return {
id,
operand1,
operand2,
operation,
correctAnswer,
displayText,
}
}
private randomOperation(): Operation {
const operations: Operation[] = ['addition', 'subtraction', 'multiplication']
return operations[Math.floor(Math.random() * operations.length)]
}
private randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
private getOperationSymbol(operation: Operation): string {
switch (operation) {
case 'addition':
return '+'
case 'subtraction':
return ''
case 'multiplication':
return '×'
}
}
}
export const mathSprintValidator = new MathSprintValidator()

View File

@@ -1,40 +0,0 @@
/**
* Math Sprint - Game Component
*
* Main wrapper component with navigation and phase routing.
*/
'use client'
import { useRouter } from 'next/navigation'
import { PageWithNav } from '@/components/PageWithNav'
import { useMathSprint } from '../Provider'
import { PlayingPhase } from './PlayingPhase'
import { ResultsPhase } from './ResultsPhase'
import { SetupPhase } from './SetupPhase'
export function GameComponent() {
const router = useRouter()
const { state, exitSession, resetGame } = useMathSprint()
return (
<PageWithNav
navTitle="Math Sprint"
navEmoji="🧮"
emphasizePlayerSelection={state.gamePhase === 'setup'}
// No currentPlayerId - free-for-all game, everyone can act simultaneously
playerScores={state.scores}
onExitSession={() => {
exitSession?.()
router.push('/arcade')
}}
onNewGame={() => {
resetGame()
}}
>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'playing' && <PlayingPhase />}
{state.gamePhase === 'results' && <ResultsPhase />}
</PageWithNav>
)
}

View File

@@ -1,350 +0,0 @@
/**
* Math Sprint - Playing Phase
*
* Main gameplay: show question, accept answers, show feedback.
*/
'use client'
import { useEffect, useState } from 'react'
import { useViewerId } from '@/lib/arcade/game-sdk'
import { css } from '../../../../styled-system/css'
import { useMathSprint } from '../Provider'
export function PlayingPhase() {
const { state, submitAnswer, nextQuestion, lastError, clearError } = useMathSprint()
const { data: viewerId } = useViewerId()
const [inputValue, setInputValue] = useState('')
const currentQuestion = state.questions[state.currentQuestionIndex]
const progress = `${state.currentQuestionIndex + 1} / ${state.questions.length}`
// Find if current user answered
const myPlayerId = Object.keys(state.playerMetadata).find(
(pid) => state.playerMetadata[pid]?.userId === viewerId
)
const myAnswer = state.answers.find((a) => a.playerId === myPlayerId)
// Auto-clear error after 3 seconds
useEffect(() => {
if (lastError) {
const timeout = setTimeout(() => clearError(), 3000)
return () => clearTimeout(timeout)
}
}, [lastError, clearError])
// Clear input after question changes
useEffect(() => {
setInputValue('')
}, [state.currentQuestionIndex])
const handleSubmit = () => {
const answer = Number.parseInt(inputValue, 10)
if (Number.isNaN(answer)) return
submitAnswer(answer)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSubmit()
}
}
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '24px',
maxWidth: '700px',
margin: '0 auto',
padding: '32px 20px',
})}
>
{/* Progress Bar */}
<div
className={css({
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '16px',
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px',
})}
>
<span className={css({ fontSize: 'sm', fontWeight: 'semibold' })}>
Question {progress}
</span>
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
{state.difficulty.charAt(0).toUpperCase() + state.difficulty.slice(1)}
</span>
</div>
<div
className={css({
background: 'gray.200',
height: '8px',
borderRadius: '4px',
overflow: 'hidden',
})}
>
<div
className={css({
background: 'linear-gradient(90deg, #a78bfa, #8b5cf6)',
height: '100%',
borderRadius: '4px',
transition: 'width 0.3s',
})}
style={{
width: `${((state.currentQuestionIndex + 1) / state.questions.length) * 100}%`,
}}
/>
</div>
</div>
{/* Error Banner */}
{lastError && (
<div
className={css({
background: 'linear-gradient(135deg, #fef2f2, #fee2e2)',
border: '2px solid',
borderColor: 'red.300',
borderRadius: '12px',
padding: '12px 16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
<span></span>
<span className={css({ fontSize: 'sm', color: 'red.700' })}>{lastError}</span>
</div>
<button
type="button"
onClick={clearError}
className={css({
fontSize: 'xs',
padding: '4px 8px',
background: 'red.100',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
_hover: { background: 'red.200' },
})}
>
Dismiss
</button>
</div>
)}
{/* Question Display */}
<div
className={css({
background: 'linear-gradient(135deg, #ede9fe, #ddd6fe)',
border: '2px solid',
borderColor: 'purple.300',
borderRadius: '16px',
padding: '48px',
textAlign: 'center',
})}
>
<div
className={css({
fontSize: '4xl',
fontWeight: 'bold',
color: 'purple.700',
fontFamily: 'monospace',
})}
>
{currentQuestion.displayText}
</div>
</div>
{/* Answer Input */}
{!state.questionAnswered && (
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: myAnswer ? 'gray.300' : 'purple.500',
borderRadius: '12px',
padding: '24px',
})}
>
{myAnswer ? (
<div className={css({ textAlign: 'center' })}>
<div
className={css({
fontSize: 'lg',
color: 'gray.600',
marginBottom: '8px',
})}
>
Your answer: <strong>{myAnswer.answer}</strong>
</div>
<div className={css({ fontSize: 'sm', color: 'gray.500' })}>
Waiting for others or correct answer...
</div>
</div>
) : (
<div>
<label
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'semibold',
marginBottom: '8px',
})}
>
Your Answer
</label>
<div className={css({ display: 'flex', gap: '12px' })}>
<input
type="number"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your answer..."
className={css({
flex: 1,
padding: '12px 16px',
fontSize: 'lg',
border: '2px solid',
borderColor: 'gray.300',
borderRadius: '8px',
_focus: {
outline: 'none',
borderColor: 'purple.500',
},
})}
/>
<button
type="button"
onClick={handleSubmit}
disabled={!inputValue}
className={css({
padding: '12px 24px',
fontSize: 'md',
fontWeight: 'semibold',
color: 'white',
background: inputValue ? 'purple.600' : 'gray.400',
border: 'none',
borderRadius: '8px',
cursor: inputValue ? 'pointer' : 'not-allowed',
_hover: {
background: inputValue ? 'purple.700' : 'gray.400',
},
})}
>
Submit
</button>
</div>
</div>
)}
</div>
)}
{/* Winner Display */}
{state.questionAnswered && state.winnerId && (
<div
className={css({
background: 'linear-gradient(135deg, #d1fae5, #a7f3d0)',
border: '2px solid',
borderColor: 'green.400',
borderRadius: '12px',
padding: '24px',
textAlign: 'center',
})}
>
<div className={css({ fontSize: '3xl', marginBottom: '8px' })}>🎉</div>
<div className={css({ fontSize: 'lg', fontWeight: 'bold', color: 'green.700' })}>
{state.playerMetadata[state.winnerId]?.name || 'Someone'} got it right!
</div>
<div className={css({ fontSize: 'md', color: 'green.600', marginTop: '4px' })}>
Answer: {currentQuestion.correctAnswer}
</div>
<button
type="button"
onClick={nextQuestion}
className={css({
marginTop: '16px',
padding: '12px 32px',
fontSize: 'md',
fontWeight: 'semibold',
color: 'white',
background: 'green.600',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
_hover: { background: 'green.700' },
})}
>
Next Question
</button>
</div>
)}
{/* Scoreboard */}
<div
className={css({
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '16px',
})}
>
<h3
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
marginBottom: '12px',
})}
>
Scores
</h3>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '8px' })}>
{Object.entries(state.scores)
.sort(([, a], [, b]) => b - a)
.map(([playerId, score]) => {
const player = state.playerMetadata[playerId]
return (
<div
key={playerId}
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
background: 'gray.50',
borderRadius: '8px',
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
<span className={css({ fontSize: 'xl' })}>{player?.emoji}</span>
<span className={css({ fontSize: 'sm', fontWeight: 'medium' })}>
{player?.name}
</span>
</div>
<span
className={css({ fontSize: 'sm', fontWeight: 'bold', color: 'purple.600' })}
>
{score} pts
</span>
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -1,194 +0,0 @@
/**
* Math Sprint - Results Phase
*
* Show final scores and winner.
*/
'use client'
import { css } from '../../../../styled-system/css'
import { useMathSprint } from '../Provider'
export function ResultsPhase() {
const { state, resetGame } = useMathSprint()
// Sort players by score
const sortedPlayers = Object.entries(state.scores)
.map(([playerId, score]) => ({
playerId,
score,
correct: state.correctAnswersCount[playerId] || 0,
player: state.playerMetadata[playerId],
}))
.sort((a, b) => b.score - a.score)
const winner = sortedPlayers[0]
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '24px',
maxWidth: '600px',
margin: '0 auto',
padding: '32px 20px',
})}
>
{/* Winner Announcement */}
<div
className={css({
background: 'linear-gradient(135deg, #fef3c7, #fde68a)',
border: '2px solid',
borderColor: 'yellow.400',
borderRadius: '16px',
padding: '32px',
textAlign: 'center',
})}
>
<div className={css({ fontSize: '4xl', marginBottom: '12px' })}>🏆</div>
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: 'yellow.800',
marginBottom: '8px',
})}
>
{winner.player?.name} Wins!
</h2>
<div className={css({ fontSize: 'lg', color: 'yellow.700' })}>
{winner.score} points {winner.correct} correct
</div>
</div>
{/* Final Scores */}
<div
className={css({
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '24px',
})}
>
<h3
className={css({
fontSize: 'lg',
fontWeight: 'semibold',
marginBottom: '16px',
})}
>
Final Scores
</h3>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '12px' })}>
{sortedPlayers.map((item, index) => (
<div
key={item.playerId}
className={css({
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '16px',
background: index === 0 ? 'linear-gradient(135deg, #fef3c7, #fde68a)' : 'gray.50',
border: '1px solid',
borderColor: index === 0 ? 'yellow.300' : 'gray.200',
borderRadius: '12px',
})}
>
{/* Rank */}
<div
className={css({
width: '32px',
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: index === 0 ? 'yellow.500' : 'gray.300',
color: index === 0 ? 'white' : 'gray.700',
borderRadius: '50%',
fontWeight: 'bold',
fontSize: 'sm',
})}
>
{index + 1}
</div>
{/* Player Info */}
<div className={css({ flex: 1 })}>
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
<span className={css({ fontSize: 'xl' })}>{item.player?.emoji}</span>
<span className={css({ fontSize: 'md', fontWeight: 'semibold' })}>
{item.player?.name}
</span>
</div>
<div className={css({ fontSize: 'xs', color: 'gray.600', marginTop: '2px' })}>
{item.correct} / {state.questions.length} correct
</div>
</div>
{/* Score */}
<div
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: index === 0 ? 'yellow.700' : 'purple.600',
})}
>
{item.score}
</div>
</div>
))}
</div>
</div>
{/* Stats */}
<div
className={css({
background: 'linear-gradient(135deg, #ede9fe, #ddd6fe)',
border: '1px solid',
borderColor: 'purple.300',
borderRadius: '12px',
padding: '20px',
})}
>
<div className={css({ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' })}>
<div className={css({ textAlign: 'center' })}>
<div className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'purple.700' })}>
{state.questions.length}
</div>
<div className={css({ fontSize: 'sm', color: 'purple.600' })}>Questions</div>
</div>
<div className={css({ textAlign: 'center' })}>
<div className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'purple.700' })}>
{state.difficulty.charAt(0).toUpperCase() + state.difficulty.slice(1)}
</div>
<div className={css({ fontSize: 'sm', color: 'purple.600' })}>Difficulty</div>
</div>
</div>
</div>
{/* Play Again Button */}
<button
type="button"
onClick={resetGame}
className={css({
padding: '14px 28px',
fontSize: 'lg',
fontWeight: 'semibold',
color: 'white',
background: 'purple.600',
border: 'none',
borderRadius: '12px',
cursor: 'pointer',
transition: 'background 0.2s',
_hover: {
background: 'purple.700',
},
})}
>
Play Again
</button>
</div>
)
}

View File

@@ -1,198 +0,0 @@
/**
* Math Sprint - Setup Phase
*
* Configure game settings before starting.
*/
'use client'
import { css } from '../../../../styled-system/css'
import { useMathSprint } from '../Provider'
import type { Difficulty } from '../types'
export function SetupPhase() {
const { state, startGame, setConfig } = useMathSprint()
const handleDifficultyChange = (difficulty: Difficulty) => {
setConfig('difficulty', difficulty)
}
const handleQuestionsChange = (questions: number) => {
setConfig('questionsPerRound', questions)
}
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '24px',
maxWidth: '600px',
margin: '0 auto',
padding: '32px 20px',
})}
>
{/* Game Title */}
<div className={css({ textAlign: 'center' })}>
<h1
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: 'purple.700',
marginBottom: '8px',
})}
>
🧮 Math Sprint
</h1>
<p className={css({ color: 'gray.600' })}>
Race to solve math problems! First correct answer wins points.
</p>
</div>
{/* Settings Card */}
<div
className={css({
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '24px',
})}
>
<h2
className={css({
fontSize: 'lg',
fontWeight: 'semibold',
marginBottom: '16px',
})}
>
Game Settings
</h2>
{/* Difficulty */}
<div className={css({ marginBottom: '20px' })}>
<label
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'medium',
marginBottom: '8px',
})}
>
Difficulty
</label>
<div className={css({ display: 'flex', gap: '8px' })}>
{(['easy', 'medium', 'hard'] as Difficulty[]).map((diff) => (
<button
key={diff}
type="button"
onClick={() => handleDifficultyChange(diff)}
className={css({
flex: 1,
padding: '10px 16px',
borderRadius: '8px',
border: '2px solid',
borderColor: state.difficulty === diff ? 'purple.500' : 'gray.300',
background: state.difficulty === diff ? 'purple.50' : 'white',
color: state.difficulty === diff ? 'purple.700' : 'gray.700',
fontWeight: state.difficulty === diff ? 'semibold' : 'normal',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'purple.400',
},
})}
>
{diff.charAt(0).toUpperCase() + diff.slice(1)}
</button>
))}
</div>
<p className={css({ fontSize: 'xs', color: 'gray.500', marginTop: '4px' })}>
{state.difficulty === 'easy' && 'Numbers 1-10, simple operations'}
{state.difficulty === 'medium' && 'Numbers 1-50, varied operations'}
{state.difficulty === 'hard' && 'Numbers 1-100, harder calculations'}
</p>
</div>
{/* Questions Per Round */}
<div>
<label
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'medium',
marginBottom: '8px',
})}
>
Questions: {state.questionsPerRound}
</label>
<input
type="range"
min="5"
max="20"
step="5"
value={state.questionsPerRound}
onChange={(e) => handleQuestionsChange(Number(e.target.value))}
className={css({
width: '100%',
})}
/>
<div
className={css({ display: 'flex', justifyContent: 'space-between', fontSize: 'xs' })}
>
<span>5</span>
<span>10</span>
<span>15</span>
<span>20</span>
</div>
</div>
</div>
{/* Instructions */}
<div
className={css({
background: 'linear-gradient(135deg, #fef3c7, #fde68a)',
border: '1px solid',
borderColor: 'yellow.300',
borderRadius: '12px',
padding: '16px',
})}
>
<h3 className={css({ fontSize: 'sm', fontWeight: 'semibold', marginBottom: '8px' })}>
How to Play
</h3>
<ul className={css({ fontSize: 'sm', color: 'gray.700', paddingLeft: '20px' })}>
<li>Solve math problems as fast as you can</li>
<li>First correct answer earns 10 points</li>
<li>Everyone can answer at the same time</li>
<li>Most points wins!</li>
</ul>
</div>
{/* Start Button */}
<button
type="button"
onClick={startGame}
disabled={state.activePlayers.length < 2}
className={css({
padding: '14px 28px',
fontSize: 'lg',
fontWeight: 'semibold',
color: 'white',
background: state.activePlayers.length < 2 ? 'gray.400' : 'purple.600',
borderRadius: '12px',
border: 'none',
cursor: state.activePlayers.length < 2 ? 'not-allowed' : 'pointer',
transition: 'all 0.2s',
_hover: {
background: state.activePlayers.length < 2 ? 'gray.400' : 'purple.700',
},
})}
>
{state.activePlayers.length < 2
? `Need ${2 - state.activePlayers.length} more player(s)`
: 'Start Game'}
</button>
</div>
)
}

View File

@@ -1,16 +0,0 @@
name: math-sprint
displayName: Math Sprint
icon: 🧮
description: Fast-paced math racing game
longDescription: Race against other players to solve math problems! Answer questions quickly to earn points. First person to answer correctly wins the round. Features multiple difficulty levels and customizable question counts.
maxPlayers: 6
difficulty: Beginner
chips:
- 👥 Multiplayer
- ⚡ Free-for-All
- 🧮 Math Skills
- 🏃 Speed
color: purple
gradient: linear-gradient(135deg, #ddd6fe, #c4b5fd)
borderColor: purple.200
available: true

View File

@@ -1,61 +0,0 @@
/**
* Math Sprint Game Definition
*
* A free-for-all math game demonstrating the TEAM_MOVE pattern.
* Players race to solve math problems - first correct answer wins points.
*/
import { defineGame } from '@/lib/arcade/game-sdk'
import type { GameManifest } from '@/lib/arcade/game-sdk'
import { GameComponent } from './components/GameComponent'
import { MathSprintProvider } from './Provider'
import type { MathSprintConfig, MathSprintMove, MathSprintState } from './types'
import { mathSprintValidator } from './Validator'
const manifest: GameManifest = {
name: 'math-sprint',
displayName: 'Math Sprint',
icon: '🧮',
description: 'Race to solve math problems!',
longDescription:
'A fast-paced free-for-all game where players compete to solve math problems. First correct answer earns points. Choose your difficulty and test your mental math skills!',
maxPlayers: 8,
difficulty: 'Beginner',
chips: ['👥 Multiplayer', '⚡ Fast-Paced', '🧠 Mental Math'],
color: 'purple',
gradient: 'linear-gradient(135deg, #ddd6fe, #c4b5fd)',
borderColor: 'purple.200',
available: true,
}
const defaultConfig: MathSprintConfig = {
difficulty: 'medium',
questionsPerRound: 10,
timePerQuestion: 30,
}
// Config validation function
function validateMathSprintConfig(config: unknown): config is MathSprintConfig {
return (
typeof config === 'object' &&
config !== null &&
'difficulty' in config &&
'questionsPerRound' in config &&
'timePerQuestion' in config &&
['easy', 'medium', 'hard'].includes((config as any).difficulty) &&
typeof (config as any).questionsPerRound === 'number' &&
typeof (config as any).timePerQuestion === 'number' &&
(config as any).questionsPerRound >= 5 &&
(config as any).questionsPerRound <= 20 &&
(config as any).timePerQuestion >= 10
)
}
export const mathSprintGame = defineGame<MathSprintConfig, MathSprintState, MathSprintMove>({
manifest,
Provider: MathSprintProvider,
GameComponent,
validator: mathSprintValidator,
defaultConfig,
validateConfig: validateMathSprintConfig,
})

View File

@@ -1,120 +0,0 @@
/**
* Math Sprint Game Types
*
* A free-for-all game where players race to solve math problems.
* Demonstrates the TEAM_MOVE pattern (no specific turn owner).
*/
import type { GameConfig, GameMove, GameState } from '@/lib/arcade/game-sdk'
/**
* Difficulty levels for math problems
*/
export type Difficulty = 'easy' | 'medium' | 'hard'
/**
* Math operation types
*/
export type Operation = 'addition' | 'subtraction' | 'multiplication'
/**
* Game configuration (persisted to database)
*/
export interface MathSprintConfig extends GameConfig {
difficulty: Difficulty
questionsPerRound: number
timePerQuestion: number // seconds
}
/**
* A math question
*/
export interface Question {
id: string
operand1: number
operand2: number
operation: Operation
correctAnswer: number
displayText: string // e.g., "5 + 3 = ?"
}
/**
* Player answer submission
*/
export interface Answer {
playerId: string
answer: number
timestamp: number
correct: boolean
}
/**
* Game state (synchronized across all clients)
*/
export interface MathSprintState extends GameState {
gamePhase: 'setup' | 'playing' | 'results'
activePlayers: string[]
playerMetadata: Record<string, { name: string; emoji: string; color: string; userId: string }>
// Configuration
difficulty: Difficulty
questionsPerRound: number
timePerQuestion: number
// Game progress
currentQuestionIndex: number
questions: Question[]
// Scoring
scores: Record<string, number> // playerId -> score
correctAnswersCount: Record<string, number> // playerId -> count
// Current question state
answers: Answer[] // All answers for current question
questionStartTime: number // Timestamp when question was shown
questionAnswered: boolean // True if someone got it right
winnerId: string | null // Winner of current question (first correct)
}
/**
* Move types for Math Sprint
*/
export type MathSprintMove =
| StartGameMove
| SubmitAnswerMove
| NextQuestionMove
| ResetGameMove
| SetConfigMove
export interface StartGameMove extends GameMove {
type: 'START_GAME'
data: {
activePlayers: string[]
playerMetadata: Record<string, unknown>
}
}
export interface SubmitAnswerMove extends GameMove {
type: 'SUBMIT_ANSWER'
data: {
answer: number
}
}
export interface NextQuestionMove extends GameMove {
type: 'NEXT_QUESTION'
data: Record<string, never>
}
export interface ResetGameMove extends GameMove {
type: 'RESET_GAME'
data: Record<string, never>
}
export interface SetConfigMove extends GameMove {
type: 'SET_CONFIG'
data: {
field: 'difficulty' | 'questionsPerRound' | 'timePerQuestion'
value: Difficulty | number
}
}

View File

@@ -1,215 +0,0 @@
/**
* Number Guesser Provider
* Manages game state using the Arcade SDK
*/
'use client'
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
import {
type GameMove,
buildPlayerMetadata,
useArcadeSession,
useGameMode,
useRoomData,
useUpdateGameConfig,
useViewerId,
} from '@/lib/arcade/game-sdk'
import type { NumberGuesserState } from './types'
/**
* Context value interface
*/
interface NumberGuesserContextValue {
state: NumberGuesserState
lastError: string | null
startGame: () => void
chooseNumber: (number: number) => void
makeGuess: (guess: number) => void
nextRound: () => void
goToSetup: () => void
setConfig: (field: 'minNumber' | 'maxNumber' | 'roundsToWin', value: number) => void
clearError: () => void
exitSession: () => void
}
const NumberGuesserContext = createContext<NumberGuesserContextValue | null>(null)
/**
* Hook to access Number Guesser context
*/
export function useNumberGuesser() {
const context = useContext(NumberGuesserContext)
if (!context) {
throw new Error('useNumberGuesser must be used within NumberGuesserProvider')
}
return context
}
/**
* Optimistic move application
*/
function applyMoveOptimistically(state: NumberGuesserState, move: GameMove): NumberGuesserState {
// For simplicity, just return current state
// Server will send back the validated new state
return state
}
/**
* Number Guesser Provider Component
*/
export function NumberGuesserProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
const { activePlayers: activePlayerIds, players } = useGameMode()
const { mutate: updateGameConfig } = useUpdateGameConfig()
// Get active players as array (keep Set iteration order to match UI display)
const activePlayers = Array.from(activePlayerIds)
// Merge saved config from room
const initialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, unknown> | null | undefined
const savedConfig = gameConfig?.['number-guesser'] as Record<string, unknown> | undefined
return {
minNumber: (savedConfig?.minNumber as number) || 1,
maxNumber: (savedConfig?.maxNumber as number) || 100,
roundsToWin: (savedConfig?.roundsToWin as number) || 3,
gamePhase: 'setup' as const,
activePlayers: [],
playerMetadata: {},
secretNumber: null,
chooser: '',
currentGuesser: '',
guesses: [],
roundNumber: 0,
scores: {},
gameStartTime: null,
gameEndTime: null,
winner: null,
}
}, [roomData?.gameConfig])
// Arcade session integration
const { state, sendMove, exitSession, lastError, clearError } =
useArcadeSession<NumberGuesserState>({
userId: viewerId || '',
roomId: roomData?.id,
initialState,
applyMove: applyMoveOptimistically,
})
// Action creators
const startGame = useCallback(() => {
if (activePlayers.length < 2) {
console.error('Need at least 2 players to start')
return
}
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined)
sendMove({
type: 'START_GAME',
playerId: activePlayers[0],
userId: viewerId || '',
data: {
activePlayers,
playerMetadata,
},
})
}, [activePlayers, players, viewerId, sendMove])
const chooseNumber = useCallback(
(secretNumber: number) => {
sendMove({
type: 'CHOOSE_NUMBER',
playerId: state.chooser,
userId: viewerId || '',
data: { secretNumber },
})
},
[state.chooser, viewerId, sendMove]
)
const makeGuess = useCallback(
(guess: number) => {
const playerName = state.playerMetadata[state.currentGuesser]?.name || 'Unknown'
sendMove({
type: 'MAKE_GUESS',
playerId: state.currentGuesser,
userId: viewerId || '',
data: { guess, playerName },
})
},
[state.currentGuesser, state.playerMetadata, viewerId, sendMove]
)
const nextRound = useCallback(() => {
sendMove({
type: 'NEXT_ROUND',
playerId: activePlayers[0] || '',
userId: viewerId || '',
data: {},
})
}, [activePlayers, viewerId, sendMove])
const goToSetup = useCallback(() => {
sendMove({
type: 'GO_TO_SETUP',
playerId: activePlayers[0] || state.chooser || '',
userId: viewerId || '',
data: {},
})
}, [activePlayers, state.chooser, viewerId, sendMove])
const setConfig = useCallback(
(field: 'minNumber' | 'maxNumber' | 'roundsToWin', value: number) => {
sendMove({
type: 'SET_CONFIG',
playerId: activePlayers[0] || '',
userId: viewerId || '',
data: { field, value },
})
// Persist to database
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, unknown>) || {}
const currentNumberGuesserConfig =
(currentGameConfig['number-guesser'] as Record<string, unknown>) || {}
const updatedConfig = {
...currentGameConfig,
'number-guesser': {
...currentNumberGuesserConfig,
[field]: value,
},
}
updateGameConfig({
roomId: roomData.id,
gameConfig: updatedConfig,
})
}
},
[activePlayers, viewerId, sendMove, roomData?.id, roomData?.gameConfig, updateGameConfig]
)
const contextValue: NumberGuesserContextValue = {
state,
lastError,
startGame,
chooseNumber,
makeGuess,
nextRound,
goToSetup,
setConfig,
clearError,
exitSession,
}
return (
<NumberGuesserContext.Provider value={contextValue}>{children}</NumberGuesserContext.Provider>
)
}

View File

@@ -1,315 +0,0 @@
/**
* Server-side validator for Number Guesser game
*/
import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk'
import type { NumberGuesserConfig, NumberGuesserMove, NumberGuesserState } from './types'
export class NumberGuesserValidator
implements GameValidator<NumberGuesserState, NumberGuesserMove>
{
validateMove(state: NumberGuesserState, move: NumberGuesserMove): ValidationResult {
switch (move.type) {
case 'START_GAME':
return this.validateStartGame(state, move.data.activePlayers, move.data.playerMetadata)
case 'CHOOSE_NUMBER':
// Ensure secretNumber is a number (JSON deserialization can make it a string)
return this.validateChooseNumber(state, Number(move.data.secretNumber), move.playerId)
case 'MAKE_GUESS':
// Ensure guess is a number (JSON deserialization can make it a string)
return this.validateMakeGuess(
state,
Number(move.data.guess),
move.playerId,
move.data.playerName
)
case 'NEXT_ROUND':
return this.validateNextRound(state)
case 'GO_TO_SETUP':
return this.validateGoToSetup(state)
case 'SET_CONFIG':
// Ensure value is a number (JSON deserialization can make it a string)
return this.validateSetConfig(state, move.data.field, Number(move.data.value))
default:
return {
valid: false,
error: `Unknown move type: ${(move as { type: string }).type}`,
}
}
}
private validateStartGame(
state: NumberGuesserState,
activePlayers: string[],
playerMetadata: Record<string, unknown>
): ValidationResult {
if (!activePlayers || activePlayers.length < 2) {
return { valid: false, error: 'Need at least 2 players' }
}
const newState: NumberGuesserState = {
...state,
gamePhase: 'choosing',
activePlayers,
playerMetadata: playerMetadata as typeof state.playerMetadata,
chooser: activePlayers[0],
currentGuesser: '',
secretNumber: null,
guesses: [],
roundNumber: 1,
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
gameStartTime: Date.now(),
gameEndTime: null,
winner: null,
}
return { valid: true, newState }
}
private validateChooseNumber(
state: NumberGuesserState,
secretNumber: number,
playerId: string
): ValidationResult {
if (state.gamePhase !== 'choosing') {
return { valid: false, error: 'Not in choosing phase' }
}
if (playerId !== state.chooser) {
return { valid: false, error: 'Not your turn to choose' }
}
if (
secretNumber < state.minNumber ||
secretNumber > state.maxNumber ||
!Number.isInteger(secretNumber)
) {
return {
valid: false,
error: `Number must be between ${state.minNumber} and ${state.maxNumber}`,
}
}
// Debug logging
console.log('[NumberGuesser] Setting secret number:', {
secretNumber,
secretNumberType: typeof secretNumber,
})
// First guesser is the next player after chooser
const chooserIndex = state.activePlayers.indexOf(state.chooser)
const firstGuesserIndex = (chooserIndex + 1) % state.activePlayers.length
const firstGuesser = state.activePlayers[firstGuesserIndex]
const newState: NumberGuesserState = {
...state,
gamePhase: 'guessing',
secretNumber,
currentGuesser: firstGuesser,
}
return { valid: true, newState }
}
private validateMakeGuess(
state: NumberGuesserState,
guess: number,
playerId: string,
playerName: string
): ValidationResult {
if (state.gamePhase !== 'guessing') {
return { valid: false, error: 'Not in guessing phase' }
}
if (playerId !== state.currentGuesser) {
return { valid: false, error: 'Not your turn to guess' }
}
if (guess < state.minNumber || guess > state.maxNumber || !Number.isInteger(guess)) {
return {
valid: false,
error: `Guess must be between ${state.minNumber} and ${state.maxNumber}`,
}
}
if (!state.secretNumber) {
return { valid: false, error: 'No secret number set' }
}
// Debug logging
console.log('[NumberGuesser] Validating guess:', {
guess,
guessType: typeof guess,
secretNumber: state.secretNumber,
secretNumberType: typeof state.secretNumber,
})
const distance = Math.abs(guess - state.secretNumber)
console.log('[NumberGuesser] Calculated distance:', distance)
const newGuess = {
playerId,
playerName,
guess,
distance,
timestamp: Date.now(),
}
const guesses = [...state.guesses, newGuess]
// Check if guess is correct
if (distance === 0) {
// Correct guess! Award point and end round
const newScores = {
...state.scores,
[playerId]: (state.scores[playerId] || 0) + 1,
}
// Check if player won
const winner = newScores[playerId] >= state.roundsToWin ? playerId : null
const newState: NumberGuesserState = {
...state,
guesses,
scores: newScores,
gamePhase: winner ? 'results' : 'guessing',
gameEndTime: winner ? Date.now() : null,
winner,
}
return { valid: true, newState }
}
// Incorrect guess, move to next guesser
const guesserIndex = state.activePlayers.indexOf(state.currentGuesser)
let nextGuesserIndex = (guesserIndex + 1) % state.activePlayers.length
// Skip the chooser
if (state.activePlayers[nextGuesserIndex] === state.chooser) {
nextGuesserIndex = (nextGuesserIndex + 1) % state.activePlayers.length
}
const newState: NumberGuesserState = {
...state,
guesses,
currentGuesser: state.activePlayers[nextGuesserIndex],
}
return { valid: true, newState }
}
private validateNextRound(state: NumberGuesserState): ValidationResult {
if (state.gamePhase !== 'guessing') {
return { valid: false, error: 'Not in guessing phase' }
}
// Check if the round is complete (someone guessed correctly)
const roundComplete =
state.guesses.length > 0 && state.guesses[state.guesses.length - 1].distance === 0
if (!roundComplete) {
return { valid: false, error: 'Round not complete yet - no one has guessed the number' }
}
// Rotate chooser to next player
const chooserIndex = state.activePlayers.indexOf(state.chooser)
const nextChooserIndex = (chooserIndex + 1) % state.activePlayers.length
const nextChooser = state.activePlayers[nextChooserIndex]
const newState: NumberGuesserState = {
...state,
gamePhase: 'choosing',
chooser: nextChooser,
currentGuesser: '',
secretNumber: null,
guesses: [],
roundNumber: state.roundNumber + 1,
winner: null,
}
return { valid: true, newState }
}
private validateGoToSetup(state: NumberGuesserState): ValidationResult {
const newState: NumberGuesserState = {
...state,
gamePhase: 'setup',
secretNumber: null,
chooser: '',
currentGuesser: '',
guesses: [],
roundNumber: 0,
scores: {},
activePlayers: [],
playerMetadata: {},
gameStartTime: null,
gameEndTime: null,
winner: null,
}
return { valid: true, newState }
}
private validateSetConfig(
state: NumberGuesserState,
field: 'minNumber' | 'maxNumber' | 'roundsToWin',
value: number
): ValidationResult {
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Can only change config in setup' }
}
if (!Number.isInteger(value) || value < 1) {
return { valid: false, error: 'Value must be a positive integer' }
}
if (field === 'minNumber' && value >= state.maxNumber) {
return { valid: false, error: 'Min must be less than max' }
}
if (field === 'maxNumber' && value <= state.minNumber) {
return { valid: false, error: 'Max must be greater than min' }
}
const newState: NumberGuesserState = {
...state,
[field]: value,
}
return { valid: true, newState }
}
isGameComplete(state: NumberGuesserState): boolean {
return state.gamePhase === 'results' && state.winner !== null
}
getInitialState(config: unknown): NumberGuesserState {
const { minNumber, maxNumber, roundsToWin } = config as NumberGuesserConfig
return {
minNumber: minNumber || 1,
maxNumber: maxNumber || 100,
roundsToWin: roundsToWin || 3,
gamePhase: 'setup',
activePlayers: [],
playerMetadata: {},
secretNumber: null,
chooser: '',
currentGuesser: '',
guesses: [],
roundNumber: 0,
scores: {},
gameStartTime: null,
gameEndTime: null,
winner: null,
}
}
}
export const numberGuesserValidator = new NumberGuesserValidator()

View File

@@ -1,211 +0,0 @@
/**
* Choosing Phase - Chooser picks a secret number
*/
'use client'
import { useState } from 'react'
import { useViewerId } from '@/lib/arcade/game-sdk'
import { css } from '../../../../styled-system/css'
import { useNumberGuesser } from '../Provider'
export function ChoosingPhase() {
const { state, chooseNumber } = useNumberGuesser()
const { data: viewerId } = useViewerId()
const [inputValue, setInputValue] = useState('')
const chooserMetadata = state.playerMetadata[state.chooser]
const isChooser = chooserMetadata?.userId === viewerId
const handleSubmit = () => {
const number = Number.parseInt(inputValue, 10)
if (Number.isNaN(number)) return
chooseNumber(number)
}
return (
<div
className={css({
padding: '32px',
maxWidth: '600px',
margin: '0 auto',
})}
>
<div
className={css({
textAlign: 'center',
marginBottom: '32px',
})}
>
<div
className={css({
fontSize: '64px',
marginBottom: '16px',
})}
>
{chooserMetadata?.emoji || '🤔'}
</div>
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
marginBottom: '8px',
})}
>
{isChooser ? "You're choosing!" : `${chooserMetadata?.name || 'Someone'} is choosing...`}
</h2>
<p
className={css({
color: 'gray.600',
})}
>
Round {state.roundNumber}
</p>
</div>
{isChooser ? (
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: 'orange.200',
borderRadius: '12px',
padding: '24px',
})}
>
<label
className={css({
display: 'block',
fontSize: 'md',
fontWeight: '600',
marginBottom: '12px',
textAlign: 'center',
})}
>
Choose a secret number ({state.minNumber} - {state.maxNumber})
</label>
<input
type="number"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
min={state.minNumber}
max={state.maxNumber}
placeholder={`${state.minNumber} - ${state.maxNumber}`}
className={css({
width: '100%',
padding: '16px',
border: '2px solid',
borderColor: 'gray.300',
borderRadius: '8px',
fontSize: 'xl',
textAlign: 'center',
marginBottom: '16px',
})}
/>
<button
onClick={handleSubmit}
disabled={!inputValue}
className={css({
width: '100%',
padding: '16px',
background: 'linear-gradient(135deg, #fb923c, #f97316)',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: 'lg',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
_hover: {
transform: 'translateY(-2px)',
},
})}
>
Confirm Choice
</button>
</div>
) : (
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: 'orange.200',
borderRadius: '12px',
padding: '32px',
textAlign: 'center',
})}
>
<div
className={css({
fontSize: '48px',
marginBottom: '16px',
})}
>
</div>
<p
className={css({
fontSize: 'lg',
color: 'gray.600',
})}
>
Waiting for {chooserMetadata?.name || 'player'} to choose a number...
</p>
</div>
)}
{/* Scoreboard */}
<div
className={css({
marginTop: '32px',
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '16px',
})}
>
<h3
className={css({
fontSize: 'md',
fontWeight: 'bold',
marginBottom: '12px',
textAlign: 'center',
})}
>
Scores
</h3>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
justifyContent: 'center',
})}
>
{state.activePlayers.map((playerId) => {
const player = state.playerMetadata[playerId]
return (
<div
key={playerId}
className={css({
padding: '8px 16px',
background: 'gray.100',
borderRadius: '8px',
fontSize: 'sm',
})}
>
{player?.emoji} {player?.name}: {state.scores[playerId] || 0}
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -1,60 +0,0 @@
/**
* Number Guesser Game Component
* Main component that switches between game phases
*/
'use client'
import { useRouter } from 'next/navigation'
import { PageWithNav } from '@/components/PageWithNav'
import { useNumberGuesser } from '../Provider'
import { ChoosingPhase } from './ChoosingPhase'
import { GuessingPhase } from './GuessingPhase'
import { ResultsPhase } from './ResultsPhase'
import { SetupPhase } from './SetupPhase'
export function GameComponent() {
const router = useRouter()
const { state, exitSession, goToSetup } = useNumberGuesser()
// Determine whose turn it is based on game phase
const currentPlayerId =
state.gamePhase === 'choosing'
? state.chooser
: state.gamePhase === 'guessing'
? state.currentGuesser
: undefined
return (
<PageWithNav
navTitle="Number Guesser"
navEmoji="🎯"
emphasizePlayerSelection={state.gamePhase === 'setup'}
currentPlayerId={currentPlayerId}
playerScores={state.scores}
onExitSession={() => {
exitSession?.()
router.push('/arcade')
}}
onNewGame={() => {
goToSetup?.()
}}
>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
minHeight: '100vh',
background: 'linear-gradient(135deg, #fff7ed, #ffedd5)',
}}
>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'choosing' && <ChoosingPhase />}
{state.gamePhase === 'guessing' && <GuessingPhase />}
{state.gamePhase === 'results' && <ResultsPhase />}
</div>
</PageWithNav>
)
}

View File

@@ -1,445 +0,0 @@
/**
* Guessing Phase - Players take turns guessing the secret number
*/
'use client'
import { useEffect, useState } from 'react'
import { useViewerId } from '@/lib/arcade/game-sdk'
import { css } from '../../../../styled-system/css'
import { useNumberGuesser } from '../Provider'
export function GuessingPhase() {
const { state, makeGuess, nextRound, lastError, clearError } = useNumberGuesser()
const { data: viewerId } = useViewerId()
const [inputValue, setInputValue] = useState('')
const currentGuesserMetadata = state.playerMetadata[state.currentGuesser]
const isCurrentGuesser = currentGuesserMetadata?.userId === viewerId
// Check if someone just won the round
const lastGuess = state.guesses[state.guesses.length - 1]
const roundJustEnded = lastGuess?.distance === 0
// Auto-clear error after 5 seconds
useEffect(() => {
if (lastError) {
const timeout = setTimeout(() => clearError(), 5000)
return () => clearTimeout(timeout)
}
}, [lastError, clearError])
const handleSubmit = () => {
const guess = Number.parseInt(inputValue, 10)
if (Number.isNaN(guess)) return
makeGuess(guess)
setInputValue('')
}
const getHotColdMessage = (distance: number) => {
if (distance === 0) return '🎯 Correct!'
if (distance <= 5) return '🔥 Very Hot!'
if (distance <= 10) return '🌡️ Hot'
if (distance <= 20) return '😊 Warm'
if (distance <= 30) return '😐 Cool'
if (distance <= 50) return '❄️ Cold'
return '🧊 Very Cold'
}
return (
<div
className={css({
padding: '32px',
maxWidth: '800px',
margin: '0 auto',
})}
>
{/* Header */}
<div
className={css({
textAlign: 'center',
marginBottom: '32px',
})}
>
<div
className={css({
fontSize: '64px',
marginBottom: '16px',
})}
>
{roundJustEnded ? '🎉' : currentGuesserMetadata?.emoji || '🤔'}
</div>
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
marginBottom: '8px',
})}
>
{roundJustEnded
? `${lastGuess.playerName} guessed it!`
: isCurrentGuesser
? 'Your turn to guess!'
: `${currentGuesserMetadata?.name || 'Someone'} is guessing...`}
</h2>
<p
className={css({
color: 'gray.600',
})}
>
Round {state.roundNumber} Range: {state.minNumber} - {state.maxNumber}
</p>
</div>
{/* Error Banner */}
{lastError && (
<div
className={css({
background: 'linear-gradient(135deg, #fef2f2, #fee2e2)',
border: '2px solid',
borderColor: 'red.300',
borderRadius: '12px',
padding: '16px 20px',
marginBottom: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
animation: 'slideIn 0.3s ease',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '12px',
})}
>
<div
className={css({
fontSize: '24px',
})}
>
</div>
<div>
<div
className={css({
fontSize: 'md',
fontWeight: 'bold',
color: 'red.700',
marginBottom: '4px',
})}
>
Move Rejected
</div>
<div
className={css({
fontSize: 'sm',
color: 'red.600',
})}
>
{lastError}
</div>
</div>
</div>
<button
type="button"
onClick={clearError}
className={css({
padding: '8px 12px',
background: 'white',
border: '1px solid',
borderColor: 'red.300',
borderRadius: '6px',
fontSize: 'sm',
fontWeight: '600',
color: 'red.700',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
background: 'red.50',
},
})}
>
Dismiss
</button>
</div>
)}
{/* Round ended - show next round button */}
{roundJustEnded && (
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: 'green.200',
borderRadius: '12px',
padding: '24px',
marginBottom: '24px',
textAlign: 'center',
})}
>
<div
className={css({
fontSize: '48px',
marginBottom: '16px',
})}
>
🎯
</div>
<p
className={css({
fontSize: 'lg',
marginBottom: '16px',
})}
>
The secret number was <strong>{state.secretNumber}</strong>!
</p>
<button
type="button"
onClick={nextRound}
className={css({
padding: '12px 24px',
background: 'linear-gradient(135deg, #fb923c, #f97316)',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: 'md',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
transform: 'translateY(-2px)',
},
})}
>
Next Round
</button>
</div>
)}
{/* Guessing input (only if round not ended) */}
{!roundJustEnded && (
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: 'orange.200',
borderRadius: '12px',
padding: '24px',
marginBottom: '24px',
})}
>
{isCurrentGuesser ? (
<>
<label
className={css({
display: 'block',
fontSize: 'md',
fontWeight: '600',
marginBottom: '12px',
textAlign: 'center',
})}
>
Make your guess ({state.minNumber} - {state.maxNumber})
</label>
<input
type="number"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && inputValue) {
handleSubmit()
}
}}
min={state.minNumber}
max={state.maxNumber}
placeholder={`${state.minNumber} - ${state.maxNumber}`}
className={css({
width: '100%',
padding: '16px',
border: '2px solid',
borderColor: 'gray.300',
borderRadius: '8px',
fontSize: 'xl',
textAlign: 'center',
marginBottom: '16px',
})}
/>
<button
type="button"
onClick={handleSubmit}
disabled={!inputValue}
className={css({
width: '100%',
padding: '16px',
background: 'linear-gradient(135deg, #fb923c, #f97316)',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: 'lg',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
_hover: {
transform: 'translateY(-2px)',
},
})}
>
Submit Guess
</button>
</>
) : (
<div
className={css({
textAlign: 'center',
padding: '16px',
})}
>
<div
className={css({
fontSize: '48px',
marginBottom: '16px',
})}
>
</div>
<p
className={css({
fontSize: 'lg',
color: 'gray.600',
})}
>
Waiting for {currentGuesserMetadata?.name || 'player'} to guess...
</p>
</div>
)}
</div>
)}
{/* Guess history */}
{state.guesses.length > 0 && (
<div
className={css({
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '16px',
marginBottom: '24px',
})}
>
<h3
className={css({
fontSize: 'md',
fontWeight: 'bold',
marginBottom: '12px',
})}
>
Guess History
</h3>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '8px',
})}
>
{state.guesses.map((guess, index) => {
const player = state.playerMetadata[guess.playerId]
return (
<div
key={index}
className={css({
padding: '12px',
background: guess.distance === 0 ? 'green.50' : 'gray.50',
borderRadius: '8px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '8px',
})}
>
<span>{player?.emoji || '🎮'}</span>
<span className={css({ fontWeight: '600' })}>{guess.playerName}</span>
<span className={css({ color: 'gray.600' })}>guessed</span>
<span className={css({ fontWeight: 'bold', fontSize: 'lg' })}>
{guess.guess}
</span>
</div>
<div
className={css({
fontWeight: 'bold',
color: guess.distance === 0 ? 'green.700' : 'orange.700',
})}
>
{getHotColdMessage(guess.distance)}
</div>
</div>
)
})}
</div>
</div>
)}
{/* Scoreboard */}
<div
className={css({
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '16px',
})}
>
<h3
className={css({
fontSize: 'md',
fontWeight: 'bold',
marginBottom: '12px',
textAlign: 'center',
})}
>
Scores (First to {state.roundsToWin} wins!)
</h3>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
justifyContent: 'center',
})}
>
{state.activePlayers.map((playerId) => {
const player = state.playerMetadata[playerId]
const score = state.scores[playerId] || 0
return (
<div
key={playerId}
className={css({
padding: '8px 16px',
background: 'gray.100',
borderRadius: '8px',
fontSize: 'sm',
})}
>
{player?.emoji} {player?.name}: {score}
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -1,208 +0,0 @@
/**
* Results Phase - Shows winner and final scores
*/
'use client'
import { css } from '../../../../styled-system/css'
import { useNumberGuesser } from '../Provider'
export function ResultsPhase() {
const { state, goToSetup } = useNumberGuesser()
const winnerMetadata = state.winner ? state.playerMetadata[state.winner] : null
const winnerScore = state.winner ? state.scores[state.winner] : 0
// Sort players by score
const sortedPlayers = [...state.activePlayers].sort((a, b) => {
const scoreA = state.scores[a] || 0
const scoreB = state.scores[b] || 0
return scoreB - scoreA
})
return (
<div
className={css({
padding: '32px',
maxWidth: '600px',
margin: '0 auto',
})}
>
{/* Winner Celebration */}
<div
className={css({
textAlign: 'center',
marginBottom: '32px',
})}
>
<div
className={css({
fontSize: '96px',
marginBottom: '16px',
animation: 'bounce 1s ease-in-out infinite',
})}
>
{winnerMetadata?.emoji || '🏆'}
</div>
<h1
className={css({
fontSize: '3xl',
fontWeight: 'bold',
marginBottom: '8px',
background: 'linear-gradient(135deg, #fb923c, #f97316)',
backgroundClip: 'text',
color: 'transparent',
})}
>
{winnerMetadata?.name || 'Someone'} Wins!
</h1>
<p
className={css({
fontSize: 'xl',
color: 'gray.600',
})}
>
with {winnerScore} {winnerScore === 1 ? 'round' : 'rounds'} won
</p>
</div>
{/* Final Standings */}
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: 'orange.200',
borderRadius: '12px',
padding: '24px',
marginBottom: '24px',
})}
>
<h3
className={css({
fontSize: 'lg',
fontWeight: 'bold',
marginBottom: '16px',
textAlign: 'center',
})}
>
Final Standings
</h3>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '12px',
})}
>
{sortedPlayers.map((playerId, index) => {
const player = state.playerMetadata[playerId]
const score = state.scores[playerId] || 0
const isWinner = playerId === state.winner
return (
<div
key={playerId}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px',
background: isWinner ? 'linear-gradient(135deg, #fed7aa, #fdba74)' : 'gray.100',
borderRadius: '8px',
border: isWinner ? '2px solid' : 'none',
borderColor: isWinner ? 'orange.300' : undefined,
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '12px',
})}
>
<span
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: 'gray.400',
width: '32px',
textAlign: 'center',
})}
>
{index + 1}
</span>
<span className={css({ fontSize: '32px' })}>{player?.emoji || '🎮'}</span>
<span className={css({ fontSize: 'lg', fontWeight: '600' })}>
{player?.name || 'Unknown'}
</span>
</div>
<div
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: isWinner ? 'orange.700' : 'gray.700',
})}
>
{score} {isWinner && '🏆'}
</div>
</div>
)
})}
</div>
</div>
{/* Game Stats */}
<div
className={css({
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '16px',
marginBottom: '24px',
textAlign: 'center',
})}
>
<h3
className={css({
fontSize: 'md',
fontWeight: 'bold',
marginBottom: '8px',
})}
>
Game Stats
</h3>
<p className={css({ color: 'gray.600', fontSize: 'sm' })}>
{state.roundNumber} {state.roundNumber === 1 ? 'round' : 'rounds'} played
</p>
<p className={css({ color: 'gray.600', fontSize: 'sm' })}>
{state.guesses.length} {state.guesses.length === 1 ? 'guess' : 'guesses'} made
</p>
</div>
{/* Actions */}
<button
type="button"
onClick={goToSetup}
className={css({
width: '100%',
padding: '16px',
background: 'linear-gradient(135deg, #fb923c, #f97316)',
color: 'white',
border: 'none',
borderRadius: '12px',
fontSize: 'lg',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
transform: 'translateY(-2px)',
boxShadow: '0 8px 16px rgba(249, 115, 22, 0.3)',
},
})}
>
Play Again
</button>
</div>
)
}

View File

@@ -1,197 +0,0 @@
/**
* Setup Phase - Game configuration
*/
'use client'
import { css } from '../../../../styled-system/css'
import { useNumberGuesser } from '../Provider'
export function SetupPhase() {
const { state, startGame, setConfig } = useNumberGuesser()
return (
<div
className={css({
padding: '32px',
maxWidth: '600px',
margin: '0 auto',
})}
>
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
marginBottom: '24px',
textAlign: 'center',
})}
>
🎯 Number Guesser Setup
</h2>
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: 'orange.200',
borderRadius: '12px',
padding: '24px',
marginBottom: '24px',
})}
>
<h3
className={css({
fontSize: 'lg',
fontWeight: 'bold',
marginBottom: '16px',
})}
>
Game Rules
</h3>
<ul
className={css({
listStyle: 'disc',
paddingLeft: '24px',
lineHeight: '1.6',
color: 'gray.700',
})}
>
<li>One player chooses a secret number</li>
<li>Other players take turns guessing</li>
<li>Get feedback on how close your guess is</li>
<li>First to guess correctly wins the round!</li>
<li>First to {state.roundsToWin} rounds wins the game!</li>
</ul>
</div>
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: 'orange.200',
borderRadius: '12px',
padding: '24px',
marginBottom: '24px',
})}
>
<h3
className={css({
fontSize: 'lg',
fontWeight: 'bold',
marginBottom: '16px',
})}
>
Configuration
</h3>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '16px',
})}
>
<div>
<label
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: '600',
marginBottom: '4px',
})}
>
Minimum Number
</label>
<input
type="number"
value={state.minNumber ?? 1}
onChange={(e) => setConfig('minNumber', Number.parseInt(e.target.value, 10))}
className={css({
width: '100%',
padding: '8px 12px',
border: '1px solid',
borderColor: 'gray.300',
borderRadius: '6px',
fontSize: 'md',
})}
/>
</div>
<div>
<label
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: '600',
marginBottom: '4px',
})}
>
Maximum Number
</label>
<input
type="number"
value={state.maxNumber ?? 100}
onChange={(e) => setConfig('maxNumber', Number.parseInt(e.target.value, 10))}
className={css({
width: '100%',
padding: '8px 12px',
border: '1px solid',
borderColor: 'gray.300',
borderRadius: '6px',
fontSize: 'md',
})}
/>
</div>
<div>
<label
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: '600',
marginBottom: '4px',
})}
>
Rounds to Win
</label>
<input
type="number"
value={state.roundsToWin ?? 3}
onChange={(e) => setConfig('roundsToWin', Number.parseInt(e.target.value, 10))}
className={css({
width: '100%',
padding: '8px 12px',
border: '1px solid',
borderColor: 'gray.300',
borderRadius: '6px',
fontSize: 'md',
})}
/>
</div>
</div>
</div>
<button
onClick={startGame}
className={css({
width: '100%',
padding: '16px',
background: 'linear-gradient(135deg, #fb923c, #f97316)',
color: 'white',
border: 'none',
borderRadius: '12px',
fontSize: 'lg',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
transform: 'translateY(-2px)',
boxShadow: '0 8px 16px rgba(249, 115, 22, 0.3)',
},
})}
>
Start Game
</button>
</div>
)
}

View File

@@ -1,15 +0,0 @@
name: number-guesser
displayName: Number Guesser
icon: 🎯
description: Classic turn-based number guessing game
longDescription: One player thinks of a number, others take turns guessing. Get hot/cold feedback as you try to find the secret number. Perfect for testing your deduction skills!
maxPlayers: 4
difficulty: Beginner
chips:
- 👥 Multiplayer
- 🎲 Turn-Based
- 🧠 Logic Puzzle
color: orange
gradient: linear-gradient(135deg, #fed7aa, #fdba74)
borderColor: orange.200
available: true

View File

@@ -1,66 +0,0 @@
/**
* Number Guesser Game Definition
* Exports the complete game using the Arcade SDK
*/
import { defineGame } from '@/lib/arcade/game-sdk'
import type { GameManifest } from '@/lib/arcade/game-sdk'
import { GameComponent } from './components/GameComponent'
import { NumberGuesserProvider } from './Provider'
import type { NumberGuesserConfig, NumberGuesserMove, NumberGuesserState } from './types'
import { numberGuesserValidator } from './Validator'
// Game manifest (matches game.yaml)
const manifest: GameManifest = {
name: 'number-guesser',
displayName: 'Number Guesser',
icon: '🎯',
description: 'Classic turn-based number guessing game',
longDescription:
'One player thinks of a number, others take turns guessing. Get hot/cold feedback to narrow down your guesses. First to guess wins the round!',
maxPlayers: 4,
difficulty: 'Beginner',
chips: ['👥 Multiplayer', '🎲 Turn-Based', '🧠 Logic Puzzle'],
color: 'orange',
gradient: 'linear-gradient(135deg, #fed7aa, #fdba74)',
borderColor: 'orange.200',
available: true,
}
// Default configuration
const defaultConfig: NumberGuesserConfig = {
minNumber: 1,
maxNumber: 100,
roundsToWin: 3,
}
// Config validation function
function validateNumberGuesserConfig(config: unknown): config is NumberGuesserConfig {
return (
typeof config === 'object' &&
config !== null &&
'minNumber' in config &&
'maxNumber' in config &&
'roundsToWin' in config &&
typeof config.minNumber === 'number' &&
typeof config.maxNumber === 'number' &&
typeof config.roundsToWin === 'number' &&
config.minNumber >= 1 &&
config.maxNumber > config.minNumber &&
config.roundsToWin >= 1
)
}
// Export game definition
export const numberGuesserGame = defineGame<
NumberGuesserConfig,
NumberGuesserState,
NumberGuesserMove
>({
manifest,
Provider: NumberGuesserProvider,
GameComponent,
validator: numberGuesserValidator,
defaultConfig,
validateConfig: validateNumberGuesserConfig,
})

View File

@@ -1,116 +0,0 @@
/**
* Type definitions for Number Guesser game
*/
import type { GameMove } from '@/lib/arcade/game-sdk'
/**
* Game configuration
*/
export type NumberGuesserConfig = {
minNumber: number
maxNumber: number
roundsToWin: number
}
/**
* A single guess attempt
*/
export interface Guess {
playerId: string
playerName: string
guess: number
distance: number // How far from the secret number
timestamp: number
}
/**
* Game phases
*/
export type GamePhase = 'setup' | 'choosing' | 'guessing' | 'results'
/**
* Game state
*/
export type NumberGuesserState = {
// Configuration
minNumber: number
maxNumber: number
roundsToWin: number
// Game phase
gamePhase: GamePhase
// Players
activePlayers: string[]
playerMetadata: Record<string, { name: string; emoji: string; color: string; userId: string }>
// Current round
secretNumber: number | null
chooser: string // Player ID who chose the number
currentGuesser: string // Player ID whose turn it is to guess
// Round history
guesses: Guess[]
roundNumber: number
// Scores
scores: Record<string, number>
// Game state
gameStartTime: number | null
gameEndTime: number | null
winner: string | null
}
/**
* Game moves
*/
export interface StartGameMove extends GameMove {
type: 'START_GAME'
data: {
activePlayers: string[]
playerMetadata: Record<string, unknown>
}
}
export interface ChooseNumberMove extends GameMove {
type: 'CHOOSE_NUMBER'
data: {
secretNumber: number
}
}
export interface MakeGuessMove extends GameMove {
type: 'MAKE_GUESS'
data: {
guess: number
playerName: string
}
}
export interface NextRoundMove extends GameMove {
type: 'NEXT_ROUND'
data: Record<string, never>
}
export interface GoToSetupMove extends GameMove {
type: 'GO_TO_SETUP'
data: Record<string, never>
}
export interface SetConfigMove extends GameMove {
type: 'SET_CONFIG'
data: {
field: 'minNumber' | 'maxNumber' | 'roundsToWin'
value: number
}
}
export type NumberGuesserMove =
| StartGameMove
| ChooseNumberMove
| MakeGuessMove
| NextRoundMove
| GoToSetupMove
| SetConfigMove

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

@@ -2,7 +2,7 @@
* Shared game configuration types
*
* ARCHITECTURE: Phase 3 - Type Inference
* - Modern games (number-guesser, math-sprint, memory-quiz, matching): Types inferred from game definitions
* - Modern games (memory-quiz, matching): Types inferred from game definitions
* - Legacy games (complement-race): Manual types until migrated
*
* These types are used across:
@@ -13,8 +13,6 @@
*/
// Type-only imports (won't load React components at runtime)
import type { numberGuesserGame } from '@/arcade-games/number-guesser'
import type { mathSprintGame } from '@/arcade-games/math-sprint'
import type { memoryQuizGame } from '@/arcade-games/memory-quiz'
import type { matchingGame } from '@/arcade-games/matching'
@@ -28,18 +26,6 @@ type InferGameConfig<T> = T extends { defaultConfig: infer Config } ? Config : n
// Modern Games (Type Inference from Game Definitions)
// ============================================================================
/**
* Configuration for number-guesser game
* INFERRED from numberGuesserGame.defaultConfig
*/
export type NumberGuesserGameConfig = InferGameConfig<typeof numberGuesserGame>
/**
* Configuration for math-sprint game
* INFERRED from mathSprintGame.defaultConfig
*/
export type MathSprintGameConfig = InferGameConfig<typeof mathSprintGame>
/**
* Configuration for memory-quiz (soroban lightning) game
* INFERRED from memoryQuizGame.defaultConfig
@@ -89,7 +75,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)
@@ -108,8 +94,6 @@ export interface ComplementRaceGameConfig {
*/
export type GameConfigByName = {
// Modern games (inferred types)
'number-guesser': NumberGuesserGameConfig
'math-sprint': MathSprintGameConfig
'memory-quiz': MemoryQuizGameConfig
matching: MatchingGameConfig
@@ -171,20 +155,8 @@ 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,
}
export const DEFAULT_NUMBER_GUESSER_CONFIG: NumberGuesserGameConfig = {
minNumber: 1,
maxNumber: 100,
roundsToWin: 3,
}
export const DEFAULT_MATH_SPRINT_CONFIG: MathSprintGameConfig = {
difficulty: 'medium',
questionsPerRound: 10,
timePerQuestion: 30,
}

View File

@@ -106,14 +106,10 @@ export function clearRegistry(): void {
// Game Registrations
// ============================================================================
import { numberGuesserGame } from '@/arcade-games/number-guesser'
import { mathSprintGame } from '@/arcade-games/math-sprint'
import { memoryQuizGame } from '@/arcade-games/memory-quiz'
import { matchingGame } from '@/arcade-games/matching'
import { complementRaceGame } from '@/arcade-games/complement-race/index'
registerGame(numberGuesserGame)
registerGame(mathSprintGame)
registerGame(memoryQuizGame)
registerGame(matchingGame)
registerGame(complementRaceGame)

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

@@ -12,8 +12,6 @@
import { matchingGameValidator } from '@/arcade-games/matching/Validator'
import { memoryQuizGameValidator } from '@/arcade-games/memory-quiz/Validator'
import { numberGuesserValidator } from '@/arcade-games/number-guesser/Validator'
import { mathSprintValidator } from '@/arcade-games/math-sprint/Validator'
import { complementRaceValidator } from '@/arcade-games/complement-race/Validator'
import type { GameValidator } from './validation/types'
@@ -25,8 +23,6 @@ import type { GameValidator } from './validation/types'
export const validatorRegistry = {
matching: matchingGameValidator,
'memory-quiz': memoryQuizGameValidator,
'number-guesser': numberGuesserValidator,
'math-sprint': mathSprintValidator,
'complement-race': complementRaceValidator,
// Add new games here - GameName type will auto-update
} as const
@@ -94,10 +90,4 @@ export function assertValidGameName(gameName: unknown): asserts gameName is Game
/**
* Re-export validators for backwards compatibility
*/
export {
matchingGameValidator,
memoryQuizGameValidator,
numberGuesserValidator,
mathSprintValidator,
complementRaceValidator,
}
export { matchingGameValidator, memoryQuizGameValidator, complementRaceValidator }

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.3.1",
"version": "4.7.1",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [

View File

@@ -1,17 +0,0 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
else
exec node "$basedir/../typescript/bin/tsc" "$@"
fi

View File

@@ -1,17 +0,0 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
else
exec node "$basedir/../typescript/bin/tsserver" "$@"
fi

View File

@@ -1,17 +0,0 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../vite/bin/vite.js" "$@"
else
exec node "$basedir/../vite/bin/vite.js" "$@"
fi

View File

@@ -1 +0,0 @@
../../../../../../node_modules/.pnpm/@myriaddreamin+typst-ts-renderer@0.6.0/node_modules/@myriaddreamin/typst-ts-renderer

View File

@@ -1 +0,0 @@
../../../../../../node_modules/.pnpm/@myriaddreamin+typst-ts-web-compiler@0.6.0/node_modules/@myriaddreamin/typst-ts-web-compiler

View File

@@ -1 +0,0 @@
../../../../../node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite

View File

@@ -1,19 +0,0 @@
{
"name": "soroban-flashcards-browser",
"version": "1.0.0",
"description": "Browser-based Soroban Flashcard Generator using Typst.ts",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@myriaddreamin/typst-ts-web-compiler": "^0.6.0",
"@myriaddreamin/typst-ts-renderer": "^0.6.0"
},
"devDependencies": {
"vite": "^5.0.0",
"typescript": "^5.0.0"
}
}

View File

@@ -2,12 +2,12 @@
* Node.js TypeScript wrapper for Soroban Flashcard Generator
* Calls Python functions via child_process
*/
interface FlashcardConfig$1 {
interface FlashcardConfig {
range: string;
step?: number;
cardsPerPage?: number;
paperSize?: 'us-letter' | 'a4' | 'a3' | 'a5';
orientation?: 'portrait' | 'landscape';
paperSize?: "us-letter" | "a4" | "a3" | "a5";
orientation?: "portrait" | "landscape";
margins?: {
top?: string;
bottom?: string;
@@ -24,12 +24,12 @@ interface FlashcardConfig$1 {
columns?: string | number;
showEmptyColumns?: boolean;
hideInactiveBeads?: boolean;
beadShape?: 'diamond' | 'circle' | 'square';
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating';
beadShape?: "diamond" | "circle" | "square";
colorScheme?: "monochrome" | "place-value" | "heaven-earth" | "alternating";
coloredNumerals?: boolean;
scaleFactor?: number;
}
declare class SorobanGenerator$1 {
declare class SorobanGenerator {
private pythonPath;
private generatorPath;
private projectRoot;
@@ -38,15 +38,15 @@ declare class SorobanGenerator$1 {
/**
* Generate flashcards and return PDF as Buffer
*/
generate(config: FlashcardConfig$1): Promise<Buffer>;
generate(config: FlashcardConfig): Promise<Buffer>;
/**
* Generate flashcards and save to file
*/
generateToFile(config: FlashcardConfig$1, outputPath: string): Promise<void>;
generateToFile(config: FlashcardConfig, outputPath: string): Promise<void>;
/**
* Generate flashcards and return as base64 string
*/
generateBase64(config: FlashcardConfig$1): Promise<string>;
generateBase64(config: FlashcardConfig): Promise<string>;
private executePython;
/**
* Check if all dependencies are installed
@@ -59,65 +59,4 @@ declare class SorobanGenerator$1 {
}
declare function expressExample(): Promise<void>;
/**
* TypeScript wrapper using python-shell for clean function interface
* No CLI arguments - just function calls with objects
*/
interface FlashcardConfig {
range: string;
step?: number;
cardsPerPage?: number;
paperSize?: 'us-letter' | 'a4' | 'a3' | 'a5';
orientation?: 'portrait' | 'landscape';
margins?: {
top?: string;
bottom?: string;
left?: string;
right?: string;
};
gutter?: string;
shuffle?: boolean;
seed?: number;
showCutMarks?: boolean;
showRegistration?: boolean;
fontFamily?: string;
fontSize?: string;
columns?: string | number;
showEmptyColumns?: boolean;
hideInactiveBeads?: boolean;
beadShape?: 'diamond' | 'circle' | 'square';
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating';
coloredNumerals?: boolean;
scaleFactor?: number;
format?: 'pdf' | 'svg';
mode?: 'single-card' | 'flashcards';
number?: number;
}
interface FlashcardResult {
pdf: string;
count: number;
numbers: number[];
}
declare class SorobanGenerator {
private pythonShell;
private projectRoot;
constructor(projectRoot?: string);
/**
* Initialize persistent Python process for better performance
*/
initialize(): Promise<void>;
/**
* Generate flashcards - clean function interface
*/
generate(config: FlashcardConfig): Promise<FlashcardResult>;
/**
* Generate and return as Buffer
*/
generateBuffer(config: FlashcardConfig): Promise<Buffer>;
/**
* Clean up Python process
*/
close(): Promise<void>;
}
export { FlashcardConfig as BridgeFlashcardConfig, FlashcardResult as BridgeFlashcardResult, FlashcardConfig$1 as FlashcardConfig, SorobanGenerator$1 as SorobanGenerator, SorobanGenerator as SorobanGeneratorBridge, SorobanGenerator$1 as default, expressExample };
export { type FlashcardConfig, SorobanGenerator, SorobanGenerator as default, expressExample };

View File

@@ -31,7 +31,6 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
var src_exports = {};
__export(src_exports, {
SorobanGenerator: () => SorobanGenerator,
SorobanGeneratorBridge: () => SorobanGenerator2,
default: () => SorobanGenerator,
expressExample: () => expressExample
});
@@ -194,96 +193,8 @@ var SorobanGenerator = class {
async function expressExample() {
const generator = new SorobanGenerator();
}
// src/soroban-generator-bridge.ts
var import_python_shell = require("python-shell");
var path2 = __toESM(require("path"));
var SorobanGenerator2 = class {
pythonShell = null;
projectRoot;
constructor(projectRoot) {
this.projectRoot = projectRoot || path2.join(__dirname, "../../");
}
/**
* Initialize persistent Python process for better performance
*/
async initialize() {
if (this.pythonShell)
return;
this.pythonShell = new import_python_shell.PythonShell(path2.join("src", "bridge.py"), {
mode: "json",
pythonPath: "python3",
pythonOptions: ["-u"],
// Unbuffered
scriptPath: this.projectRoot
});
}
/**
* Generate flashcards - clean function interface
*/
async generate(config) {
if (!this.pythonShell) {
return new Promise((resolve, reject) => {
const shell = new import_python_shell.PythonShell(path2.join("src", "bridge.py"), {
mode: "json",
pythonPath: "python3",
scriptPath: this.projectRoot
});
shell.on("message", (message) => {
if (message.error) {
reject(new Error(message.error));
} else {
resolve(message);
}
});
shell.on("error", (err) => {
reject(err);
});
shell.send(config);
shell.end((err, code, signal) => {
if (err)
reject(err);
});
});
}
return new Promise((resolve, reject) => {
if (!this.pythonShell) {
reject(new Error("Not initialized"));
return;
}
const handler = (message) => {
if (message.error) {
reject(new Error(message.error));
} else {
resolve(message);
}
this.pythonShell?.removeListener("message", handler);
};
this.pythonShell.on("message", handler);
this.pythonShell.send(config);
});
}
/**
* Generate and return as Buffer
*/
async generateBuffer(config) {
const result = await this.generate(config);
return Buffer.from(result.pdf, "base64");
}
/**
* Clean up Python process
*/
async close() {
if (this.pythonShell) {
this.pythonShell.end(() => {
});
this.pythonShell = null;
}
}
};
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
SorobanGenerator,
SorobanGeneratorBridge,
expressExample
});

View File

@@ -155,96 +155,8 @@ var SorobanGenerator = class {
async function expressExample() {
const generator = new SorobanGenerator();
}
// src/soroban-generator-bridge.ts
import { PythonShell } from "python-shell";
import * as path2 from "path";
var SorobanGenerator2 = class {
pythonShell = null;
projectRoot;
constructor(projectRoot) {
this.projectRoot = projectRoot || path2.join(__dirname, "../../");
}
/**
* Initialize persistent Python process for better performance
*/
async initialize() {
if (this.pythonShell)
return;
this.pythonShell = new PythonShell(path2.join("src", "bridge.py"), {
mode: "json",
pythonPath: "python3",
pythonOptions: ["-u"],
// Unbuffered
scriptPath: this.projectRoot
});
}
/**
* Generate flashcards - clean function interface
*/
async generate(config) {
if (!this.pythonShell) {
return new Promise((resolve, reject) => {
const shell = new PythonShell(path2.join("src", "bridge.py"), {
mode: "json",
pythonPath: "python3",
scriptPath: this.projectRoot
});
shell.on("message", (message) => {
if (message.error) {
reject(new Error(message.error));
} else {
resolve(message);
}
});
shell.on("error", (err) => {
reject(err);
});
shell.send(config);
shell.end((err, code, signal) => {
if (err)
reject(err);
});
});
}
return new Promise((resolve, reject) => {
if (!this.pythonShell) {
reject(new Error("Not initialized"));
return;
}
const handler = (message) => {
if (message.error) {
reject(new Error(message.error));
} else {
resolve(message);
}
this.pythonShell?.removeListener("message", handler);
};
this.pythonShell.on("message", handler);
this.pythonShell.send(config);
});
}
/**
* Generate and return as Buffer
*/
async generateBuffer(config) {
const result = await this.generate(config);
return Buffer.from(result.pdf, "base64");
}
/**
* Clean up Python process
*/
async close() {
if (this.pythonShell) {
this.pythonShell.end(() => {
});
this.pythonShell = null;
}
}
};
export {
SorobanGenerator,
SorobanGenerator2 as SorobanGeneratorBridge,
SorobanGenerator as default,
expressExample
};

View File

@@ -6,9 +6,9 @@ case `uname` in
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.19.12/node_modules/esbuild/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.19.12/node_modules/esbuild/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.19.12/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.19.12/node_modules/esbuild/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.19.12/node_modules/esbuild/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.19.12/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
"$basedir/../../../../../../node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/bin/esbuild" "$@"
"$basedir/../../../../../../node_modules/.pnpm/esbuild@0.19.12/node_modules/esbuild/bin/esbuild" "$@"
exit $?

View File

@@ -1 +0,0 @@
../../../../../node_modules/.pnpm/python-shell@5.0.0/node_modules/python-shell

View File

@@ -18,9 +18,6 @@
"type-check": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"python-shell": "^5.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"tsup": "^7.0.0",

View File

@@ -5,12 +5,5 @@
export * from "./soroban-generator";
// Export bridge generator with different name to avoid conflicts
export {
SorobanGenerator as SorobanGeneratorBridge,
FlashcardConfig as BridgeFlashcardConfig,
FlashcardResult as BridgeFlashcardResult,
} from "./soroban-generator-bridge";
// Default export for convenience
export { SorobanGenerator as default } from "./soroban-generator";

View File

@@ -1,190 +0,0 @@
/**
* TypeScript wrapper using python-shell for clean function interface
* No CLI arguments - just function calls with objects
*/
import { PythonShell } from "python-shell";
import * as path from "path";
export interface FlashcardConfig {
range: string;
step?: number;
cardsPerPage?: number;
paperSize?: "us-letter" | "a4" | "a3" | "a5";
orientation?: "portrait" | "landscape";
margins?: {
top?: string;
bottom?: string;
left?: string;
right?: string;
};
gutter?: string;
shuffle?: boolean;
seed?: number;
showCutMarks?: boolean;
showRegistration?: boolean;
fontFamily?: string;
fontSize?: string;
columns?: string | number;
showEmptyColumns?: boolean;
hideInactiveBeads?: boolean;
beadShape?: "diamond" | "circle" | "square";
colorScheme?: "monochrome" | "place-value" | "heaven-earth" | "alternating";
coloredNumerals?: boolean;
scaleFactor?: number;
format?: "pdf" | "svg";
mode?: "single-card" | "flashcards";
number?: number;
}
export interface FlashcardResult {
pdf: string; // base64 encoded PDF or SVG content (depending on format)
count: number;
numbers: number[];
}
export class SorobanGenerator {
private pythonShell: PythonShell | null = null;
private projectRoot: string;
constructor(projectRoot?: string) {
this.projectRoot = projectRoot || path.join(__dirname, "../../");
}
/**
* Initialize persistent Python process for better performance
*/
async initialize(): Promise<void> {
if (this.pythonShell) return;
this.pythonShell = new PythonShell(path.join("src", "bridge.py"), {
mode: "json",
pythonPath: "python3",
pythonOptions: ["-u"], // Unbuffered
scriptPath: this.projectRoot,
});
}
/**
* Generate flashcards - clean function interface
*/
async generate(config: FlashcardConfig): Promise<FlashcardResult> {
// One-shot mode if not initialized
if (!this.pythonShell) {
return new Promise((resolve, reject) => {
const shell = new PythonShell(path.join("src", "bridge.py"), {
mode: "json",
pythonPath: "python3",
scriptPath: this.projectRoot,
});
shell.on("message", (message: any) => {
if (message.error) {
reject(new Error(message.error));
} else {
resolve(message as FlashcardResult);
}
});
shell.on("error", (err: any) => {
reject(err);
});
shell.send(config);
shell.end((err: any, code: any, signal: any) => {
if (err) reject(err);
});
});
}
// Persistent mode
return new Promise((resolve, reject) => {
if (!this.pythonShell) {
reject(new Error("Not initialized"));
return;
}
const handler = (message: any) => {
if (message.error) {
reject(new Error(message.error));
} else {
resolve(message as FlashcardResult);
}
this.pythonShell?.removeListener("message", handler);
};
this.pythonShell.on("message", handler);
this.pythonShell.send(config);
});
}
/**
* Generate and return as Buffer
*/
async generateBuffer(config: FlashcardConfig): Promise<Buffer> {
const result = await this.generate(config);
return Buffer.from(result.pdf, "base64");
}
/**
* Clean up Python process
*/
async close(): Promise<void> {
if (this.pythonShell) {
this.pythonShell.end(() => {});
this.pythonShell = null;
}
}
}
// Example usage - just like calling a regular TypeScript function
async function example() {
const generator = new SorobanGenerator();
// Just call it like a function!
const result = await generator.generate({
range: "0-99",
cardsPerPage: 6,
colorScheme: "place-value",
coloredNumerals: true,
showCutMarks: true,
});
// You get back a clean result object
console.log(`Generated ${result.count} flashcards`);
// Convert to Buffer if needed
const pdfBuffer = Buffer.from(result.pdf, "base64");
// Or use persistent mode for better performance
await generator.initialize();
// Now calls are faster
const result2 = await generator.generate({ range: "0-9" });
const result3 = await generator.generate({ range: "10-19" });
await generator.close();
}
// Express example - clean function calls
export function expressRoute(app: any) {
const generator = new SorobanGenerator();
app.post("/api/flashcards", async (req: any, res: any) => {
try {
// Just pass the config object directly!
const result = await generator.generate(req.body);
// Send back JSON or PDF
if (req.query.format === "json") {
res.json(result);
} else {
const pdfBuffer = Buffer.from(result.pdf, "base64");
res.contentType("application/pdf");
res.send(pdfBuffer);
}
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
}

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

@@ -1,58 +0,0 @@
/**
* TypeScript client for Soroban Flashcard Generator API
*/
interface FlashcardConfig {
range: string;
step?: number;
cardsPerPage?: number;
paperSize?: 'us-letter' | 'a4' | 'a3' | 'a5';
orientation?: 'portrait' | 'landscape';
margins?: {
top?: string;
bottom?: string;
left?: string;
right?: string;
};
gutter?: string;
shuffle?: boolean;
seed?: number;
showCutMarks?: boolean;
showRegistration?: boolean;
fontFamily?: string;
fontSize?: string;
columns?: string | number;
showEmptyColumns?: boolean;
hideInactiveBeads?: boolean;
beadShape?: 'diamond' | 'circle' | 'square';
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating';
coloredNumerals?: boolean;
scaleFactor?: number;
}
interface FlashcardResponse {
pdf: string;
count: number;
numbers: number[];
}
declare class SorobanFlashcardClient {
private apiUrl;
constructor(apiUrl?: string);
/**
* Generate flashcards and return as base64 PDF
*/
generate(config: FlashcardConfig): Promise<FlashcardResponse>;
/**
* Generate flashcards and download as PDF file
*/
generateAndDownload(config: FlashcardConfig, filename?: string): Promise<void>;
/**
* Generate flashcards and open in new tab
*/
generateAndOpen(config: FlashcardConfig): Promise<void>;
/**
* Check API health
*/
health(): Promise<boolean>;
}
declare function example(): Promise<void>;
export { FlashcardConfig, FlashcardResponse, SorobanFlashcardClient, SorobanFlashcardClient as default, example };

View File

@@ -1,148 +0,0 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var src_exports = {};
__export(src_exports, {
SorobanFlashcardClient: () => SorobanFlashcardClient,
default: () => SorobanFlashcardClient,
example: () => example
});
module.exports = __toCommonJS(src_exports);
// src/soroban-flashcards.ts
var SorobanFlashcardClient = class {
apiUrl;
constructor(apiUrl = "http://localhost:8000") {
this.apiUrl = apiUrl;
}
/**
* Generate flashcards and return as base64 PDF
*/
async generate(config) {
const response = await fetch(`${this.apiUrl}/generate`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
range: config.range,
step: config.step ?? 1,
cards_per_page: config.cardsPerPage ?? 6,
paper_size: config.paperSize ?? "us-letter",
orientation: config.orientation ?? "portrait",
margins: config.margins ?? {
top: "0.5in",
bottom: "0.5in",
left: "0.5in",
right: "0.5in"
},
gutter: config.gutter ?? "5mm",
shuffle: config.shuffle ?? false,
seed: config.seed,
show_cut_marks: config.showCutMarks ?? false,
show_registration: config.showRegistration ?? false,
font_family: config.fontFamily ?? "DejaVu Sans",
font_size: config.fontSize ?? "48pt",
columns: config.columns ?? "auto",
show_empty_columns: config.showEmptyColumns ?? false,
hide_inactive_beads: config.hideInactiveBeads ?? false,
bead_shape: config.beadShape ?? "diamond",
color_scheme: config.colorScheme ?? "monochrome",
colored_numerals: config.coloredNumerals ?? false,
scale_factor: config.scaleFactor ?? 0.9,
format: "base64"
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || "Failed to generate flashcards");
}
return response.json();
}
/**
* Generate flashcards and download as PDF file
*/
async generateAndDownload(config, filename = "flashcards.pdf") {
const result = await this.generate(config);
const pdfBytes = atob(result.pdf);
const byteArray = new Uint8Array(pdfBytes.length);
for (let i = 0; i < pdfBytes.length; i++) {
byteArray[i] = pdfBytes.charCodeAt(i);
}
const blob = new Blob([byteArray], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Generate flashcards and open in new tab
*/
async generateAndOpen(config) {
const result = await this.generate(config);
const pdfBytes = atob(result.pdf);
const byteArray = new Uint8Array(pdfBytes.length);
for (let i = 0; i < pdfBytes.length; i++) {
byteArray[i] = pdfBytes.charCodeAt(i);
}
const blob = new Blob([byteArray], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
window.open(url, "_blank");
}
/**
* Check API health
*/
async health() {
try {
const response = await fetch(`${this.apiUrl}/health`);
const data = await response.json();
return data.status === "healthy";
} catch {
return false;
}
}
};
async function example() {
const client = new SorobanFlashcardClient();
await client.generateAndDownload({
range: "0-99",
cardsPerPage: 6,
colorScheme: "place-value",
coloredNumerals: true,
showCutMarks: true
});
await client.generateAndDownload(
{
range: "0-100",
step: 5,
cardsPerPage: 6
},
"counting-by-5s.pdf"
);
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
SorobanFlashcardClient,
example
});

View File

@@ -1,120 +0,0 @@
// src/soroban-flashcards.ts
var SorobanFlashcardClient = class {
apiUrl;
constructor(apiUrl = "http://localhost:8000") {
this.apiUrl = apiUrl;
}
/**
* Generate flashcards and return as base64 PDF
*/
async generate(config) {
const response = await fetch(`${this.apiUrl}/generate`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
range: config.range,
step: config.step ?? 1,
cards_per_page: config.cardsPerPage ?? 6,
paper_size: config.paperSize ?? "us-letter",
orientation: config.orientation ?? "portrait",
margins: config.margins ?? {
top: "0.5in",
bottom: "0.5in",
left: "0.5in",
right: "0.5in"
},
gutter: config.gutter ?? "5mm",
shuffle: config.shuffle ?? false,
seed: config.seed,
show_cut_marks: config.showCutMarks ?? false,
show_registration: config.showRegistration ?? false,
font_family: config.fontFamily ?? "DejaVu Sans",
font_size: config.fontSize ?? "48pt",
columns: config.columns ?? "auto",
show_empty_columns: config.showEmptyColumns ?? false,
hide_inactive_beads: config.hideInactiveBeads ?? false,
bead_shape: config.beadShape ?? "diamond",
color_scheme: config.colorScheme ?? "monochrome",
colored_numerals: config.coloredNumerals ?? false,
scale_factor: config.scaleFactor ?? 0.9,
format: "base64"
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || "Failed to generate flashcards");
}
return response.json();
}
/**
* Generate flashcards and download as PDF file
*/
async generateAndDownload(config, filename = "flashcards.pdf") {
const result = await this.generate(config);
const pdfBytes = atob(result.pdf);
const byteArray = new Uint8Array(pdfBytes.length);
for (let i = 0; i < pdfBytes.length; i++) {
byteArray[i] = pdfBytes.charCodeAt(i);
}
const blob = new Blob([byteArray], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Generate flashcards and open in new tab
*/
async generateAndOpen(config) {
const result = await this.generate(config);
const pdfBytes = atob(result.pdf);
const byteArray = new Uint8Array(pdfBytes.length);
for (let i = 0; i < pdfBytes.length; i++) {
byteArray[i] = pdfBytes.charCodeAt(i);
}
const blob = new Blob([byteArray], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
window.open(url, "_blank");
}
/**
* Check API health
*/
async health() {
try {
const response = await fetch(`${this.apiUrl}/health`);
const data = await response.json();
return data.status === "healthy";
} catch {
return false;
}
}
};
async function example() {
const client = new SorobanFlashcardClient();
await client.generateAndDownload({
range: "0-99",
cardsPerPage: 6,
colorScheme: "place-value",
coloredNumerals: true,
showCutMarks: true
});
await client.generateAndDownload(
{
range: "0-100",
step: 5,
cardsPerPage: 6
},
"counting-by-5s.pdf"
);
}
export {
SorobanFlashcardClient,
SorobanFlashcardClient as default,
example
};

View File

@@ -1,14 +0,0 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
"$basedir/../../../../../../node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/bin/esbuild" "$@"
exit $?

View File

@@ -1,17 +0,0 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
else
exec node "$basedir/../typescript/bin/tsc" "$@"
fi

View File

@@ -1,17 +0,0 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
else
exec node "$basedir/../typescript/bin/tsserver" "$@"
fi

View File

@@ -1,17 +0,0 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../tsup/dist/cli-default.js" "$@"
else
exec node "$basedir/../tsup/dist/cli-default.js" "$@"
fi

View File

@@ -1,17 +0,0 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../tsup/dist/cli-node.js" "$@"
else
exec node "$basedir/../tsup/dist/cli-node.js" "$@"
fi

View File

@@ -1,17 +0,0 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules/vitest/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules/vitest/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../vitest/vitest.mjs" "$@"
else
exec node "$basedir/../vitest/vitest.mjs" "$@"
fi

View File

@@ -1 +0,0 @@
../../../../../node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup

View File

@@ -1 +0,0 @@
../../../../../node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules/vitest

View File

@@ -1,37 +0,0 @@
{
"name": "@soroban/client",
"version": "1.0.0",
"description": "TypeScript client for Soroban Flashcard Generator",
"main": "dist/index.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"require": "./dist/index.js",
"import": "./dist/index.esm.js"
}
},
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
"test": "vitest",
"type-check": "tsc --noEmit",
"clean": "rm -rf dist"
},
"keywords": [
"soroban",
"abacus",
"flashcards",
"education",
"math"
],
"author": "",
"license": "MIT",
"devDependencies": {
"@types/node": "^20.0.0",
"tsup": "^7.0.0",
"typescript": "^5.0.0",
"vitest": "^1.0.0"
}
}

View File

@@ -1,7 +0,0 @@
/**
* Soroban Flashcard Generator - TypeScript Client
* Re-export main client functionality
*/
export * from "./soroban-flashcards";
export { SorobanFlashcardClient as default } from "./soroban-flashcards";

View File

@@ -1,176 +0,0 @@
/**
* TypeScript client for Soroban Flashcard Generator API
*/
export interface FlashcardConfig {
range: string;
step?: number;
cardsPerPage?: number;
paperSize?: "us-letter" | "a4" | "a3" | "a5";
orientation?: "portrait" | "landscape";
margins?: {
top?: string;
bottom?: string;
left?: string;
right?: string;
};
gutter?: string;
shuffle?: boolean;
seed?: number;
showCutMarks?: boolean;
showRegistration?: boolean;
fontFamily?: string;
fontSize?: string;
columns?: string | number;
showEmptyColumns?: boolean;
hideInactiveBeads?: boolean;
beadShape?: "diamond" | "circle" | "square";
colorScheme?: "monochrome" | "place-value" | "heaven-earth" | "alternating";
coloredNumerals?: boolean;
scaleFactor?: number;
}
export interface FlashcardResponse {
pdf: string; // base64 encoded PDF
count: number;
numbers: number[];
}
export class SorobanFlashcardClient {
private apiUrl: string;
constructor(apiUrl: string = "http://localhost:8000") {
this.apiUrl = apiUrl;
}
/**
* Generate flashcards and return as base64 PDF
*/
async generate(config: FlashcardConfig): Promise<FlashcardResponse> {
const response = await fetch(`${this.apiUrl}/generate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
range: config.range,
step: config.step ?? 1,
cards_per_page: config.cardsPerPage ?? 6,
paper_size: config.paperSize ?? "us-letter",
orientation: config.orientation ?? "portrait",
margins: config.margins ?? {
top: "0.5in",
bottom: "0.5in",
left: "0.5in",
right: "0.5in",
},
gutter: config.gutter ?? "5mm",
shuffle: config.shuffle ?? false,
seed: config.seed,
show_cut_marks: config.showCutMarks ?? false,
show_registration: config.showRegistration ?? false,
font_family: config.fontFamily ?? "DejaVu Sans",
font_size: config.fontSize ?? "48pt",
columns: config.columns ?? "auto",
show_empty_columns: config.showEmptyColumns ?? false,
hide_inactive_beads: config.hideInactiveBeads ?? false,
bead_shape: config.beadShape ?? "diamond",
color_scheme: config.colorScheme ?? "monochrome",
colored_numerals: config.coloredNumerals ?? false,
scale_factor: config.scaleFactor ?? 0.9,
format: "base64",
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || "Failed to generate flashcards");
}
return response.json();
}
/**
* Generate flashcards and download as PDF file
*/
async generateAndDownload(
config: FlashcardConfig,
filename: string = "flashcards.pdf",
): Promise<void> {
const result = await this.generate(config);
// Convert base64 to blob
const pdfBytes = atob(result.pdf);
const byteArray = new Uint8Array(pdfBytes.length);
for (let i = 0; i < pdfBytes.length; i++) {
byteArray[i] = pdfBytes.charCodeAt(i);
}
const blob = new Blob([byteArray], { type: "application/pdf" });
// Create download link
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Generate flashcards and open in new tab
*/
async generateAndOpen(config: FlashcardConfig): Promise<void> {
const result = await this.generate(config);
// Convert base64 to blob
const pdfBytes = atob(result.pdf);
const byteArray = new Uint8Array(pdfBytes.length);
for (let i = 0; i < pdfBytes.length; i++) {
byteArray[i] = pdfBytes.charCodeAt(i);
}
const blob = new Blob([byteArray], { type: "application/pdf" });
// Open in new tab
const url = URL.createObjectURL(blob);
window.open(url, "_blank");
}
/**
* Check API health
*/
async health(): Promise<boolean> {
try {
const response = await fetch(`${this.apiUrl}/health`);
const data = await response.json();
return data.status === "healthy";
} catch {
return false;
}
}
}
// Example usage function
export async function example() {
const client = new SorobanFlashcardClient();
// Generate and download flashcards for 0-99 with place-value coloring
await client.generateAndDownload({
range: "0-99",
cardsPerPage: 6,
colorScheme: "place-value",
coloredNumerals: true,
showCutMarks: true,
});
// Generate counting by 5s
await client.generateAndDownload(
{
range: "0-100",
step: 5,
cardsPerPage: 6,
},
"counting-by-5s.pdf",
);
}

View File

@@ -1,303 +0,0 @@
#!/usr/bin/env python3
"""
Python bridge for Node.js integration
Provides a clean function interface instead of CLI
"""
import json
import sys
import base64
import tempfile
import os
import glob
from pathlib import Path
import subprocess
# Import our existing functions
from generate import parse_range, generate_typst_file, generate_single_card_typst
def generate_flashcards_json(config_json):
"""
Generate flashcards from JSON config
Returns base64 encoded PDF
"""
config = json.loads(config_json)
# Parse numbers
numbers = parse_range(
config.get('range', '0-9'),
config.get('step', 1)
)
# Handle shuffle
if config.get('shuffle', False):
import random
if 'seed' in config:
random.seed(config['seed'])
random.shuffle(numbers)
# Build Typst config
typst_config = {
'cards_per_page': config.get('cardsPerPage', 6),
'paper_size': config.get('paperSize', 'us-letter'),
'orientation': config.get('orientation', 'portrait'),
'margins': config.get('margins', {
'top': '0.5in',
'bottom': '0.5in',
'left': '0.5in',
'right': '0.5in'
}),
'gutter': config.get('gutter', '5mm'),
'show_cut_marks': config.get('showCutMarks', False),
'show_registration': config.get('showRegistration', False),
'font_family': config.get('fontFamily', 'DejaVu Sans'),
'font_size': config.get('fontSize', '48pt'),
'columns': config.get('columns', 'auto'),
'show_empty_columns': config.get('showEmptyColumns', False),
'hide_inactive_beads': config.get('hideInactiveBeads', False),
'bead_shape': config.get('beadShape', 'diamond'),
'color_scheme': config.get('colorScheme', 'monochrome'),
'colored_numerals': config.get('coloredNumerals', False),
'scale_factor': config.get('scaleFactor', 0.9),
}
# Generate in core package directory to match main generator behavior
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
# Generate Typst file - same setup as main generate.py
core_package_root = Path(__file__).parent.parent # packages/core directory
# Create temp files in core package root, not temp directory
temp_typst = core_package_root / f'temp_flashcards_{os.getpid()}.typ'
temp_pdf = core_package_root / f'temp_flashcards_{os.getpid()}.pdf'
# Convert Python list to Typst array syntax (same as main generate.py)
if numbers:
numbers_str = '(' + ', '.join(str(n) for n in numbers) + ',)'
else:
numbers_str = '()'
# Create temp Typst with relative imports (works when run from core package root)
typst_content = f'''
#import "templates/flashcards.typ": generate-flashcards
#generate-flashcards(
{numbers_str},
cards-per-page: {typst_config['cards_per_page']},
paper-size: "{typst_config['paper_size']}",
orientation: "{typst_config['orientation']}",
margins: (
top: {typst_config['margins'].get('top', '0.5in')},
bottom: {typst_config['margins'].get('bottom', '0.5in')},
left: {typst_config['margins'].get('left', '0.5in')},
right: {typst_config['margins'].get('right', '0.5in')}
),
gutter: {typst_config['gutter']},
show-cut-marks: {str(typst_config['show_cut_marks']).lower()},
show-registration: {str(typst_config['show_registration']).lower()},
font-family: "{typst_config['font_family']}",
font-size: {typst_config['font_size']},
columns: {typst_config['columns']},
show-empty-columns: {str(typst_config['show_empty_columns']).lower()},
hide-inactive-beads: {str(typst_config['hide_inactive_beads']).lower()},
bead-shape: "{typst_config['bead_shape']}",
color-scheme: "{typst_config['color_scheme']}",
colored-numerals: {str(typst_config['colored_numerals']).lower()},
scale-factor: {typst_config['scale_factor']}
)
'''
with open(temp_typst, 'w') as f:
f.write(typst_content)
# Get format preference
output_format = config.get('format', 'pdf').lower()
temp_svg = None
try:
if output_format == 'svg':
# Generate SVG using Typst with page template for multi-page support
temp_svg = core_package_root / f'temp_flashcards_{os.getpid()}_{{p}}.svg'
result = subprocess.run(
['typst', 'compile', str(temp_typst), str(temp_svg), '--format', 'svg'],
capture_output=True,
text=True,
cwd=str(core_package_root)
)
if result.returncode != 0:
return json.dumps({
'error': f'Typst SVG compilation failed: {result.stderr}'
})
# Read SVG content - find the first generated page
svg_pattern = core_package_root / f'temp_flashcards_{os.getpid()}_*.svg'
import glob
svg_files = glob.glob(str(svg_pattern))
if not svg_files:
return json.dumps({
'error': 'No SVG files were generated'
})
# Read the first SVG file (page 1)
svg_file = Path(svg_files[0])
with open(svg_file, 'r', encoding='utf-8') as f:
svg_content = f.read()
# Clean up all generated SVG files
for svg_path in svg_files:
Path(svg_path).unlink()
result_data = {
'pdf': svg_content, # Keep field name for compatibility
'count': len(numbers),
'numbers': numbers[:100]
}
else:
# Generate PDF (default)
result = subprocess.run(
['typst', 'compile', str(temp_typst), str(temp_pdf)],
capture_output=True,
text=True,
cwd=str(core_package_root)
)
if result.returncode != 0:
return json.dumps({
'error': f'Typst compilation failed: {result.stderr}'
})
# Read and encode PDF
with open(temp_pdf, 'rb') as f:
pdf_bytes = f.read()
result_data = {
'pdf': base64.b64encode(pdf_bytes).decode('utf-8'),
'count': len(numbers),
'numbers': numbers[:100] # Limit preview
}
finally:
# Clean up temp files
for temp_file in [temp_typst, temp_pdf, temp_svg if output_format == 'svg' else None]:
if temp_file and temp_file.exists():
temp_file.unlink()
return json.dumps(result_data)
def generate_single_card_json(config_json):
"""
Generate a single card SVG from JSON config
Specifically for preview - always returns front side (abacus)
"""
config = json.loads(config_json)
# Extract the single number
number = config.get('number')
if number is None:
return json.dumps({'error': 'Missing number parameter'})
# Build Typst config optimized for preview display
typst_config = {
'bead_shape': config.get('beadShape', 'diamond'),
'color_scheme': config.get('colorScheme', 'monochrome'),
'color_palette': config.get('colorPalette', 'default'),
'colored_numerals': config.get('coloredNumerals', False),
'hide_inactive_beads': config.get('hideInactiveBeads', False),
'show_empty_columns': config.get('showEmptyColumns', False),
'columns': config.get('columns', 'auto'),
'transparent': config.get('transparent', False),
'card_width': '120pt', # Smaller card for larger abacus
'card_height': '160pt', # Smaller card for larger abacus
'font_size': config.get('fontSize', '48pt'),
'font_family': config.get('fontFamily', 'DejaVu Sans'),
'scale_factor': config.get('scaleFactor', 4.0), # Much larger scale for preview visibility
}
# Generate in core package directory
core_package_root = Path(__file__).parent.parent
temp_typst = core_package_root / f'temp_single_{number}_{os.getpid()}.typ'
temp_svg = core_package_root / f'temp_single_{number}_{os.getpid()}.svg'
try:
# Create single card content directly with correct template path
typst_content = f'''
#import "templates/single-card.typ": generate-single-card
#generate-single-card(
{number},
side: "front",
bead-shape: "{typst_config['bead_shape']}",
color-scheme: "{typst_config['color_scheme']}",
color-palette: "{typst_config['color_palette']}",
colored-numerals: {str(typst_config['colored_numerals']).lower()},
hide-inactive-beads: {str(typst_config['hide_inactive_beads']).lower()},
show-empty-columns: {str(typst_config['show_empty_columns']).lower()},
columns: {typst_config['columns']},
transparent: {str(typst_config['transparent']).lower()},
width: {typst_config['card_width']},
height: {typst_config['card_height']},
font-size: {typst_config['font_size']},
font-family: "{typst_config['font_family']}",
scale-factor: {typst_config['scale_factor']}
)
'''
with open(temp_typst, 'w') as f:
f.write(typst_content)
# Generate SVG
result = subprocess.run(
['typst', 'compile', str(temp_typst), str(temp_svg), '--format', 'svg'],
capture_output=True,
text=True,
cwd=str(core_package_root)
)
if result.returncode != 0:
return json.dumps({
'error': f'Typst SVG compilation failed: {result.stderr}'
})
# Read SVG content
if not temp_svg.exists():
return json.dumps({
'error': 'SVG file was not generated'
})
with open(temp_svg, 'r', encoding='utf-8') as f:
svg_content = f.read()
return json.dumps({
'pdf': svg_content, # Keep field name for compatibility
'count': 1,
'numbers': [number]
})
except Exception as e:
return json.dumps({'error': f'Single card generation failed: {str(e)}'})
finally:
# Clean up temp files
for temp_file in [temp_typst, temp_svg]:
if temp_file and temp_file.exists():
temp_file.unlink()
if __name__ == '__main__':
# Read JSON from stdin, write JSON to stdout
# This allows clean function-like communication
for line in sys.stdin:
try:
config = json.loads(line.strip())
# Check if this is a single-card generation request
if config.get('mode') == 'single-card':
result = generate_single_card_json(line.strip())
else:
result = generate_flashcards_json(line.strip())
print(result)
sys.stdout.flush()
except Exception as e:
print(json.dumps({'error': str(e)}))
sys.stdout.flush()

54
pnpm-lock.yaml generated
View File

@@ -119,9 +119,6 @@ importers:
'@soroban/abacus-react':
specifier: workspace:*
version: link:../../packages/abacus-react
'@soroban/client':
specifier: workspace:*
version: link:../../packages/core/client/typescript
'@soroban/core':
specifier: workspace:*
version: link:../../packages/core/client/node
@@ -380,42 +377,7 @@ importers:
specifier: ^1.0.0
version: 1.6.1(@types/node@20.19.19)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jsdom@27.0.0(postcss@8.5.6))(terser@5.44.0)
packages/core/client/browser:
dependencies:
'@myriaddreamin/typst-ts-renderer':
specifier: ^0.6.0
version: 0.6.0
'@myriaddreamin/typst-ts-web-compiler':
specifier: ^0.6.0
version: 0.6.0
devDependencies:
typescript:
specifier: ^5.0.0
version: 5.9.3
vite:
specifier: ^5.0.0
version: 5.4.20(@types/node@20.19.19)(terser@5.44.0)
packages/core/client/node:
dependencies:
python-shell:
specifier: ^5.0.0
version: 5.0.0
devDependencies:
'@types/node':
specifier: ^20.0.0
version: 20.19.19
tsup:
specifier: ^7.0.0
version: 7.3.0(postcss@8.5.6)(typescript@5.9.3)
typescript:
specifier: ^5.0.0
version: 5.9.3
vitest:
specifier: ^1.0.0
version: 1.6.1(@types/node@20.19.19)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jsdom@27.0.0(postcss@8.5.6))(terser@5.44.0)
packages/core/client/typescript:
devDependencies:
'@types/node':
specifier: ^20.0.0
@@ -1951,12 +1913,6 @@ packages:
'@types/react': '>=16'
react: '>=16'
'@myriaddreamin/typst-ts-renderer@0.6.0':
resolution: {integrity: sha512-56Mids4E5Ob6LeEeXDedvmsVnEWnLmc1qeUOeUSruL/zI3S9QXleF/c3Os1FXwJmLuCFbWTEIq8Quh2cXlnxKw==}
'@myriaddreamin/typst-ts-web-compiler@0.6.0':
resolution: {integrity: sha512-P/eIJ5RnfElj0NYzn5PI296t/IwWtgqUyyTMi5Jm5X3V5kZfskkH+LI7mSQe8tEyxwgCvxbxvFe5adinA3K8Gg==}
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@@ -7767,10 +7723,6 @@ packages:
python-bridge@1.1.0:
resolution: {integrity: sha512-qjQ0QB8p9cn/XDeILQH0aP307hV58lrmv0Opjyub68Um7FHdF+ZXlTqyxNkKaXOFk2QSkScoPWwn7U9GGnrkeQ==}
python-shell@5.0.0:
resolution: {integrity: sha512-RUOOOjHLhgR1MIQrCtnEqz/HJ1RMZBIN+REnpSUrfft2bXqXy69fwJASVziWExfFXsR1bCY0TznnHooNsCo0/w==}
engines: {node: '>=0.10'}
qs@6.13.0:
resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
engines: {node: '>=0.6'}
@@ -10808,10 +10760,6 @@ snapshots:
'@types/react': 18.3.26
react: 18.3.1
'@myriaddreamin/typst-ts-renderer@0.6.0': {}
'@myriaddreamin/typst-ts-web-compiler@0.6.0': {}
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.5.0
@@ -17636,8 +17584,6 @@ snapshots:
dependencies:
bluebird: 3.7.2
python-shell@5.0.0: {}
qs@6.13.0:
dependencies:
side-channel: 1.1.0