Compare commits

...

86 Commits

Author SHA1 Message Date
semantic-release-bot
ddbaf55aa2 chore(release): 2.3.0 [skip ci]
## [2.3.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.2.1...v2.3.0) (2025-10-07)

### Features

* add Biome + ESLint linting setup ([fc1838f](fc1838f4f5))

### Styles

* apply Biome formatting to entire codebase ([60d70cd](60d70cd2f2))
2025-10-07 17:49:19 +00:00
Thomas Hallock
60d70cd2f2 style: apply Biome formatting to entire codebase
Run Biome formatter on all files to ensure consistent code style:
- Single quotes for JS/TS
- Double quotes for JSX
- 2-space indentation
- 100 character line width
- Semicolons as needed
- ES5 trailing commas

This is the result of running: npx @biomejs/biome format . --write

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 12:48:26 -05:00
Thomas Hallock
fc1838f4f5 feat: add Biome + ESLint linting setup
Add Biome for formatting and general linting, with minimal ESLint
configuration for React Hooks rules only. This provides:

- Fast formatting via Biome (10-100x faster than Prettier)
- General JS/TS linting via Biome
- React Hooks validation via ESLint (rules-of-hooks)
- Import organization via Biome

Configuration files:
- biome.jsonc: Biome config with custom rule overrides
- eslint.config.js: Minimal flat config for React Hooks only
- .gitignore: Added Biome cache exclusion
- LINTING.md: Documentation for the setup

Scripts added to package.json:
- npm run lint: Check all files
- npm run lint:fix: Auto-fix issues
- npm run format: Format all files
- npm run check: Full Biome check

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 12:48:26 -05:00
semantic-release-bot
3c245d29fa chore(release): 2.2.1 [skip ci]
## [2.2.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.2.0...v2.2.1) (2025-10-07)

### Bug Fixes

* remove remaining typst-dependent files ([d1b9b72](d1b9b72cfc))
2025-10-07 15:47:29 +00:00
Thomas Hallock
d1b9b72cfc fix: remove remaining typst-dependent files
Remove preview API route and template-demo page that still
referenced the deleted typst-soroban library.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:46:46 -05:00
semantic-release-bot
3c00ebfe2f chore(release): 2.2.0 [skip ci]
## [2.2.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.3...v2.2.0) (2025-10-07)

### Features

* remove typst-related code and routes ([be6fb1a](be6fb1a881))
2025-10-07 15:42:43 +00:00
Thomas Hallock
be6fb1a881 feat: remove typst-related code and routes
Remove all typst-related files, API routes, and components.
This completes the typst dependency removal.

Removed:
- apps/web/src/app/api/typst-svg/route.ts
- apps/web/src/app/api/typst-template/route.ts
- apps/web/src/lib/typst-soroban.ts
- apps/web/src/components/TypstSoroban.tsx
- apps/web/src/app/test-typst/
- apps/web/src/app/typst-gallery/
- apps/web/src/app/typst-playground/

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:41:44 -05:00
semantic-release-bot
e157bbff43 chore(release): 2.1.3 [skip ci]
## [2.1.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.2...v2.1.3) (2025-10-07)

### Bug Fixes

* remove .npmrc from Dockerfile COPY ([e71c2b4](e71c2b4da8))
2025-10-07 15:37:01 +00:00
Thomas Hallock
e71c2b4da8 fix: remove .npmrc from Dockerfile COPY
.npmrc no longer exists after reverting to default pnpm mode.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:36:10 -05:00
semantic-release-bot
40cbe96385 chore(release): 2.1.2 [skip ci]
## [2.1.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.1...v2.1.2) (2025-10-07)

### Bug Fixes

* revert to default pnpm mode for Docker compatibility ([bd0092e](bd0092e69a))
2025-10-07 15:34:15 +00:00
Thomas Hallock
bd0092e69a fix: revert to default pnpm mode for Docker compatibility
Hoisted mode is incompatible with Docker's overlay filesystem.
Remove .npmrc and regenerate lockfile with default isolated mode.

This maintains semantic-release functionality while allowing
Docker builds to succeed.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:33:26 -05:00
semantic-release-bot
f9262a2c83 chore(release): 2.1.1 [skip ci]
## [2.1.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.0...v2.1.1) (2025-10-07)

### Bug Fixes

* ignore all node_modules in Docker ([4792dde](4792dde1be))
2025-10-07 15:28:57 +00:00
Thomas Hallock
4792dde1be fix: ignore all node_modules in Docker
Docker overlay filesystem conflicts with local node_modules structure,
regardless of whether it's hoisted mode or not. Ignore all node_modules
and rely on the base stage installation.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:28:09 -05:00
semantic-release-bot
f91248b0bb chore(release): 2.1.0 [skip ci]
## [2.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.7...v2.1.0) (2025-10-07)

### Features

* remove typst dependencies ([eedce28](eedce28572))
2025-10-07 15:23:47 +00:00
Thomas Hallock
eedce28572 feat: remove typst dependencies
Remove @myriaddreamin/typst-* packages that are no longer needed.
This eliminates Docker overlay conflicts with hoisted node_modules.

Removed packages (-365):
- @myriaddreamin/typst-all-in-one.ts
- @myriaddreamin/typst-ts-renderer
- @myriaddreamin/typst-ts-web-compiler
- @myriaddreamin/typst.ts

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:22:48 -05:00
semantic-release-bot
d84bf9c845 chore(release): 2.0.7 [skip ci]
## [2.0.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.6...v2.0.7) (2025-10-07)

### Bug Fixes

* preserve workspace node_modules in Docker for hoisted mode ([4f8aaf0](4f8aaf04aa))
2025-10-07 15:15:43 +00:00
Thomas Hallock
4f8aaf04aa fix: preserve workspace node_modules in Docker for hoisted mode
With hoisted mode, each workspace needs its own node_modules folder
(containing symlinks). Only ignore root /node_modules.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:14:47 -05:00
semantic-release-bot
a43c8654e1 chore(release): 2.0.6 [skip ci]
## [2.0.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.5...v2.0.6) (2025-10-07)

### Bug Fixes

* ignore nested node_modules in Docker ([f554592](f554592272))
2025-10-07 15:11:09 +00:00
Thomas Hallock
f554592272 fix: ignore nested node_modules in Docker
Add **/node_modules pattern to prevent Docker overlay conflicts
when hoisted mode creates nested symlink structures.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:10:23 -05:00
semantic-release-bot
b073b9e1ec chore(release): 2.0.5 [skip ci]
## [2.0.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.4...v2.0.5) (2025-10-07)

### Bug Fixes

* use .npmrc in Docker for hoisted mode consistency ([2df8cdc](2df8cdc88e))
2025-10-07 15:06:45 +00:00
Thomas Hallock
2df8cdc88e fix: use .npmrc in Docker for hoisted mode consistency
The pnpm lockfile was generated with hoisted mode, so Docker must
also use hoisted mode to match the module resolution paths.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:05:56 -05:00
semantic-release-bot
e73afdb913 chore(release): 2.0.4 [skip ci]
## [2.0.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.3...v2.0.4) (2025-10-07)

### Bug Fixes

* remove .npmrc in Docker to avoid hoisted mode issues ([2a77d75](2a77d755b7))
2025-10-07 15:02:36 +00:00
Thomas Hallock
2a77d755b7 fix: remove .npmrc in Docker to avoid hoisted mode issues
Docker builds should use default pnpm isolated mode, not hoisted mode
which causes tsup module resolution failures.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 10:01:39 -05:00
semantic-release-bot
f4ab0ff9ba chore(release): 2.0.3 [skip ci]
## [2.0.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.2...v2.0.3) (2025-10-07)

### Bug Fixes

* remove duplicate PlayerStatusBar story file from arcade ([4e721f7](4e721f765a))
2025-10-07 14:58:20 +00:00
Thomas Hallock
4e721f765a fix: remove duplicate PlayerStatusBar story file from arcade
Remove apps/web/src/app/arcade/matching/components/PlayerStatusBar.stories.tsx
to fix Storybook build error about duplicate story IDs.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:57:17 -05:00
semantic-release-bot
8bd6d6d8b7 chore(release): 2.0.2 [skip ci]
## [2.0.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.1...v2.0.2) (2025-10-07)

### Bug Fixes

* update Dockerfile pnpm version and fix TypeScript config ([43077a8](43077a80e2))
2025-10-07 14:54:44 +00:00
Thomas Hallock
43077a80e2 fix: update Dockerfile pnpm version and fix TypeScript config
- Upgrade Dockerfile from pnpm 8.0.0 to 9.15.4 for lockfile compatibility
- Add "types": [] to abacus-react tsconfig to prevent implicit @types includes
- Fixes Docker build lockfile incompatibility
- Fixes TypeScript error looking for @types/minimatch

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:54:04 -05:00
semantic-release-bot
1f527581b8 chore(release): 2.0.1 [skip ci]
## [2.0.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.0...v2.0.1) (2025-10-07)

### Bug Fixes

* add @types/minimatch to abacus-react devDependencies ([fa45475](fa4547543d))
2025-10-07 14:47:53 +00:00
Thomas Hallock
fa4547543d fix: add @types/minimatch to abacus-react devDependencies
- TypeScript was looking for minimatch type definitions
- Hoisted mode made this implicit dependency explicit
- Fixes abacus-react build failure in CI

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:47:04 -05:00
semantic-release-bot
81f50323ad chore(release): 2.0.0 [skip ci]
## [2.0.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v1.2.1...v2.0.0) (2025-10-07)

### ⚠ BREAKING CHANGES

* abacus-react package now has independent versioning from monorepo

### Features

* **abacus-react:** add dual publishing to npm and GitHub Packages ([242ee52](242ee523ed))
* **abacus-react:** comprehensive README overhaul with current capabilities ([0ce351e](0ce351e572))
* **abacus-react:** configure GitHub Packages-only publishing workflow ([5eeedd9](5eeedd9a59))
* **abacus-react:** enable dual publishing to npm and GitHub Packages ([176a196](176a1961d0))
* **abacus-react:** enhance package description with semantic versioning details ([af037b5](af037b5e0a))
* **abacus-react:** implement GitHub Packages-only publishing workflow ([b194599](b194599f60))
* **abacus-react:** implement GitHub-only semantic release with manual package publishing ([33b0567](33b0567698))
* **abacus-react:** simplify to GitHub Packages-only publishing ([acc126b](acc126bd5a))
* **abacus-react:** update description to mention GitHub Packages support ([af77256](af7725622e))
* **abacus-react:** use environment variables to override npm registry ([ad444e1](ad444e108f))
* add API routes for players and user stats ([6f940e2](6f940e24d6))
* add arcade matching game components and utilities ([ff16303](ff16303a7c))
* add arcade room system database schema and managers (Phase 1) ([a9175a0](a9175a050c))
* add build info API endpoint ([571664e](571664e725))
* add build info generation script ([416dc89](416dc897e2))
* add category browsing and scrolling to emoji picker ([616a50e](616a50e234))
* add complement display options and unify equation display ([2ed7b2c](2ed7b2cbf8))
* add Complement Race game with three unique game modes ([582bce4](582bce411f))
* add comprehensive E2E testing with Playwright ([d58053f](d58053fad3))
* add comprehensive Storybook stories for PlayerStatusBar ([8973241](8973241297))
* add configuration access to active player emojis in prominent nav ([6049a7f](6049a7f6b7))
* add configuration access to fullscreen player selection ([b85968b](b85968bcb6))
* add consecutive match tracking system for escalating celebrations ([111c0ce](111c0ced71))
* add CSS animations and visual feedback system ([80e33e2](80e33e25b3))
* add deployment info modal with keyboard shortcut ([43be7ac](43be7ac83a))
* add direct URL routes for each game mode ([a08f053](a08f0535bf))
* add exitSession to MemoryPairsContextValue interface ([abc2ea5](abc2ea50d0))
* add GameControlButtons component with unit tests ([1f45c17](1f45c17e0a))
* add guest session system with JWT tokens ([10d8aaf](10d8aaf814))
* add initialStyle prop to ComplementRaceProvider ([f3bc2f6](f3bc2f6d92))
* add intelligent on-screen number pad for devices without keyboards ([d4740ff](d4740ff997))
* add interactive remove buttons for players in mini nav ([fa1cf96](fa1cf96789))
* add magnifying glass preview on emoji hover ([2c88b6b](2c88b6b5f3))
* add middleware for pathname header support in [@nav](https://github.com/nav) fallback ([b7e7c4b](b7e7c4beff))
* add mini app nav to arcade page ([a854fe3](a854fe3dc9))
* add optimistic updates and remove dead code ([b62cf26](b62cf26fb6))
* add passenger boarding system with station-based pickup ([23a9016](23a9016245))
* add player types and migration utilities ([79f44b2](79f44b25d6))
* add PlayerStatusBar with escalating celebration animations ([7f8c90a](7f8c90acea))
* add prominent game context display to mini nav with smooth transitions ([8792393](8792393956))
* add React Query setup with api helper ([a3878a8](a3878a8537))
* add realistic mountains with peaks and ground terrain ([99cdfa8](99cdfa8a0b))
* add security tests and userId injection protection ([aa1ad31](aa1ad315ef))
* add server-side validation for player modifications during active arcade sessions ([3b3cad4](3b3cad4b76))
* add Setup button to exit arcade sessions ([ae1318e](ae1318e8bf))
* add smooth spring animations to pressure gauge ([863a2e1](863a2e1319))
* add sound settings support to AbacusReact component ([90b9ffa](90b9ffa0d8))
* add train car system with smooth boarding/disembarking animations ([1613912](1613912740))
* add Web Audio API sound effects system with 16 sound types ([90ba866](90ba86640c))
* **complement-race:** add abacus displays to pressure gauge ([c5ebc63](c5ebc635af))
* complete themed navigation system with game-specific chrome ([0a4bf17](0a4bf1765c))
* create mode selection landing page for Complement Race ([1ff9695](1ff9695f69))
* create PlayerConfigDialog component for player customization ([4f2a661](4f2a661494))
* create StandardGameLayout for perfect viewport sizing ([728a920](728a92076a))
* display passengers visually on train and at stations ([1599904](159990489f))
* dynamic player card rendering on games page ([81d17f2](81d17f2397))
* dynamically calculate train cars based on max concurrent passengers ([9ea1553](9ea15535d1))
* emit session-state after creating arcade session ([70d6f43](70d6f43d6d))
* enable prominent nav and fix layout on arcade page ([5c8c18c](5c8c18cbb8))
* enhance emoji picker with super powered magnifying glass and hide empty categories ([d8b4e42](d8b4e425bf))
* enhance passenger card UI with boarding status indicators ([4bbdabc](4bbdabc3b5))
* extend ground terrain to cover entire track area ([ee48417](ee48417abf))
* extend player customization to support all 4 players ([72f8dee](72f8dee183))
* extend railroad track to viewport edges ([eadd7da](eadd7da6db))
* extend track and tunnels to absolute viewport edges ([f7419bc](f7419bc6a0))
* implement [@nav](https://github.com/nav) parallel routes for game name display in mini navigation ([885fc72](885fc725dc))
* implement cozy sound effects for abacus with variable intensity ([c95be1d](c95be1df6d))
* implement cozy sound effects for abacus with variable intensity ([cea5fad](cea5fadbe4))
* implement game control callbacks in MemoryPairsGame ([4758ad2](4758ad2f26))
* implement game theming system with context-based navigation chrome ([3fa11c4](3fa11c4fbc))
* implement innovative dynamic two-panel layout for on-screen keyboard ([4bb8f6d](4bb8f6daf1))
* implement mobile-first responsive design for speed memory quiz ([13efc4d](13efc4d070))
* implement simple fixed bottom keyboard bar ([9ef72d7](9ef72d7e88))
* implement smooth train exit with fade-out through right tunnel ([0176694](01766944f0))
* implement toggleable on-screen keyboard to prevent UI overlap ([701d23c](701d23c369))
* improve game availability logic and messaging ([9a3fa93](9a3fa93e53))
* increase landmark emoji size for better visibility ([0bcd7a3](0bcd7a30d4))
* integrate GameControlButtons into navigation ([fbd8cd4](fbd8cd4a6b))
* integrate remaining game sound effects ([600bc35](600bc35bc3))
* integrate sound effects into game flow (countdown, answers, performance) ([8c3a855](8c3a855239))
* integrate user profiles with PlayerStatusBar and game results ([beff646](beff64652c))
* make Steam Sprint infinite mode ([32c3a35](32c3a35eab))
* make SVG span full viewport width for sprint mode ([7488bb3](7488bb3803))
* migrate abacus display settings to database ([92ef136](92ef1360a4))
* migrate contexts to React Query (remove localStorage) ([fe01a1f](fe01a1fe18))
* migrate contexts to UUID-based player system ([2b94cad](2b94cad11b))
* preserve track and passengers during route transitions ([f2e7165](f2e71657dc))
* redesign passenger cards with vintage train station aesthetic ([651bc21](651bc21583))
* set up automated npm publishing for @soroban/abacus-react package ([dd80d29](dd80d29c97))
* set up Drizzle ORM with SQLite database ([5d5afd4](5d5afd4e68))
* skip countdown for train mode (sprint) ([65dafc9](65dafc9215))
* skip intro screen and start directly at game setup ([4b6888a](4b6888af05))
* sync URL with selected game mode ([3920bba](3920bbad33))
* UI polish for Sprint mode (viewport, backgrounds, data attributes) ([90ad789](90ad789ff1))
* update nav components for UUID players ([e85d041](e85d0415f2))
* use CSS transitions for smooth fullscreen player selection collapse ([3189832](31898328a3))
* wire player configuration through nav component hierarchy ([edfdd81](edfdd81227))

### Bug Fixes

* **abacus-react:** add debugging and explicit authentication for npm publish ([b82e9bb](b82e9bb9d6))
* **abacus-react:** add packages: write permission for GitHub Packages publishing ([8e16487](8e1648737d))
* **abacus-react:** apply global columnPosts styling and fix reckoning bar width ([bb9959f](bb9959f7fb))
* **abacus-react:** force npm to use GitHub Packages registry ([5e6c901](5e6c901f73))
* **abacus-react:** improve publishing workflow with better version sync ([7a4ecd2](7a4ecd2b59))
* **abacus-react:** improve workspace dependency cleanup and add validation ([11fd6f9](11fd6f9b3d))
* **abacus-react:** resolve workspace dependencies before npm publish ([834b062](834b062b2d))
* **abacus-react:** simplify semantic-release config to resolve dependency issues ([88cab38](88cab380ef))
* **abacus-react:** temporarily allow test failures during setup phase ([e3db7f4](e3db7f4daf))
* add CLEAR_MISMATCH move to allow mismatch feedback to auto-dismiss ([158f527](158f52773d))
* add missing GameThemeContext file for themed navigation ([d4fbdd1](d4fbdd1463))
* add npmrc for hoisting and fix template paths ([5c65ac5](5c65ac5caa))
* add Python setuptools and build tools for better-sqlite3 compilation ([a216a3d](a216a3d343))
* add testing mode for on-screen keyboard and fix toggle functionality ([904074c](904074ca82))
* align all bottom UI elements to same 20px baseline ([076c97a](076c97abac))
* align bottom-positioned UI elements ([227cfab](227cfabf11))
* allow navigation to game setup pages without active session ([c7ad3c0](c7ad3c0695))
* change pressure gauge to fixed positioning to stay above terrain ([1b11031](1b11031598))
* change question display to fixed positioning with higher z-index ([4ac8758](4ac8758957))
* **complement-race:** improve abacus display in equations ([491b299](491b299e28))
* **complement-race:** prevent passengers being left behind at delivery stations ([e6ebecb](e6ebecb09b))
* correct emoji category group IDs to match Unicode CLDR ([b2a21b7](b2a21b79ad))
* defer URL update until game starts ([12c54b2](12c54b27b7))
* delay passenger display update until train resets ([e06a750](e06a750454))
* disable turn validation in arcade mode matching game ([7c0e6b1](7c0e6b142b))
* eliminate rail jaggies on sharp curves by increasing sampling density ([46d4af2](46d4af2bda))
* enable shamefully-hoist for semantic-release dependencies ([6168c29](6168c292d5))
* enforce playerId must be explicitly provided in arcade moves ([d5a8a2a](d5a8a2a14c))
* ensure consistent r×c grid layout for memory matching game ([f1a0633](f1a0633596))
* ensure game names persist in navigation on page reload ([9191b12](9191b12493))
* ensure passengers only travel forward on train route ([8ad3144](8ad3144d2d))
* export missing hooks and types from @soroban/abacus-react package ([423ba55](423ba55350))
* implement route-based theme detection for page reload persistence ([3dcff2f](3dcff2ff88))
* improve navigation chrome background color extraction from gradients ([00bfcbc](00bfcbcdee))
* increase question display zIndex to stay above terrain ([8c8b8e0](8c8b8e08b4))
* lazy-load database connection to prevent build-time access ([af8d993](af8d993628))
* make results screen compact to fit viewport without scrolling ([9d4cba0](9d4cba05be))
* migrate viewport from metadata to separate viewport export ([1fe12c4](1fe12c4837))
* move auth.ts to src/ to match @/ path alias ([7829d8a](7829d8a0fb))
* move fontWeight to style object for station names ([05a3ddb](05a3ddb086))
* only show configuration gear icon for players 1 and 2 ([d0a3bc7](d0a3bc7dc1))
* pass player IDs (not user IDs) in all arcade game moves ([d00abd2](d00abd25e7))
* passengers now board/disembark based on their car position, not locomotive ([96782b0](96782b0e7a))
* position tunnels at absolute viewBox edges ([1a5fa28](1a5fa2873b))
* prevent layout shift when selecting Steam Sprint mode ([73a5974](73a59745a5))
* prevent multiple passengers from boarding same car in single frame ([63b0b55](63b0b552a8))
* prevent premature passenger display during route transitions ([fe9ea67](fe9ea67f56))
* prevent random passenger repopulation during route transitions ([db56ce8](db56ce89ee))
* prevent route celebration from immediately reappearing ([1a80934](1a8093416e))
* redesign matching game setup page for StandardGameLayout ([cc1f27f](cc1f27f0f8))
* reduce landmark size from 4.0x to 2.0x multiplier ([c928e90](c928e90785))
* regenerate lockfile with correct dependency order ([51bf448](51bf448c9f))
* regenerate lockfile with node-linker=hoisted from scratch ([480960c](480960c2c8))
* regenerate pnpm lockfile for pnpm 9 compatibility ([4ab1aef](4ab1aef9b8))
* remove double PageWithNav wrapper on matching page ([b58bcd9](b58bcd92ee))
* remove duplicate CAR_SPACING and MAX_CARS declarations ([e704a28](e704a28524))
* remove duplicate previousPassengersRef declaration ([fad8636](fad8636763))
* remove frozen lockfile flag from publishing workflow to resolve dependency installation issues ([18af973](18af9730ff))
* remove hard-coded car count from game loop ([6c90a68](6c90a68c49))
* remove unnecessary zIndex from question display ([db52e14](db52e14dfe))
* reposition on-screen keyboard to avoid covering abacus tiles ([6e5b4ec](6e5b4ec7bf))
* require activePlayers in START_GAME, never fallback to userId ([ea1b1a2](ea1b1a2f69))
* reset momentum and pressure when starting new route ([3ea88d7](3ea88d7a5a))
* resolve circular dependency errors in memory quiz on-screen keyboard ([d25e2c4](d25e2c4c00))
* resolve JSX parsing error with emoji in guide page ([bf046c9](bf046c999b))
* resolve mini navigation game name persistence across all routes ([3fa314a](3fa314aaa5))
* resolve runtime error - calculateOptimalGrid not defined ([fbc84fe](fbc84febda))
* resolve SSR/client hydration mismatch for themed navigation ([301e65d](301e65dfa6))
* resolve TypeScript errors in PlayerStatusBar component ([a935e5a](a935e5aed8))
* restore navigation to all pages using PageWithNav ([183706d](183706dade))
* show "Return to Arcade" button only during active game ([4153929](4153929a2a))
* smooth rail curves and deterministic track generation ([4f79c08](4f79c08d73))
* stabilize route completion threshold to prevent stuck trains ([b7233f9](b7233f9e4a))
* update lockfile and fix Makefile paths ([7ba746b](7ba746b6bd))
* update matching game for UUID player system ([2e041dd](2e041ddc44))
* update memory pairs game to use StandardGameLayout ([8df76c0](8df76c08fd))
* update memory quiz to use StandardGameLayout ([3f86163](3f86163c14))
* update pnpm version to 8.15.6 to resolve ERR_INVALID_THIS error in workflow ([0b9bfed](0b9bfed12d))
* update race track components for new player system ([ae4e8fc](ae4e8fcb5a))
* update tutorial tests to use consolidated AbacusDisplayProvider ([899fc69](899fc6975f))
* update workflow to support Personal Access Token for GitHub Packages publishing auth ([ae4b71b](ae4b71b986))
* upgrade CI dependencies and fix deprecated actions ([6a51c1e](6a51c1e9bd))
* use displayPassengers for station rendering in RailroadTrackPath ([a9e0d19](a9e0d19734))
* use node-linker=hoisted for full dependency hoisting ([d3b2e0b](d3b2e0b2e1))
* use player IDs instead of array indices in matching game ([ccd0d6d](ccd0d6d94c))
* use style fontSize instead of attribute for landmarks ([ebc6894](ebc6894746))
* use UUID player IDs in session creation fallback ([22541df](22541df99f))
* wrap animated pressure value in animated.span to prevent React error ([5c5954b](5c5954be74))

### Performance Improvements

* optimize React rendering with memoization and consolidated effects ([93cb070](93cb070ca5))

### Code Refactoring

* completely remove [@nav](https://github.com/nav) parallel routes and simplify navigation ([54ff20c](54ff20c755))
* consolidate abacus display context management ([a387b03](a387b030fa))
* extract ActivePlayersList component from PageWithNav ([2849576](28495767a9))
* extract AddPlayerButton component from PageWithNav ([57a72e3](57a72e34a5))
* extract FullscreenPlayerSelection component from PageWithNav ([66f5223](66f52234e1))
* extract GameContextNav orchestration component ([e3f552d](e3f552d8f5))
* extract GameHUD component from SteamTrainJourney ([78d5234](78d5234a79))
* extract GameModeIndicator component from PageWithNav ([d67315f](d67315f771))
* extract guide components to fix syntax error in large file ([c77e880](c77e880be3))
* extract RailroadTrackPath component from SteamTrainJourney ([d9acc0e](d9acc0efea))
* extract TrainAndCars component from SteamTrainJourney ([5ae22e4](5ae22e4645))
* extract TrainTerrainBackground component from SteamTrainJourney ([05bb035](05bb035db5))
* extract usePassengerAnimations hook from SteamTrainJourney ([32abde1](32abde107c))
* extract useTrackManagement hook from SteamTrainJourney ([a1f2b97](a1f2b9736a))
* extract useTrainTransforms hook from SteamTrainJourney ([a2512d5](a2512d5738))
* make game mode a computed property from active player count ([386c88a](386c88a3c0))
* remove drag-and-drop UI from EnhancedChampionArena ([982fa45](982fa45c08))
* remove duplicate game control buttons from game phases ([9165014](9165014997))
* remove redundant game titles from game screens ([402724c](402724c80e))
* replace bulky MemoryGrid stats with compact progress display ([c4d6691](c4d6691715))
* simplify navigation flow and enhance GameControls UI ([920aaa6](920aaa6398))
* simplify PageWithNav by extracting nav components ([98cfa56](98cfa5645b))
* split deployment info into server/client components ([5e7b273](5e7b273b33))
* streamline GamePhase header and integrate PlayerStatusBar ([dcefa74](dcefa74902))
* streamline UI and remove duplicate information displays ([7a3e34b](7a3e34b4fa))

### Documentation

* add comprehensive workflow documentation for automated npm publishing ([f923b53](f923b53a44))
* add server persistence migration plan ([dd0df8c](dd0df8c274))

### Tests

* add comprehensive unit tests for refactored hooks and components ([5d20839](5d2083903e))
* add E2E tests for arcade modal session behavior ([619be98](619be9859c))
2025-10-07 14:34:57 +00:00
Thomas Hallock
480960c2c8 fix: regenerate lockfile with node-linker=hoisted from scratch
- Delete and regenerate pnpm-lock.yaml to ensure clean state
- All deps now properly hoisted to root node_modules
- conventional-changelog-conventionalcommits now accessible

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:32:21 -05:00
Thomas Hallock
d3b2e0b2e1 fix: use node-linker=hoisted for full dependency hoisting
- Change from shamefully-hoist to node-linker=hoisted
- This creates a flat node_modules structure like npm
- Should fix semantic-release module resolution in CI

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:27:51 -05:00
Thomas Hallock
6168c292d5 fix: enable shamefully-hoist for semantic-release dependencies
- Switch .npmrc to shamefully-hoist=true for better compatibility
- This ensures all dependencies are hoisted to root node_modules
- Fixes module resolution issues in CI for semantic-release plugins

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:23:25 -05:00
Thomas Hallock
5c65ac5caa fix: add npmrc for hoisting and fix template paths
- Add .npmrc with public-hoist-pattern for semantic-release deps
- Fix project_root path in generate.py for monorepo structure
- Templates are at packages/templates/ not packages/core/templates/
- Fonts are at monorepo root fonts/ directory

Fixes:
- semantic-release conventional-changelog-conventionalcommits not found
- FileNotFoundError for single-card.typ and flashcards.typ

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:19:45 -05:00
Thomas Hallock
51bf448c9f fix: regenerate lockfile with correct dependency order
- Remove and regenerate pnpm-lock.yaml to match exact package.json state
- Fixes ERR_PNPM_OUTDATED_LOCKFILE errors in CI

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:10:25 -05:00
Thomas Hallock
4ab1aef9b8 fix: regenerate pnpm lockfile for pnpm 9 compatibility
- Completely regenerate pnpm-lock.yaml with pnpm 9.15.4
- Fixes ERR_PNPM_OUTDATED_LOCKFILE for dependency order changes
- pnpm 9 is stricter about matching dependency specs order

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:07:51 -05:00
Thomas Hallock
7ba746b6bd fix: update lockfile and fix Makefile paths
- Update pnpm-lock.yaml with new semantic-release dependencies
- Fix Makefile paths to use packages/core/src/ instead of src/
- All Python scripts now reference correct monorepo structure

Fixes:
- ERR_PNPM_OUTDATED_LOCKFILE in CI workflows
- Missing generate_examples.py in verify-examples workflow

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:05:41 -05:00
Thomas Hallock
6a51c1e9bd fix: upgrade CI dependencies and fix deprecated actions
- Upgrade pnpm from 8.0.0 to 9.15.4 to fix ERR_INVALID_THIS registry errors
- Upgrade actions/upload-artifact from v3 to v4 (v3 deprecated)
- Add missing semantic-release peer dependencies:
  - @semantic-release/commit-analyzer@^11.0.0
  - @semantic-release/release-notes-generator@^12.0.0

Fixes GitHub Actions failures in:
- Deploy Storybooks workflow
- Release workflow
- Verify Examples workflow

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 09:02:22 -05:00
Thomas Hallock
af8d993628 fix: lazy-load database connection to prevent build-time access
Refactor db/index.ts to use lazy initialization via Proxy pattern.
This prevents the database from being accessed at module import time,
which was causing Next.js build failures in CI/CD environments where
no database file exists.

The database connection is now created only when first accessed at
runtime, allowing static site generation to complete successfully.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 08:41:06 -05:00
Thomas Hallock
a9175a050c feat: add arcade room system database schema and managers (Phase 1)
Implement foundational infrastructure for multi-room arcade system:

Database:
- Add arcade_rooms table for room metadata and lifecycle
- Add room_members table for membership tracking
- Add nullable roomId field to arcade_sessions for room association
- Create migration 0003_naive_reptil.sql

Managers:
- Implement room-manager.ts with full CRUD operations
- Implement room-membership.ts for member management
- Add room-code.ts utility for unique room code generation
- Include TTL-based room cleanup functionality

Documentation:
- Add arcade-rooms-technical-plan.md with complete system design
- Add arcade-rooms-implementation-tasks.md with 62-task breakdown

This establishes the foundation for public multiplayer rooms with:
- URL-addressable rooms with unique codes
- Guest user support
- Configurable TTL for automatic cleanup
- Room creator moderation controls

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 15:11:24 -05:00
Thomas Hallock
9165014997 refactor: remove duplicate game control buttons from game phases
Remove duplicate New Game button from GamePhase and update ResultsPhase
to use proper arcade navigation now that controls are in the nav bar.

- Remove New Game button from GamePhase (now in nav)
- Change ResultsPhase "Back to Games" to "Back to Arcade"
- Add proper session exit in ResultsPhase arcade navigation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 14:25:01 -05:00
Thomas Hallock
4758ad2f26 feat: implement game control callbacks in MemoryPairsGame
Connect game control buttons to actual game actions using context
functions for Setup, New Game, and Quit functionality.

- Setup: exit session and navigate to /arcade/matching (returns to setup)
- New Game: call resetGame() from context
- Quit: exit session and navigate to /arcade

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 14:24:51 -05:00
Thomas Hallock
fbd8cd4a6b feat: integrate GameControlButtons into navigation
Wire up GameControlButtons through PageWithNav and GameContextNav to
enable game control buttons in the navigation bar during gameplay.

- Add onSetup, onNewGame props to PageWithNav and GameContextNav
- Show GameControlButtons when !showFullscreenSelection && !canModifyPlayers
- Pass callbacks through component hierarchy

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 14:24:40 -05:00
Thomas Hallock
1f45c17e0a feat: add GameControlButtons component with unit tests
Add reusable GameControlButtons component with Setup, New Game, and Quit
buttons for arcade game navigation. Includes comprehensive unit tests.

- Create GameControlButtons component with optional callbacks
- Add flexWrap: nowrap and whiteSpace: nowrap to prevent wrapping
- Write 10 unit tests covering all button behaviors
- All tests passing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 14:24:31 -05:00
Thomas Hallock
2248c34215 chore: improve logging in arcade session management
Added detailed logging to help debug player ID vs user ID flow:
- useArcadeSocket: Log full move payload with JSON stringification
- session-manager: Log player ID, game state players, and phase info

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 13:27:19 -05:00
Thomas Hallock
ea1b1a2f69 fix: require activePlayers in START_GAME, never fallback to userId
START_GAME moves must explicitly provide activePlayers array containing
database player IDs. Removed fallback to [data.userId] which incorrectly
used guest ID as player ID. Server now rejects START_GAME moves that are
missing activePlayers.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 13:27:10 -05:00
Thomas Hallock
d00abd25e7 fix: pass player IDs (not user IDs) in all arcade game moves
All game moves now explicitly include the database player ID (avatar ID)
in the playerId field. This fixes multiplayer turn validation which was
failing because it was comparing database player IDs with guest IDs.

- FLIP_CARD: Use state.currentPlayer (database player ID)
- START_GAME/resetGame: Use first active player ID, with validation
- CLEAR_MISMATCH: Use state.currentPlayer

Removed fallback to viewerId which incorrectly conflated user/guest IDs
with player IDs.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 13:27:00 -05:00
Thomas Hallock
d5a8a2a14c fix: enforce playerId must be explicitly provided in arcade moves
Player IDs (database avatar IDs) must never be conflated with or fall back
to user/guest IDs. This commit makes playerId a required field in all game
moves and throws an error if missing.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 13:26:49 -05:00
Thomas Hallock
22541df99f fix: use UUID player IDs in session creation fallback
- Changed CreateSessionOptions.activePlayers from number[] to string[]
- Updated socket-server.ts fallback from [1] to [data.userId]
- Added debug logging to validateFlipCard to diagnose turn validation issues

This ensures that when a session is created without explicit activePlayers,
it uses the actual UUID of the requesting player instead of the numeric value 1.
2025-10-06 13:04:33 -05:00
Thomas Hallock
ccd0d6d94c fix: use player IDs instead of array indices in matching game
Changed Player type from number to string (UUID) throughout the
matching game to properly identify players by their unique IDs
rather than array positions. This fixes the "Not your turn"
validation errors that were occurring because server-side
validation was comparing UUIDs (move.playerId) with numeric
indices (state.currentPlayer).

Changes:
- Updated Player type from number to string in both arcade and
  games matching context types
- Changed all player tracking to use UUID strings instead of
  numeric indices (1, 2, 3)
- Updated turn validation in MatchingGameValidator to compare
  string IDs correctly
- Fixed all UI components (GameCard, PlayerStatusBar, etc.) to
  use player.findIndex() for array positions when needed
- Updated MatchingStartGameMove type to expect string[] for
  activePlayers
- Re-enabled turn validation (previously disabled as workaround)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 12:49:10 -05:00
Thomas Hallock
619be9859c test: add E2E tests for arcade modal session behavior
Added comprehensive Playwright tests for arcade modal session system:
- Session redirects and persistence
- Player modification blocking during games
- "Return to Arcade" button functionality
- Session lifecycle from creation to end

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 12:21:42 -05:00
Thomas Hallock
7c0e6b142b fix: disable turn validation in arcade mode matching game
Removed turn validation check that was causing "Not your turn" errors.
The validator was comparing player numbers (1, 2, 3) with viewer UUIDs,
which never matched. In arcade mode, a single user controls all players,
so turn validation is not applicable.

- Removed comparison of state.currentPlayer (number) vs playerId (UUID)
- Allows single user to flip cards for any player in arcade mode
- Fixes card flipping functionality in matching game

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 12:21:34 -05:00
Thomas Hallock
70d6f43d6d feat: emit session-state after creating arcade session
Added session-state event emission after creating new arcade session
during START_GAME. This ensures connected clients (like useArcadeRedirect)
are immediately notified of the new session, triggering proper UI updates.

- Fetches newly created session after START_GAME
- Emits session-state to all clients in user's arcade room
- Enables "Return to Arcade" button to appear immediately

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 12:21:24 -05:00
Thomas Hallock
4153929a2a fix: show "Return to Arcade" button only during active game
Modified GameContextNav to only display "Return to Arcade" button when
user cannot modify players (i.e., during active game session). This
prevents the button from appearing during game setup phase.

- Button now conditional on !canModifyPlayers
- Removed incorrect "Setup" button display during setup

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 12:21:15 -05:00
Thomas Hallock
c7ad3c0695 fix: allow navigation to game setup pages without active session
Modified useArcadeRedirect to not redirect users away from game pages
when they have no active session. Users can now navigate to game setup
pages to start new sessions.

- Removed redirect logic from onNoActiveSession callback
- Updated canModifyPlayers to allow modification whenever no active session
- Only redirect when user has active session for DIFFERENT game

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 12:21:07 -05:00
Thomas Hallock
3b3cad4b76 feat: add server-side validation for player modifications during active arcade sessions
Prevents users from changing isActive status of players while they have
an active arcade session in progress. Returns 403 error with game info
when blocked.

- Added arcade session check in PATCH /api/players/[id] endpoint
- Enhanced error handling to surface server validation errors to users
- Added comprehensive E2E tests for validation behavior

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 12:20:58 -05:00
Thomas Hallock
ff16303a7c feat: add arcade matching game components and utilities
- Add game components (GameCard, GamePhase, SetupPhase, MemoryGrid)
- Add player status bar with multiplayer support
- Add emoji picker for player customization
- Add card generation and validation utilities
- Add game scoring system with combo multipliers
- Add page route for arcade matching game

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 11:04:41 -05:00
Thomas Hallock
9d4cba05be fix: make results screen compact to fit viewport without scrolling
- Use flexbox with space-between for non-scrollable layout
- Reduce all spacing and font sizes for compactness
- Remove performance analysis section
- Add responsive breakpoints for mobile/desktop
- Ensures Play Again button is always visible

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 11:04:29 -05:00
Thomas Hallock
ae1318e8bf feat: add Setup button to exit arcade sessions
- Add onExitSession prop to PageWithNav and GameContextNav
- Display Setup button (⚙️) in nav bar during games
- Call exitSession() and reload page to return to setup
- Provides consistent exit UI across all arcade games

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 11:04:22 -05:00
Thomas Hallock
abc2ea50d0 feat: add exitSession to MemoryPairsContextValue interface
- Add exitSession method to context type definition
- Enables arcade session cleanup and return to setup

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 11:04:15 -05:00
Thomas Hallock
158f52773d fix: add CLEAR_MISMATCH move to allow mismatch feedback to auto-dismiss
- Add MatchingClearMismatchMove type to arcade validation types
- Implement CLEAR_MISMATCH validation in MatchingGameValidator
- Keep mismatched cards visible briefly (1.5s) so player can see them
- Auto-dismiss mismatch feedback toast after timeout
- Essential for memory gameplay where seeing wrong cards builds memory

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 11:04:03 -05:00
Thomas Hallock
7829d8a0fb fix: move auth.ts to src/ to match @/ path alias
- Moved apps/web/auth.ts to apps/web/src/auth.ts
- Updated import in src/lib/viewer.ts to use @/auth alias
- Fixes 'Module not found: Can't resolve @/auth' error
- This error was not caught in dev due to typescript.ignoreBuildErrors
  but caused production build failures and 500 errors on /api/auth/* endpoints

The @/* path alias resolves to src/*, so auth.ts must be in src/ directory.
Previously, /api/auth/csrf and other NextAuth endpoints returned 500 errors.
2025-10-06 06:46:18 -05:00
Thomas Hallock
a216a3d343 fix: add Python setuptools and build tools for better-sqlite3 compilation
- Add python3, py3-setuptools, make, g++ to Alpine base image
- Required for better-sqlite3 native module compilation in Docker build
- Fixes 'ModuleNotFoundError: No module named distutils' error
2025-10-06 06:46:04 -05:00
Thomas Hallock
aa1ad315ef feat: add security tests and userId injection protection
Security improvements:
- Add comprehensive e2e tests for userId injection attacks
- Explicitly strip userId from abacus-settings PATCH request body
- Add security comments to player update routes
- Tests verify foreign key and unique constraints prevent attacks
- Document that API layer security is critical (DB constraints insufficient)

Test coverage:
- 12 tests for abacus-settings API (including 3 security tests)
- 11 tests for players API (including 3 security tests)
- All 23 tests passing

Key findings documented in tests:
- Database foreign keys prevent invalid userId references
- Primary key constraints prevent duplicate userIds (abacus_settings)
- For players, userId CAN be changed to another valid userId at DB level
- API layer MUST filter userId from request body and use session-derived userId
- WHERE clauses scope all queries to current user's data

Defense in depth:
1. Session-derived userId (JWT cookie)
2. Explicit userId filtering from request body
3. WHERE clauses limiting scope to user's own data
4. Foreign key constraints (fallback)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 19:57:51 -05:00
Thomas Hallock
92ef1360a4 feat: migrate abacus display settings to database
- Add abacus_settings table with all display configuration fields
- Create API routes (GET/PATCH) for abacus settings
- Add React Query hooks with optimistic updates
- Create AbacusSettingsSync component to bridge localStorage and API
- Settings now persist server-side per guest/user session
- Maintains backward compatibility with existing localStorage pattern

Migration includes:
- Database schema for 12 abacus display settings
- Automatic migration generation and application
- API-driven persistence with guest session support
- Sync component loads from API on mount and saves changes automatically

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 19:34:33 -05:00
Thomas Hallock
b62cf26fb6 feat: add optimistic updates and remove dead code
Phase 4 & 5: Cleanup + Optimistic Updates
- Removed dead localStorage types from player.ts
- Added optimistic updates to all player mutations
- Added optimistic updates to stats mutations
- Instant UI feedback with automatic rollback on error

Breaking changes: None

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 18:21:33 -05:00
Thomas Hallock
fe01a1fe18 feat: migrate contexts to React Query (remove localStorage)
Phase 3: React Query Integration
- Created useUserPlayers hook with CRUD mutations
- Created useUserStats hook with update mutations
- Rewrote GameModeContext to use API instead of localStorage
- Rewrote UserProfileContext to use API instead of localStorage
- Removed playerMigration.ts (localStorage utilities)
- Maintained backward-compatible interfaces

Technical details:
- All data now persists to SQLite via API
- React Query handles caching, invalidation, and optimistic updates
- Contexts still provide same interface for existing components
- No localStorage dependencies remaining (except sound settings)

Breaking changes:
- None - interfaces remain compatible

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 18:13:32 -05:00
Thomas Hallock
6f940e24d6 feat: add API routes for players and user stats
Phase 2.2: API Routes
- POST /api/players - Create player
- GET /api/players - List user's players
- PATCH /api/players/[id] - Update player
- DELETE /api/players/[id] - Delete player
- GET /api/user-stats - Get user statistics
- PATCH /api/user-stats - Update user statistics

Technical details:
- Middleware passes guest ID via x-guest-id header for same-request access
- API routes use getViewerId() to identify guest/user sessions
- Automatic user record creation on first API access
- Full test coverage (16 tests passing)
- Manual API testing verified with curl

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 18:03:36 -05:00
Thomas Hallock
10d8aaf814 feat: add guest session system with JWT tokens
Phase 1.2: Guest Session System
- Guest token utilities with JWT signing/verification (jose)
- Middleware for automatic guest cookie generation
- NextAuth v5 configuration with guest provider support
- Viewer helper utility for unified session access
- API route handlers for NextAuth
- Comprehensive test coverage (22 tests passing)

Technical details:
- Uses HttpOnly cookies for security
- Conditional cookie naming (__Host- in prod, plain in dev)
- 30-day token expiration with automatic rotation
- No localStorage dependency (fully server-side)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 17:35:18 -05:00
Thomas Hallock
5d5afd4e68 feat: set up Drizzle ORM with SQLite database
Phase 1.1 Complete: Database & Auth Infrastructure

- Configure Drizzle with SQLite and better-sqlite3
- Create schema for users, players, and user_stats tables
- Set up database client with foreign keys and WAL mode enabled
- Add migration runner and package.json scripts
- Generate initial migration (0000_third_carnage.sql)

Database Features:
- Users table with guestId for guest sessions
- Players table with userId FK (cascade delete)
- UserStats table with userId FK (cascade delete)
- Indexes on foreign keys for performance
- Type-safe schema with Drizzle ORM

Testing:
- 20 unit + e2e tests all passing
- Schema validation tests
- Migration idempotency tests
- Foreign key constraint tests
- Cascade delete tests

Scripts added:
- pnpm db:generate - Generate migration from schema
- pnpm db:migrate - Run pending migrations
- pnpm db:push - Push schema directly (dev)
- pnpm db:studio - Visual DB browser
- pnpm db:drop - Drop migration (dev)

User tests verified:
 Migration runs successfully
 Database tables created with correct schema
 Migration is idempotent (can run multiple times)
2025-10-05 17:01:27 -05:00
Thomas Hallock
dd0df8c274 docs: add server persistence migration plan
Add comprehensive plan for migrating from localStorage to server-side
database with NextAuth guest sessions.

Key features:
- SQLite + Drizzle ORM with type-safe schema definitions
- NextAuth v5 with JWT strategy for stateless guest sessions
- React Query for client-side data fetching and caching
- Comprehensive testing strategy (unit, e2e, manual)
- Fast-failure approach with no backwards compatibility
- Detailed Drizzle migration setup and workflow
- 5 phases with 10 checkpoints, each with specific tests

Strategy: greenfield approach with hard cutover at each checkpoint,
no localStorage fallbacks, no gradual migration.
2025-10-05 16:55:11 -05:00
Thomas Hallock
a3878a8537 feat: add React Query setup with api helper
- Install @tanstack/react-query
- Create QueryClientProvider in ClientProviders with stable client instance
- Add queryClient.ts with createQueryClient() and api() helper
- Add api() helper that wraps fetch with automatic /api prefix
- Add example.ts with complete CRUD hook examples
- Configure sensible defaults (5min staleTime, retry once)

All API routes are now prefixed with /api automatically via api() helper.
2025-10-05 16:55:03 -05:00
Thomas Hallock
1234e6ce60 chore: clean up working tree and update gitignore
Remove unused multiplayer/player code and abandoned e2e tests.
Add gitignore patterns for playwright reports and database files.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 06:59:26 -05:00
Thomas Hallock
5e7b273b33 refactor: split deployment info into server/client components
Refactor to use server component composition pattern where DeploymentInfoContent (server component) imports build info JSON directly and is rendered as a child of DeploymentInfoModal (client component). Eliminates unnecessary API fetch.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 06:43:34 -05:00
Thomas Hallock
43be7ac83a feat: add deployment info modal with keyboard shortcut
Add modal component to view deployment information (version, git commit, build time, etc.) accessible via Cmd/Ctrl+Shift+I keyboard shortcut throughout the app.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 06:37:12 -05:00
Thomas Hallock
571664e725 feat: add build info API endpoint
Add API route to serve deployment information and TypeScript definitions for type safety.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 06:37:05 -05:00
Thomas Hallock
416dc897e2 feat: add build info generation script
Add script to capture deployment metadata (git commit, branch, timestamp, version) at build time and integrate it into the build process.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 06:37:00 -05:00
Thomas Hallock
2e041ddc44 fix: update matching game for UUID player system
- GamePhase: Convert Map to array, map numeric IDs to players
- GameCard: Use active players array for emoji lookup
- PlayerStatusBar: Fix Map.filter, fix duplicate className, fix type comparison
- ResultsPhase: Convert to array-based player lookup, add numericId mapping
- MemoryPairsContext: Create compatibility layer for numeric player IDs
- EmojiPicker: Update import path for PLAYER_EMOJIS
- EmojiPicker test: Update import path

Maintains backward compatibility with internal numeric player tracking
while using UUID-based players from GameModeContext
2025-10-04 17:06:55 -05:00
Thomas Hallock
ae4e8fcb5a fix: update race track components for new player system
CircularTrack, LinearTrack, SteamTrainJourney:
- Convert Map to array for active player lookup
- Use player.emoji directly instead of profile.player1Emoji
- Remove hardcoded player ID switch statements
2025-10-04 17:06:55 -05:00
Thomas Hallock
81d17f2397 feat: dynamic player card rendering on games page
- Render player cards from Map instead of hardcoded 1-4
- Support arbitrary number of players
- Use player.color for theming
- Add React import for Fragment usage
2025-10-04 17:06:54 -05:00
Thomas Hallock
e85d0415f2 feat: update nav components for UUID players
- Update all player ID types from number to string
- Remove switch statements for player lookups
- Use Map/Set operations instead of array methods
- Support arbitrary number of players
- PlayerConfigDialog now accepts string IDs
2025-10-04 17:06:54 -05:00
Thomas Hallock
2b94cad11b feat: migrate contexts to UUID-based player system
GameModeContext:
- Use Map<string, Player> instead of array
- Use Set<string> for active player tracking
- Add migration support on initialization
- Remove hardcoded 1-4 player limit

UserProfileContext:
- Remove player1-4 fields (moved to GameModeContext)
- Keep only game statistics
- Add stats migration from V1
2025-10-04 17:06:53 -05:00
Thomas Hallock
79f44b25d6 feat: add player types and migration utilities
- Add Player interface with UUID-based id field
- Add PlayerStorageV2 format for new storage schema
- Add migration utilities to convert from V1 (indexed) to V2 (UUID)
- Add validation and rollback support
- Move PLAYER_EMOJIS to shared constants
2025-10-04 17:06:53 -05:00
Thomas Hallock
42b73cf8ee chore: add nanoid dependency
Add nanoid for generating universally unique player IDs
2025-10-04 17:06:53 -05:00
Thomas Hallock
2960eef2b5 debug: add comprehensive passenger boarding debug logging
Adds detailed console logging to capture all state during passenger
boarding and delivery in Steam Sprint mode. When passengers are left
behind, the entire log can be copied and pasted into a new Claude Code
session for immediate debugging.

Debug log includes:
- Train position, speed, momentum, and configuration
- All station positions and details
- Complete passenger states (waiting, boarded, delivered)
- Car positions and occupancy
- Passengers scheduled for delivery in current frame
- Detailed boarding attempt analysis for each waiting passenger
- Distance calculations and eligibility checks per car
- Actual boarding and delivery events

Enable by setting DEBUG_PASSENGER_BOARDING = true in useSteamJourney.ts

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 17:56:21 -05:00
Thomas Hallock
e6ebecb09b fix(complement-race): prevent passengers being left behind at delivery stations
The bug occurred when a car reached a station where both:
- A passenger needed to be delivered
- A new passenger was waiting to board

The car appeared occupied during boarding check, then the passenger was delivered,
leaving the new passenger behind.

Fix: Identify passengers to be delivered BEFORE building the occupiedCars map,
and exclude them from the map. This makes cars that are about to become empty
immediately available for new passengers.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 17:48:40 -05:00
392 changed files with 64875 additions and 28498 deletions

View File

@@ -171,9 +171,19 @@
"Bash(open http://localhost:3002/create)",
"Bash(open http://localhost:3002/games/complement-race/practice)",
"Bash(open http://localhost:3002/games/complement-race)",
"Bash(npx vitest run:*)"
"Bash(npx vitest run:*)",
"Bash(sqlite3:*)",
"Bash(NODE_ENV=development node server.js)",
"Bash(lsof:*)",
"Bash(xargs kill:*)",
"Bash(./test-arcade-api.sh:*)",
"Bash(pkill:*)",
"Bash(shasum:*)",
"Bash(open http://localhost:3000/arcade/matching)",
"Bash(echo:*)",
"Bash(npm run type-check:*)"
],
"deny": [],
"ask": []
}
}
}

View File

@@ -1,5 +1,7 @@
# Ignore development files
# Ignore all node_modules to prevent Docker overlay conflicts
node_modules
**/node_modules
.next
.git
.github

View File

@@ -36,7 +36,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8.0.0
version: 9.15.4
- name: Get pnpm store directory
shell: bash

View File

@@ -18,7 +18,7 @@ jobs:
- uses: pnpm/action-setup@v2
with:
version: 8.0.0
version: 9.15.4
- uses: actions/setup-node@v4
with:

View File

@@ -32,7 +32,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8.0.0
version: 9.15.4
- name: Get pnpm store directory
shell: bash

View File

@@ -46,7 +46,7 @@ jobs:
- name: Upload example images if changed
if: failure()
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: updated-examples
path: docs/images/

11
.gitignore vendored
View File

@@ -10,6 +10,9 @@ dist/
# Generated CSS (Panda CSS / styled-system)
**/styled-system/
# Generated build info
**/generated/build-info.json
# Environment
.env*
@@ -40,6 +43,14 @@ Thumbs.db
# Test coverage
coverage/
# Test reports
playwright-report/
# Database files
*.db
*.db-shm
*.db-wal
# Temporary files
tmp/
temp/

View File

@@ -1,3 +1,351 @@
## [2.3.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.2.1...v2.3.0) (2025-10-07)
### Features
* add Biome + ESLint linting setup ([fc1838f](https://github.com/antialias/soroban-abacus-flashcards/commit/fc1838f4f53a4f8d8f1c5303de3a63f12d9c9303))
### Styles
* apply Biome formatting to entire codebase ([60d70cd](https://github.com/antialias/soroban-abacus-flashcards/commit/60d70cd2f2f2b1d250c4c645889af4334968cb7e))
## [2.2.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.2.0...v2.2.1) (2025-10-07)
### Bug Fixes
* remove remaining typst-dependent files ([d1b9b72](https://github.com/antialias/soroban-abacus-flashcards/commit/d1b9b72cfc2f2ba36c40d7ae54bc6fdfcc5f34da))
## [2.2.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.3...v2.2.0) (2025-10-07)
### Features
* remove typst-related code and routes ([be6fb1a](https://github.com/antialias/soroban-abacus-flashcards/commit/be6fb1a881b983f9830d36c079b7b41f35153b8a))
## [2.1.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.2...v2.1.3) (2025-10-07)
### Bug Fixes
* remove .npmrc from Dockerfile COPY ([e71c2b4](https://github.com/antialias/soroban-abacus-flashcards/commit/e71c2b4da85076dfc97401fc170cd88cb0aa4375))
## [2.1.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.1...v2.1.2) (2025-10-07)
### Bug Fixes
* revert to default pnpm mode for Docker compatibility ([bd0092e](https://github.com/antialias/soroban-abacus-flashcards/commit/bd0092e69ac4f74ea89b8d31399cf72f57484cbb))
## [2.1.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.0...v2.1.1) (2025-10-07)
### Bug Fixes
* ignore all node_modules in Docker ([4792dde](https://github.com/antialias/soroban-abacus-flashcards/commit/4792dde1beef9c6cb84a27bc6bb6acfa43919a72))
## [2.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.7...v2.1.0) (2025-10-07)
### Features
* remove typst dependencies ([eedce28](https://github.com/antialias/soroban-abacus-flashcards/commit/eedce28572035897001f6b8a08f79beaa2360d44))
## [2.0.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.6...v2.0.7) (2025-10-07)
### Bug Fixes
* preserve workspace node_modules in Docker for hoisted mode ([4f8aaf0](https://github.com/antialias/soroban-abacus-flashcards/commit/4f8aaf04aadda11ce9ec470dec44f78062929e77))
## [2.0.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.5...v2.0.6) (2025-10-07)
### Bug Fixes
* ignore nested node_modules in Docker ([f554592](https://github.com/antialias/soroban-abacus-flashcards/commit/f554592272c2e92d7f1ec6550211518de9c3242f))
## [2.0.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.4...v2.0.5) (2025-10-07)
### Bug Fixes
* use .npmrc in Docker for hoisted mode consistency ([2df8cdc](https://github.com/antialias/soroban-abacus-flashcards/commit/2df8cdc88ed03b6b04642a3441e17c6fda11d2a5))
## [2.0.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.3...v2.0.4) (2025-10-07)
### Bug Fixes
* remove .npmrc in Docker to avoid hoisted mode issues ([2a77d75](https://github.com/antialias/soroban-abacus-flashcards/commit/2a77d755b7820b5b6b52ea99db418e6d071d726e))
## [2.0.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.2...v2.0.3) (2025-10-07)
### Bug Fixes
* remove duplicate PlayerStatusBar story file from arcade ([4e721f7](https://github.com/antialias/soroban-abacus-flashcards/commit/4e721f765a29fe8628d4e34ef94cdf5728eea3dc))
## [2.0.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.1...v2.0.2) (2025-10-07)
### Bug Fixes
* update Dockerfile pnpm version and fix TypeScript config ([43077a8](https://github.com/antialias/soroban-abacus-flashcards/commit/43077a80e271a793c88f100874914ae6f3c515b5))
## [2.0.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.0...v2.0.1) (2025-10-07)
### Bug Fixes
* add @types/minimatch to abacus-react devDependencies ([fa45475](https://github.com/antialias/soroban-abacus-flashcards/commit/fa4547543dcd0cddc7cc9ff9da62f60a4717fb1f))
## [2.0.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v1.2.1...v2.0.0) (2025-10-07)
### ⚠ BREAKING CHANGES
* abacus-react package now has independent versioning from monorepo
### Features
* **abacus-react:** add dual publishing to npm and GitHub Packages ([242ee52](https://github.com/antialias/soroban-abacus-flashcards/commit/242ee523edebe2cfc5db27cc72fba0315072bec2))
* **abacus-react:** comprehensive README overhaul with current capabilities ([0ce351e](https://github.com/antialias/soroban-abacus-flashcards/commit/0ce351e572ac34fa816ee7533a26403c843d93f3))
* **abacus-react:** configure GitHub Packages-only publishing workflow ([5eeedd9](https://github.com/antialias/soroban-abacus-flashcards/commit/5eeedd9a59a6b3898cadb30c413daa791a9561ee))
* **abacus-react:** enable dual publishing to npm and GitHub Packages ([176a196](https://github.com/antialias/soroban-abacus-flashcards/commit/176a1961d05f99908a72837cf4e8ec93c0d33145))
* **abacus-react:** enhance package description with semantic versioning details ([af037b5](https://github.com/antialias/soroban-abacus-flashcards/commit/af037b5e0a1ded5460f95498eb1fb5ac19c2e3fa))
* **abacus-react:** implement GitHub Packages-only publishing workflow ([b194599](https://github.com/antialias/soroban-abacus-flashcards/commit/b194599f6029015b1aba0e57eb5fe9f83b89d403))
* **abacus-react:** implement GitHub-only semantic release with manual package publishing ([33b0567](https://github.com/antialias/soroban-abacus-flashcards/commit/33b056769811d1cf1c41dee9e65f6e12188e6f5f))
* **abacus-react:** simplify to GitHub Packages-only publishing ([acc126b](https://github.com/antialias/soroban-abacus-flashcards/commit/acc126bd5a0f0b2017263593ac2e3a180606f17b))
* **abacus-react:** update description to mention GitHub Packages support ([af77256](https://github.com/antialias/soroban-abacus-flashcards/commit/af7725622e15801f9e56af12930c4e14c5e67c53))
* **abacus-react:** use environment variables to override npm registry ([ad444e1](https://github.com/antialias/soroban-abacus-flashcards/commit/ad444e108f76d3014e492ddc94de0e52c61743ea))
* add API routes for players and user stats ([6f940e2](https://github.com/antialias/soroban-abacus-flashcards/commit/6f940e24d663cc06084a943df4743c2a1c1b3c33))
* add arcade matching game components and utilities ([ff16303](https://github.com/antialias/soroban-abacus-flashcards/commit/ff16303a7cd2880fcdfd51ef8a744e245905d87d))
* add arcade room system database schema and managers (Phase 1) ([a9175a0](https://github.com/antialias/soroban-abacus-flashcards/commit/a9175a050c1668a6ba066078e0bdbd944b4eb960))
* add build info API endpoint ([571664e](https://github.com/antialias/soroban-abacus-flashcards/commit/571664e725b63f22fa9f0bca8a1c518a54441dec))
* add build info generation script ([416dc89](https://github.com/antialias/soroban-abacus-flashcards/commit/416dc897e26ab93930b52faf77da3a6ffd4a0fb9))
* add category browsing and scrolling to emoji picker ([616a50e](https://github.com/antialias/soroban-abacus-flashcards/commit/616a50e234f79e271cb0bd9c959866d2d2e5ac82))
* add complement display options and unify equation display ([2ed7b2c](https://github.com/antialias/soroban-abacus-flashcards/commit/2ed7b2cbf8ad7c18b14c0e86b04a3ba96cc4de0b))
* add Complement Race game with three unique game modes ([582bce4](https://github.com/antialias/soroban-abacus-flashcards/commit/582bce411f5e89fe1ee677321d06ca7d0fd78701))
* add comprehensive E2E testing with Playwright ([d58053f](https://github.com/antialias/soroban-abacus-flashcards/commit/d58053fad3ab06b9884b46dbb6807e938426dbb5))
* add comprehensive Storybook stories for PlayerStatusBar ([8973241](https://github.com/antialias/soroban-abacus-flashcards/commit/8973241297d50604028bde95b9ebbf033688db89))
* add configuration access to active player emojis in prominent nav ([6049a7f](https://github.com/antialias/soroban-abacus-flashcards/commit/6049a7f6b7481ca42a7907d11d93676549bf6629))
* add configuration access to fullscreen player selection ([b85968b](https://github.com/antialias/soroban-abacus-flashcards/commit/b85968bcb6afa379d50242185e7743f6fe4ba982))
* add consecutive match tracking system for escalating celebrations ([111c0ce](https://github.com/antialias/soroban-abacus-flashcards/commit/111c0ced715be7cade006387d01f4e2f52c59be9))
* add CSS animations and visual feedback system ([80e33e2](https://github.com/antialias/soroban-abacus-flashcards/commit/80e33e25b3d30a44a1a5294997d56949e2aeef8b))
* add deployment info modal with keyboard shortcut ([43be7ac](https://github.com/antialias/soroban-abacus-flashcards/commit/43be7ac83a9ba3e0ad970f4588729ba2ad394702))
* add direct URL routes for each game mode ([a08f053](https://github.com/antialias/soroban-abacus-flashcards/commit/a08f0535bf6dfb424e8f9764b37ce6912db6021c))
* add exitSession to MemoryPairsContextValue interface ([abc2ea5](https://github.com/antialias/soroban-abacus-flashcards/commit/abc2ea50d07e87537ced649bcc9276ef95a3bc4e))
* add GameControlButtons component with unit tests ([1f45c17](https://github.com/antialias/soroban-abacus-flashcards/commit/1f45c17e0a68db2a452844f87217671223cf7bb0))
* add guest session system with JWT tokens ([10d8aaf](https://github.com/antialias/soroban-abacus-flashcards/commit/10d8aaf814275a9c3f08e0f1b39970c3ab1a8427))
* add initialStyle prop to ComplementRaceProvider ([f3bc2f6](https://github.com/antialias/soroban-abacus-flashcards/commit/f3bc2f6d926b9c8d9229636e3ed688bf9ea3baf3))
* add intelligent on-screen number pad for devices without keyboards ([d4740ff](https://github.com/antialias/soroban-abacus-flashcards/commit/d4740ff99709be915c41f51d973706f6ff2774b3))
* add interactive remove buttons for players in mini nav ([fa1cf96](https://github.com/antialias/soroban-abacus-flashcards/commit/fa1cf967898bdc396b0bfdcbfe2147a06e189190))
* add magnifying glass preview on emoji hover ([2c88b6b](https://github.com/antialias/soroban-abacus-flashcards/commit/2c88b6b5f3ba3a47007aa832c2e204bf2ebcc90b))
* add middleware for pathname header support in [@nav](https://github.com/nav) fallback ([b7e7c4b](https://github.com/antialias/soroban-abacus-flashcards/commit/b7e7c4beff1e37e90e9e20a890c5af7a134a7fca))
* add mini app nav to arcade page ([a854fe3](https://github.com/antialias/soroban-abacus-flashcards/commit/a854fe3dc935b10db8dc71569c0c8abd81938e4c))
* add optimistic updates and remove dead code ([b62cf26](https://github.com/antialias/soroban-abacus-flashcards/commit/b62cf26fb6597bae9a590f8b8d630fd31a8dd321))
* add passenger boarding system with station-based pickup ([23a9016](https://github.com/antialias/soroban-abacus-flashcards/commit/23a9016245e061b65151735db31aebdc9d36ed1d))
* add player types and migration utilities ([79f44b2](https://github.com/antialias/soroban-abacus-flashcards/commit/79f44b25d6c17f119c3ae225fb449be27c77c56d))
* add PlayerStatusBar with escalating celebration animations ([7f8c90a](https://github.com/antialias/soroban-abacus-flashcards/commit/7f8c90acea84b208df0e3e23e80a02cf425c0950))
* add prominent game context display to mini nav with smooth transitions ([8792393](https://github.com/antialias/soroban-abacus-flashcards/commit/8792393956b01a5a8ca67d78209e6defb6a11903))
* add React Query setup with api helper ([a3878a8](https://github.com/antialias/soroban-abacus-flashcards/commit/a3878a8537fe123fa345d2d2990b3cd76132ba1e))
* add realistic mountains with peaks and ground terrain ([99cdfa8](https://github.com/antialias/soroban-abacus-flashcards/commit/99cdfa8a0ba63e7b523466d8b2108bca05b0310a))
* add security tests and userId injection protection ([aa1ad31](https://github.com/antialias/soroban-abacus-flashcards/commit/aa1ad315ef75af5b6833a3a3628a9bbceb80c03c))
* add server-side validation for player modifications during active arcade sessions ([3b3cad4](https://github.com/antialias/soroban-abacus-flashcards/commit/3b3cad4b769b0ed9ed8e6dc2363bcaf13cc8e08a))
* add Setup button to exit arcade sessions ([ae1318e](https://github.com/antialias/soroban-abacus-flashcards/commit/ae1318e8bf2c584853ceeb38336d871110f13a39))
* add smooth spring animations to pressure gauge ([863a2e1](https://github.com/antialias/soroban-abacus-flashcards/commit/863a2e1319448381c853540301886fb4a169e112))
* add sound settings support to AbacusReact component ([90b9ffa](https://github.com/antialias/soroban-abacus-flashcards/commit/90b9ffa0d8659891bfe8062217e45245bbff5d5a))
* add train car system with smooth boarding/disembarking animations ([1613912](https://github.com/antialias/soroban-abacus-flashcards/commit/1613912740756d984205e3625791c1d8a2a6fa51))
* add Web Audio API sound effects system with 16 sound types ([90ba866](https://github.com/antialias/soroban-abacus-flashcards/commit/90ba86640c7062a00d2c7553827a61524ec17da1))
* **complement-race:** add abacus displays to pressure gauge ([c5ebc63](https://github.com/antialias/soroban-abacus-flashcards/commit/c5ebc635afb6e78f9f4b1192ff39dcec53879a60))
* complete themed navigation system with game-specific chrome ([0a4bf17](https://github.com/antialias/soroban-abacus-flashcards/commit/0a4bf1765cbd86bf6f67fb3b99c577cfe3cce075))
* create mode selection landing page for Complement Race ([1ff9695](https://github.com/antialias/soroban-abacus-flashcards/commit/1ff9695f6930f5232b2ad80ddcbd32bbc182d4e7))
* create PlayerConfigDialog component for player customization ([4f2a661](https://github.com/antialias/soroban-abacus-flashcards/commit/4f2a661494add3f61b714d0bead07b0e0bc3f92d))
* create StandardGameLayout for perfect viewport sizing ([728a920](https://github.com/antialias/soroban-abacus-flashcards/commit/728a92076a6ac9ef71f0c75d2e9503575881130a))
* display passengers visually on train and at stations ([1599904](https://github.com/antialias/soroban-abacus-flashcards/commit/159990489fec9162d9ed9ecf77c7592b776bbb23))
* dynamic player card rendering on games page ([81d17f2](https://github.com/antialias/soroban-abacus-flashcards/commit/81d17f23976cc340e23c63f8e27f1a15afd1a4d0))
* dynamically calculate train cars based on max concurrent passengers ([9ea1553](https://github.com/antialias/soroban-abacus-flashcards/commit/9ea15535d18efc25739342b0945c6d7ec7896c5d))
* emit session-state after creating arcade session ([70d6f43](https://github.com/antialias/soroban-abacus-flashcards/commit/70d6f43d6d7ff918ab15edb6e27d4eab8c7a3de6))
* enable prominent nav and fix layout on arcade page ([5c8c18c](https://github.com/antialias/soroban-abacus-flashcards/commit/5c8c18cbb89da38ccaab3c2ad7081e1a6d45a73e))
* enhance emoji picker with super powered magnifying glass and hide empty categories ([d8b4e42](https://github.com/antialias/soroban-abacus-flashcards/commit/d8b4e425bf019c593abdcb7693a04e4780b18f06))
* enhance passenger card UI with boarding status indicators ([4bbdabc](https://github.com/antialias/soroban-abacus-flashcards/commit/4bbdabc3b576ba8cdda5a053878b3f2e9004afca))
* extend ground terrain to cover entire track area ([ee48417](https://github.com/antialias/soroban-abacus-flashcards/commit/ee48417abfe9f5a2788d6de8ff522f60c13b6066))
* extend player customization to support all 4 players ([72f8dee](https://github.com/antialias/soroban-abacus-flashcards/commit/72f8dee183b17c88d51748b5131b5a51906a24b3))
* extend railroad track to viewport edges ([eadd7da](https://github.com/antialias/soroban-abacus-flashcards/commit/eadd7da6dbc4103342dd673f03f97850cdc20f23))
* extend track and tunnels to absolute viewport edges ([f7419bc](https://github.com/antialias/soroban-abacus-flashcards/commit/f7419bc6a0c03cbe2dbbc095e47891ee67d10b51))
* implement [@nav](https://github.com/nav) parallel routes for game name display in mini navigation ([885fc72](https://github.com/antialias/soroban-abacus-flashcards/commit/885fc725dc0bb41bbb5e500c2c907c6182192854))
* implement cozy sound effects for abacus with variable intensity ([c95be1d](https://github.com/antialias/soroban-abacus-flashcards/commit/c95be1df6dbe74aad08b9a1feb1f33688212be0b))
* implement cozy sound effects for abacus with variable intensity ([cea5fad](https://github.com/antialias/soroban-abacus-flashcards/commit/cea5fadbe4b4d5ae9e0ee988e9b1c4db09f21ba6))
* implement game control callbacks in MemoryPairsGame ([4758ad2](https://github.com/antialias/soroban-abacus-flashcards/commit/4758ad2f266ef3f3f67c22533fbb5f475dd8bd5b))
* implement game theming system with context-based navigation chrome ([3fa11c4](https://github.com/antialias/soroban-abacus-flashcards/commit/3fa11c4fbcbeabeb3bdd0db38374fb9a13cbb754))
* implement innovative dynamic two-panel layout for on-screen keyboard ([4bb8f6d](https://github.com/antialias/soroban-abacus-flashcards/commit/4bb8f6daf1f3eecb5cbaf31bf4057f43e43aeb07))
* implement mobile-first responsive design for speed memory quiz ([13efc4d](https://github.com/antialias/soroban-abacus-flashcards/commit/13efc4d0705bb9e71a2002689a4ebac109caacc2))
* implement simple fixed bottom keyboard bar ([9ef72d7](https://github.com/antialias/soroban-abacus-flashcards/commit/9ef72d7e88a6a9b30cfd7a7d3944197cc1e0037a))
* implement smooth train exit with fade-out through right tunnel ([0176694](https://github.com/antialias/soroban-abacus-flashcards/commit/01766944f0267b1f2adeb6a30c9f89d48038a7f8))
* implement toggleable on-screen keyboard to prevent UI overlap ([701d23c](https://github.com/antialias/soroban-abacus-flashcards/commit/701d23c36992b09c075e1a394f8a72edffb919f9))
* improve game availability logic and messaging ([9a3fa93](https://github.com/antialias/soroban-abacus-flashcards/commit/9a3fa93e53d05475844b54052acbc838d7487d23))
* increase landmark emoji size for better visibility ([0bcd7a3](https://github.com/antialias/soroban-abacus-flashcards/commit/0bcd7a30d42a0a0d5bdfcf5abd8eb3eb9a8b6a73))
* integrate GameControlButtons into navigation ([fbd8cd4](https://github.com/antialias/soroban-abacus-flashcards/commit/fbd8cd4a6bca44bbc0f7c4e8153900558805a84a))
* integrate remaining game sound effects ([600bc35](https://github.com/antialias/soroban-abacus-flashcards/commit/600bc35bc3111a455290638e7be31d0032ff656c))
* integrate sound effects into game flow (countdown, answers, performance) ([8c3a855](https://github.com/antialias/soroban-abacus-flashcards/commit/8c3a85523930fca7f2dcc53c79454fb9be523d55))
* integrate user profiles with PlayerStatusBar and game results ([beff646](https://github.com/antialias/soroban-abacus-flashcards/commit/beff64652c72a5cd0c008891b6dc2f5167e28b62))
* make Steam Sprint infinite mode ([32c3a35](https://github.com/antialias/soroban-abacus-flashcards/commit/32c3a35eabd10f8c9b50a55cfb525a76ea050914))
* make SVG span full viewport width for sprint mode ([7488bb3](https://github.com/antialias/soroban-abacus-flashcards/commit/7488bb38033b2d3d3fc18b9f09373506d69e25a5))
* migrate abacus display settings to database ([92ef136](https://github.com/antialias/soroban-abacus-flashcards/commit/92ef1360a4792d0b36f3a35e100bd9f3c7451656))
* migrate contexts to React Query (remove localStorage) ([fe01a1f](https://github.com/antialias/soroban-abacus-flashcards/commit/fe01a1fe182293aeadd5cbfd73f0a54a858ae38e))
* migrate contexts to UUID-based player system ([2b94cad](https://github.com/antialias/soroban-abacus-flashcards/commit/2b94cad11bd05b1a324e360c56be686c3c6a4b64))
* preserve track and passengers during route transitions ([f2e7165](https://github.com/antialias/soroban-abacus-flashcards/commit/f2e71657dc1587c2b6df1f4227160b8a261c6084))
* redesign passenger cards with vintage train station aesthetic ([651bc21](https://github.com/antialias/soroban-abacus-flashcards/commit/651bc2158361fbaafb0b011ab90006b21d3a7c85))
* set up automated npm publishing for @soroban/abacus-react package ([dd80d29](https://github.com/antialias/soroban-abacus-flashcards/commit/dd80d29c979e20b4d3624cf66be79ec51d5f53a9))
* set up Drizzle ORM with SQLite database ([5d5afd4](https://github.com/antialias/soroban-abacus-flashcards/commit/5d5afd4e6860241ff45c7173d4aad2b7156a41b1))
* skip countdown for train mode (sprint) ([65dafc9](https://github.com/antialias/soroban-abacus-flashcards/commit/65dafc92153399336f200a566bc91f869fdfcbb1))
* skip intro screen and start directly at game setup ([4b6888a](https://github.com/antialias/soroban-abacus-flashcards/commit/4b6888af05c6be9616cf20b9d2b8b66ac13cf253))
* sync URL with selected game mode ([3920bba](https://github.com/antialias/soroban-abacus-flashcards/commit/3920bbad33ef5dd6323d2baea498943f5115dbec))
* UI polish for Sprint mode (viewport, backgrounds, data attributes) ([90ad789](https://github.com/antialias/soroban-abacus-flashcards/commit/90ad789ff1f94f52b98de9fd934a623eab452387))
* update nav components for UUID players ([e85d041](https://github.com/antialias/soroban-abacus-flashcards/commit/e85d0415f23049da861533bbec2a65e1d84adfe1))
* use CSS transitions for smooth fullscreen player selection collapse ([3189832](https://github.com/antialias/soroban-abacus-flashcards/commit/31898328a391614a0fe8d24ec9d2881bfb6e6984))
* wire player configuration through nav component hierarchy ([edfdd81](https://github.com/antialias/soroban-abacus-flashcards/commit/edfdd8122774e36dbda9acea741a5e248be95676))
### Bug Fixes
* **abacus-react:** add debugging and explicit authentication for npm publish ([b82e9bb](https://github.com/antialias/soroban-abacus-flashcards/commit/b82e9bb9d6adf3793065067f96c6fbbfd1a78bca))
* **abacus-react:** add packages: write permission for GitHub Packages publishing ([8e16487](https://github.com/antialias/soroban-abacus-flashcards/commit/8e1648737de9305f82872cb9b86b98b5045f77a7))
* **abacus-react:** apply global columnPosts styling and fix reckoning bar width ([bb9959f](https://github.com/antialias/soroban-abacus-flashcards/commit/bb9959f7fb8985e0c6496247306838d97e7121f7))
* **abacus-react:** force npm to use GitHub Packages registry ([5e6c901](https://github.com/antialias/soroban-abacus-flashcards/commit/5e6c901f73a68b60ec05f19c4a991ca8affc1589))
* **abacus-react:** improve publishing workflow with better version sync ([7a4ecd2](https://github.com/antialias/soroban-abacus-flashcards/commit/7a4ecd2b5970ed8b6bfde8938b36917f8e7a7176))
* **abacus-react:** improve workspace dependency cleanup and add validation ([11fd6f9](https://github.com/antialias/soroban-abacus-flashcards/commit/11fd6f9b3deb1122d3788a7e0698de891eeb0f3a))
* **abacus-react:** resolve workspace dependencies before npm publish ([834b062](https://github.com/antialias/soroban-abacus-flashcards/commit/834b062b2d22356b9d96bb9c3c444eccaa51d793))
* **abacus-react:** simplify semantic-release config to resolve dependency issues ([88cab38](https://github.com/antialias/soroban-abacus-flashcards/commit/88cab380ef383c941b41671d58d3e35fcaefb2d3))
* **abacus-react:** temporarily allow test failures during setup phase ([e3db7f4](https://github.com/antialias/soroban-abacus-flashcards/commit/e3db7f4daf16fce82bccfe47dcaa90d7f4896a79))
* add CLEAR_MISMATCH move to allow mismatch feedback to auto-dismiss ([158f527](https://github.com/antialias/soroban-abacus-flashcards/commit/158f52773d20dfab7dc55575d9999f32b4c589a2))
* add missing GameThemeContext file for themed navigation ([d4fbdd1](https://github.com/antialias/soroban-abacus-flashcards/commit/d4fbdd14630e2f2fcdbc0de23ccc4ccd9eb74b48))
* add npmrc for hoisting and fix template paths ([5c65ac5](https://github.com/antialias/soroban-abacus-flashcards/commit/5c65ac5caabb7197f069344d0ed29d02c3de2b9a))
* add Python setuptools and build tools for better-sqlite3 compilation ([a216a3d](https://github.com/antialias/soroban-abacus-flashcards/commit/a216a3d3435a132c8add0a7c711b021bf4b1555f))
* add testing mode for on-screen keyboard and fix toggle functionality ([904074c](https://github.com/antialias/soroban-abacus-flashcards/commit/904074ca821b62cd6b1e129354eb36c5dd4b5e7f))
* align all bottom UI elements to same 20px baseline ([076c97a](https://github.com/antialias/soroban-abacus-flashcards/commit/076c97abac7a33f600b80083d8990a8c4a51be99))
* align bottom-positioned UI elements ([227cfab](https://github.com/antialias/soroban-abacus-flashcards/commit/227cfabf113bc875ea3a61f0de41a9093ad1dd30))
* allow navigation to game setup pages without active session ([c7ad3c0](https://github.com/antialias/soroban-abacus-flashcards/commit/c7ad3c069502580d1e72e7cc01e7b1f793ba9357))
* change pressure gauge to fixed positioning to stay above terrain ([1b11031](https://github.com/antialias/soroban-abacus-flashcards/commit/1b110315982f631dadca96a37fc88db98a7f9cca))
* change question display to fixed positioning with higher z-index ([4ac8758](https://github.com/antialias/soroban-abacus-flashcards/commit/4ac875895781dba5e115eeb3336ef76744b782bb))
* **complement-race:** improve abacus display in equations ([491b299](https://github.com/antialias/soroban-abacus-flashcards/commit/491b299e28ee82c49069cf892609b1b2b3c0aee3))
* **complement-race:** prevent passengers being left behind at delivery stations ([e6ebecb](https://github.com/antialias/soroban-abacus-flashcards/commit/e6ebecb09b1e5dd78c2dc11e125399082fb420ab))
* correct emoji category group IDs to match Unicode CLDR ([b2a21b7](https://github.com/antialias/soroban-abacus-flashcards/commit/b2a21b79ad705a5b52767317af00e4d666d33907))
* defer URL update until game starts ([12c54b2](https://github.com/antialias/soroban-abacus-flashcards/commit/12c54b27b717e852b96585eacdf6e9d964e32c50))
* delay passenger display update until train resets ([e06a750](https://github.com/antialias/soroban-abacus-flashcards/commit/e06a7504549bb4e0fcc38bd03249b9c0386c3079))
* disable turn validation in arcade mode matching game ([7c0e6b1](https://github.com/antialias/soroban-abacus-flashcards/commit/7c0e6b142b90f0fc3d444b3dcc1fff1512a0a3b2))
* eliminate rail jaggies on sharp curves by increasing sampling density ([46d4af2](https://github.com/antialias/soroban-abacus-flashcards/commit/46d4af2bdad761366890171cc666ba24d5309257))
* enable shamefully-hoist for semantic-release dependencies ([6168c29](https://github.com/antialias/soroban-abacus-flashcards/commit/6168c292d5f15748e80610103a6a787c0cf29d0f))
* enforce playerId must be explicitly provided in arcade moves ([d5a8a2a](https://github.com/antialias/soroban-abacus-flashcards/commit/d5a8a2a14cb14ecd00827ddc96873f3db79573fd))
* ensure consistent r×c grid layout for memory matching game ([f1a0633](https://github.com/antialias/soroban-abacus-flashcards/commit/f1a0633596fd1bb53418e56e28f3f27d3fce8b54))
* ensure game names persist in navigation on page reload ([9191b12](https://github.com/antialias/soroban-abacus-flashcards/commit/9191b124934b9a5577a91f67e8fb6f83b173cc4f))
* ensure passengers only travel forward on train route ([8ad3144](https://github.com/antialias/soroban-abacus-flashcards/commit/8ad3144d2da9b4ceedd62a9f379f664fa9381afe))
* export missing hooks and types from @soroban/abacus-react package ([423ba55](https://github.com/antialias/soroban-abacus-flashcards/commit/423ba5535023928f1e0198b2bd01c3c6cf7ee848))
* implement route-based theme detection for page reload persistence ([3dcff2f](https://github.com/antialias/soroban-abacus-flashcards/commit/3dcff2ff888558d7b746a732cfd53a1897c2b1df))
* improve navigation chrome background color extraction from gradients ([00bfcbc](https://github.com/antialias/soroban-abacus-flashcards/commit/00bfcbcdee28d63094c09a4ae0359789ebcf4a22))
* increase question display zIndex to stay above terrain ([8c8b8e0](https://github.com/antialias/soroban-abacus-flashcards/commit/8c8b8e08b4d51e6462d4b7dd258a25e64bf16dba))
* lazy-load database connection to prevent build-time access ([af8d993](https://github.com/antialias/soroban-abacus-flashcards/commit/af8d9936285c697ff45700115eba83b5debdf9ad))
* make results screen compact to fit viewport without scrolling ([9d4cba0](https://github.com/antialias/soroban-abacus-flashcards/commit/9d4cba05be84e7c162d706c8697eede23314b1a4))
* migrate viewport from metadata to separate viewport export ([1fe12c4](https://github.com/antialias/soroban-abacus-flashcards/commit/1fe12c4837b1229d0f0ab93c55d0ffb504eb8721))
* move auth.ts to src/ to match @/ path alias ([7829d8a](https://github.com/antialias/soroban-abacus-flashcards/commit/7829d8a0fb86dac07aa1b2fb0b68908e7e8381b8))
* move fontWeight to style object for station names ([05a3ddb](https://github.com/antialias/soroban-abacus-flashcards/commit/05a3ddb086e28529efb321943f4e423dbe5ed6a6))
* only show configuration gear icon for players 1 and 2 ([d0a3bc7](https://github.com/antialias/soroban-abacus-flashcards/commit/d0a3bc7dc1efc00c1db63fb50bc2ab3c6aabdd59))
* pass player IDs (not user IDs) in all arcade game moves ([d00abd2](https://github.com/antialias/soroban-abacus-flashcards/commit/d00abd25e755c0304517a7953cb78022a073b7c3))
* passengers now board/disembark based on their car position, not locomotive ([96782b0](https://github.com/antialias/soroban-abacus-flashcards/commit/96782b0e7a6d0db5a4435ca303b1d819947ce460))
* position tunnels at absolute viewBox edges ([1a5fa28](https://github.com/antialias/soroban-abacus-flashcards/commit/1a5fa2873bcda9a24cc578a7ecea43632077a0a1))
* prevent layout shift when selecting Steam Sprint mode ([73a5974](https://github.com/antialias/soroban-abacus-flashcards/commit/73a59745a5145b734b0893a489c5d018e3c9475c))
* prevent multiple passengers from boarding same car in single frame ([63b0b55](https://github.com/antialias/soroban-abacus-flashcards/commit/63b0b552a89a1165137de125bf57246a7cf6ac73))
* prevent premature passenger display during route transitions ([fe9ea67](https://github.com/antialias/soroban-abacus-flashcards/commit/fe9ea67f56847859b4fb4fa4f747022f0a2e5a70))
* prevent random passenger repopulation during route transitions ([db56ce8](https://github.com/antialias/soroban-abacus-flashcards/commit/db56ce89ee1d4e7583b60cde4e1d2610ee31123a))
* prevent route celebration from immediately reappearing ([1a80934](https://github.com/antialias/soroban-abacus-flashcards/commit/1a8093416e0feff774b6cdc6dfafdbafbb8baf7f))
* redesign matching game setup page for StandardGameLayout ([cc1f27f](https://github.com/antialias/soroban-abacus-flashcards/commit/cc1f27f0f82256f9344531814e8b965fa547d555))
* reduce landmark size from 4.0x to 2.0x multiplier ([c928e90](https://github.com/antialias/soroban-abacus-flashcards/commit/c928e907854e18264266958d813d4e1d4c03e760))
* regenerate lockfile with correct dependency order ([51bf448](https://github.com/antialias/soroban-abacus-flashcards/commit/51bf448c9f159152e89296d9014dde688fcf3a97))
* regenerate lockfile with node-linker=hoisted from scratch ([480960c](https://github.com/antialias/soroban-abacus-flashcards/commit/480960c2c8e0c50fe2b6ec69a34b772751a8bf41))
* regenerate pnpm lockfile for pnpm 9 compatibility ([4ab1aef](https://github.com/antialias/soroban-abacus-flashcards/commit/4ab1aef9b8fcf15cc03e86c829ca9885e7201b77))
* remove double PageWithNav wrapper on matching page ([b58bcd9](https://github.com/antialias/soroban-abacus-flashcards/commit/b58bcd92ee0521a6413f4d6e9656c9ccb1c72851))
* remove duplicate CAR_SPACING and MAX_CARS declarations ([e704a28](https://github.com/antialias/soroban-abacus-flashcards/commit/e704a28524e1217b6f56ca5a51784db73f5eadce))
* remove duplicate previousPassengersRef declaration ([fad8636](https://github.com/antialias/soroban-abacus-flashcards/commit/fad86367638b1a48b7e1544976148f43b061c832))
* remove frozen lockfile flag from publishing workflow to resolve dependency installation issues ([18af973](https://github.com/antialias/soroban-abacus-flashcards/commit/18af9730ffbcd822da292161815ffd09ad97f66c))
* remove hard-coded car count from game loop ([6c90a68](https://github.com/antialias/soroban-abacus-flashcards/commit/6c90a68c49b5f7fbc262c38f9a8828f5c725cb6a))
* remove unnecessary zIndex from question display ([db52e14](https://github.com/antialias/soroban-abacus-flashcards/commit/db52e14dfe9aceaaa1f98fc79100c04adc84611a))
* reposition on-screen keyboard to avoid covering abacus tiles ([6e5b4ec](https://github.com/antialias/soroban-abacus-flashcards/commit/6e5b4ec7bf7e2af5f724628693d3c4ee8c5b3968))
* require activePlayers in START_GAME, never fallback to userId ([ea1b1a2](https://github.com/antialias/soroban-abacus-flashcards/commit/ea1b1a2f69a35a6a27f7f952971509b2bb2e6f8d))
* reset momentum and pressure when starting new route ([3ea88d7](https://github.com/antialias/soroban-abacus-flashcards/commit/3ea88d7a5a6ed427b17cf408d993918870b75f7f))
* resolve circular dependency errors in memory quiz on-screen keyboard ([d25e2c4](https://github.com/antialias/soroban-abacus-flashcards/commit/d25e2c4c006b54a51eaa3a93fa8462e3a06221b7))
* resolve JSX parsing error with emoji in guide page ([bf046c9](https://github.com/antialias/soroban-abacus-flashcards/commit/bf046c999b51ba422284a139ebadde2c35187ac7))
* resolve mini navigation game name persistence across all routes ([3fa314a](https://github.com/antialias/soroban-abacus-flashcards/commit/3fa314aaa5de7b9c26a5390a52996c7d5ef9ea51))
* resolve runtime error - calculateOptimalGrid not defined ([fbc84fe](https://github.com/antialias/soroban-abacus-flashcards/commit/fbc84febda5507d434cf60aa0fce32350e01ec96))
* resolve SSR/client hydration mismatch for themed navigation ([301e65d](https://github.com/antialias/soroban-abacus-flashcards/commit/301e65dfa66d0de6b6efbbfbd09b717308ab57f1))
* resolve TypeScript errors in PlayerStatusBar component ([a935e5a](https://github.com/antialias/soroban-abacus-flashcards/commit/a935e5aed8c4584d21c8fc4359453b7dec494464))
* restore navigation to all pages using PageWithNav ([183706d](https://github.com/antialias/soroban-abacus-flashcards/commit/183706dade12080a748b0c074d0bd71fb0471d7e))
* show "Return to Arcade" button only during active game ([4153929](https://github.com/antialias/soroban-abacus-flashcards/commit/4153929a2ab199836249d53d92c1be4979782b73))
* smooth rail curves and deterministic track generation ([4f79c08](https://github.com/antialias/soroban-abacus-flashcards/commit/4f79c08d73c090165a1c419e7aa8ef543bc23e7e))
* stabilize route completion threshold to prevent stuck trains ([b7233f9](https://github.com/antialias/soroban-abacus-flashcards/commit/b7233f9e4afe1dadfe29ecc4430c74074a2674fc))
* update lockfile and fix Makefile paths ([7ba746b](https://github.com/antialias/soroban-abacus-flashcards/commit/7ba746b6bdcc4c06172e1dbacd856da9416e010a))
* update matching game for UUID player system ([2e041dd](https://github.com/antialias/soroban-abacus-flashcards/commit/2e041ddc4443be1d032139ad9850bbc28db5c171))
* update memory pairs game to use StandardGameLayout ([8df76c0](https://github.com/antialias/soroban-abacus-flashcards/commit/8df76c08fdf4108b88ce95de252cb8bd559fc5e4))
* update memory quiz to use StandardGameLayout ([3f86163](https://github.com/antialias/soroban-abacus-flashcards/commit/3f86163c142e577a64adfb3bf262656d2e100ced))
* update pnpm version to 8.15.6 to resolve ERR_INVALID_THIS error in workflow ([0b9bfed](https://github.com/antialias/soroban-abacus-flashcards/commit/0b9bfed12dfd48d9eacae69b378e28e188d3f2b1))
* update race track components for new player system ([ae4e8fc](https://github.com/antialias/soroban-abacus-flashcards/commit/ae4e8fcb5a62c07fb9ffa9a70c07e45ca8be88c8))
* update tutorial tests to use consolidated AbacusDisplayProvider ([899fc69](https://github.com/antialias/soroban-abacus-flashcards/commit/899fc6975f1fa14ddb42b2ead03524c9389e7c38))
* update workflow to support Personal Access Token for GitHub Packages publishing auth ([ae4b71b](https://github.com/antialias/soroban-abacus-flashcards/commit/ae4b71b98655364887a729ef9d2b67b6a753d6e9))
* upgrade CI dependencies and fix deprecated actions ([6a51c1e](https://github.com/antialias/soroban-abacus-flashcards/commit/6a51c1e9bdc299d86b8001eba35f930fe16cd60c))
* use displayPassengers for station rendering in RailroadTrackPath ([a9e0d19](https://github.com/antialias/soroban-abacus-flashcards/commit/a9e0d197348377cb66581150df47d2d1127ad09a))
* use node-linker=hoisted for full dependency hoisting ([d3b2e0b](https://github.com/antialias/soroban-abacus-flashcards/commit/d3b2e0b2e152150886110edd80dfe43f70df63d9))
* use player IDs instead of array indices in matching game ([ccd0d6d](https://github.com/antialias/soroban-abacus-flashcards/commit/ccd0d6d94ccd1cc25eed602d32a9cf884bda2ee6))
* use style fontSize instead of attribute for landmarks ([ebc6894](https://github.com/antialias/soroban-abacus-flashcards/commit/ebc6894746d1d490c3a5cae19c38bb86fe8fdc65))
* use UUID player IDs in session creation fallback ([22541df](https://github.com/antialias/soroban-abacus-flashcards/commit/22541df99f049ce99e020e12c2d28b33434de51d))
* wrap animated pressure value in animated.span to prevent React error ([5c5954b](https://github.com/antialias/soroban-abacus-flashcards/commit/5c5954be74708fb7019802a6dd80b10e9b2c1d6a))
### Performance Improvements
* optimize React rendering with memoization and consolidated effects ([93cb070](https://github.com/antialias/soroban-abacus-flashcards/commit/93cb070ca503effa05541f7ed217bb4260359581))
### Code Refactoring
* completely remove [@nav](https://github.com/nav) parallel routes and simplify navigation ([54ff20c](https://github.com/antialias/soroban-abacus-flashcards/commit/54ff20c7555e028b50471ac83a7030921c76f43b))
* consolidate abacus display context management ([a387b03](https://github.com/antialias/soroban-abacus-flashcards/commit/a387b030fa9fe36f2c8c0e65e34ec9ee872a7afa))
* extract ActivePlayersList component from PageWithNav ([2849576](https://github.com/antialias/soroban-abacus-flashcards/commit/28495767a9b792e6f8b9cc9f4df85e1b27a15c35))
* extract AddPlayerButton component from PageWithNav ([57a72e3](https://github.com/antialias/soroban-abacus-flashcards/commit/57a72e34a5df198ac795ff83cb6bfc6fb8a2f27e))
* extract FullscreenPlayerSelection component from PageWithNav ([66f5223](https://github.com/antialias/soroban-abacus-flashcards/commit/66f52234e12594947069c4ffed3648ce034c3e79))
* extract GameContextNav orchestration component ([e3f552d](https://github.com/antialias/soroban-abacus-flashcards/commit/e3f552d8f588b25d13eca20fbcd4f43b30de917f))
* extract GameHUD component from SteamTrainJourney ([78d5234](https://github.com/antialias/soroban-abacus-flashcards/commit/78d5234a79eb5293ae8eb279fb46b5aa2bbb6ae7))
* extract GameModeIndicator component from PageWithNav ([d67315f](https://github.com/antialias/soroban-abacus-flashcards/commit/d67315f771a3f660be58495c1236f74b49001d23))
* extract guide components to fix syntax error in large file ([c77e880](https://github.com/antialias/soroban-abacus-flashcards/commit/c77e880be32456c1e91d37358d85c445b2f707df))
* extract RailroadTrackPath component from SteamTrainJourney ([d9acc0e](https://github.com/antialias/soroban-abacus-flashcards/commit/d9acc0efea397ce58013160efbb2ed4fbee244b2))
* extract TrainAndCars component from SteamTrainJourney ([5ae22e4](https://github.com/antialias/soroban-abacus-flashcards/commit/5ae22e4645614bc67e7dece509d92bdd85250e28))
* extract TrainTerrainBackground component from SteamTrainJourney ([05bb035](https://github.com/antialias/soroban-abacus-flashcards/commit/05bb035db5483892f101cef0971bf08575cb041b))
* extract usePassengerAnimations hook from SteamTrainJourney ([32abde1](https://github.com/antialias/soroban-abacus-flashcards/commit/32abde107ca050bfc191a325d0bf074d53df33fc))
* extract useTrackManagement hook from SteamTrainJourney ([a1f2b97](https://github.com/antialias/soroban-abacus-flashcards/commit/a1f2b9736a0ff087dae44110708254e6da966b79))
* extract useTrainTransforms hook from SteamTrainJourney ([a2512d5](https://github.com/antialias/soroban-abacus-flashcards/commit/a2512d573823e187aad08f3fed365c4211023bb3))
* make game mode a computed property from active player count ([386c88a](https://github.com/antialias/soroban-abacus-flashcards/commit/386c88a3c03eb6de7814f65a6556a2d0ab50386b))
* remove drag-and-drop UI from EnhancedChampionArena ([982fa45](https://github.com/antialias/soroban-abacus-flashcards/commit/982fa45c08b34fdb64ec0835bc591b850e1c1373))
* remove duplicate game control buttons from game phases ([9165014](https://github.com/antialias/soroban-abacus-flashcards/commit/9165014997ccf6f4859646709d7e800933b4868e))
* remove redundant game titles from game screens ([402724c](https://github.com/antialias/soroban-abacus-flashcards/commit/402724c80e1776b90be1fc02186813389560f380))
* replace bulky MemoryGrid stats with compact progress display ([c4d6691](https://github.com/antialias/soroban-abacus-flashcards/commit/c4d6691715d09066661be4a0af7e917c6217ed8c))
* simplify navigation flow and enhance GameControls UI ([920aaa6](https://github.com/antialias/soroban-abacus-flashcards/commit/920aaa639887b42dcb225cb42ce146e67d29a98e))
* simplify PageWithNav by extracting nav components ([98cfa56](https://github.com/antialias/soroban-abacus-flashcards/commit/98cfa5645bc21faaeffa676d205bdbdf604eb488))
* split deployment info into server/client components ([5e7b273](https://github.com/antialias/soroban-abacus-flashcards/commit/5e7b273b339cd11d7b4b55dd50a1bf6c823b41d5))
* streamline GamePhase header and integrate PlayerStatusBar ([dcefa74](https://github.com/antialias/soroban-abacus-flashcards/commit/dcefa74902c65618f79eda32c94a5b8736b15b55))
* streamline UI and remove duplicate information displays ([7a3e34b](https://github.com/antialias/soroban-abacus-flashcards/commit/7a3e34b4faab62c069e6698a935ad66ee80037d2))
### Documentation
* add comprehensive workflow documentation for automated npm publishing ([f923b53](https://github.com/antialias/soroban-abacus-flashcards/commit/f923b53a44c2875fe152c9bd326d6a427d07a71e))
* add server persistence migration plan ([dd0df8c](https://github.com/antialias/soroban-abacus-flashcards/commit/dd0df8c274f513947e43f014b9086f77077f0196))
### Tests
* add comprehensive unit tests for refactored hooks and components ([5d20839](https://github.com/antialias/soroban-abacus-flashcards/commit/5d2083903e9eb751d810921e06dc51b7137f726f))
* add E2E tests for arcade modal session behavior ([619be98](https://github.com/antialias/soroban-abacus-flashcards/commit/619be9859c548b6c3f0af45379a61f690bcb8e13))
## [1.2.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v1.2.0...v1.2.1) (2025-09-28)

View File

@@ -1,8 +1,11 @@
# Multi-stage build for Soroban Abacus Flashcards
FROM node:18-alpine AS base
# Install Python and build tools for better-sqlite3
RUN apk add --no-cache python3 py3-setuptools make g++
# Install pnpm and turbo
RUN npm install -g pnpm@8.0.0 turbo@1.10.0
RUN npm install -g pnpm@9.15.4 turbo@1.10.0
WORKDIR /app
@@ -14,7 +17,7 @@ COPY packages/core/client/typescript/package.json ./packages/core/client/typescr
COPY packages/abacus-react/package.json ./packages/abacus-react/
COPY packages/templates/package.json ./packages/templates/
# Install dependencies
# Install dependencies (will use .npmrc with hoisted mode)
RUN pnpm install --frozen-lockfile
# Builder stage

View File

@@ -19,7 +19,7 @@ install:
# Generate default flashcards
out/flashcards.pdf: check-deps
@mkdir -p out
python3 src/generate.py --config config/default.yaml --output out/flashcards.pdf
python3 packages/core/src/generate.py --config config/default.yaml --output out/flashcards.pdf
# Generate linearized version
out/flashcards_linear.pdf: out/flashcards.pdf
@@ -29,23 +29,23 @@ out/flashcards_linear.pdf: out/flashcards.pdf
samples: check-deps
@echo "Generating sample outputs..."
@mkdir -p out/samples
python3 src/generate.py --config config/default.yaml --output out/samples/default.pdf
python3 src/generate.py --config config/0-99.yaml --output out/samples/0-99.pdf
python3 src/generate.py --config config/3-column-fixed.yaml --output out/samples/3-column-fixed.pdf
python3 src/generate.py --range "1,2,5,10,20,50,100" --cards-per-page 8 --output out/samples/custom-list.pdf
python3 packages/core/src/generate.py --config config/default.yaml --output out/samples/default.pdf
python3 packages/core/src/generate.py --config config/0-99.yaml --output out/samples/0-99.pdf
python3 packages/core/src/generate.py --config config/3-column-fixed.yaml --output out/samples/3-column-fixed.pdf
python3 packages/core/src/generate.py --range "1,2,5,10,20,50,100" --cards-per-page 8 --output out/samples/custom-list.pdf
@echo "Sample PDFs generated in out/samples/"
# Quick test with small range
test: check-deps
@echo "Running quick test..."
python3 src/generate.py --range "0-9" --output out/test.pdf
python3 packages/core/src/generate.py --range "0-9" --output out/test.pdf
@command -v qpdf >/dev/null 2>&1 && qpdf --check out/test.pdf || echo "PDF generated (validation skipped)"
@echo "Test completed successfully"
# Generate README example images
examples: check-deps
@echo "Generating example images for README..."
@python3 src/generate_examples.py
@python3 packages/core/src/generate_examples.py
@echo "✓ Example images generated in docs/images/"
# Verify examples are up to date (for CI)

View File

@@ -0,0 +1,38 @@
{
"permissions": {
"allow": [
"Bash(npm run build:*)",
"Bash(npx tsc:*)",
"Bash(curl:*)",
"Bash(git add:*)",
"Bash(git commit -m \"$(cat <<''EOF''\nfix: lazy-load database connection to prevent build-time access\n\nRefactor db/index.ts to use lazy initialization via Proxy pattern.\nThis prevents the database from being accessed at module import time,\nwhich was causing Next.js build failures in CI/CD environments where\nno database file exists.\n\nThe database connection is now created only when first accessed at\nruntime, allowing static site generation to complete successfully.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
"Bash(git push:*)",
"Read(//Users/antialias/projects/soroban-abacus-flashcards/**)",
"Bash(npm install:*)",
"Bash(cat:*)",
"Bash(pnpm add:*)",
"Bash(npx biome check:*)",
"Bash(npx:*)",
"Bash(eslint:*)",
"Bash(npm run lint:fix:*)",
"Bash(npm run format:*)",
"Bash(npm run lint:*)",
"Bash(pnpm install:*)",
"Bash(pnpm run:*)",
"Bash(rm:*)",
"Bash(lsof:*)",
"Bash(xargs kill:*)",
"Bash(tee:*)",
"Bash(for file in src/app/arcade/complement-race/components/RaceTrack/CircularTrack.tsx src/app/arcade/complement-race/components/RaceTrack/LinearTrack.tsx src/app/games/complement-race/components/RaceTrack/CircularTrack.tsx src/app/games/complement-race/components/RaceTrack/LinearTrack.tsx)",
"Bash(do)",
"Bash(done)",
"Bash(for file in src/app/arcade/complement-race/components/RaceTrack/SteamTrainJourney.tsx src/app/games/complement-race/components/RaceTrack/SteamTrainJourney.tsx)",
"Bash(for file in src/app/arcade/complement-race/hooks/useTrackManagement.ts src/app/games/complement-race/hooks/useTrackManagement.ts)",
"Bash(echo \"EXIT CODE: $?\")",
"Bash(git commit -m \"$(cat <<''EOF''\nfeat: add Biome + ESLint linting setup\n\nAdd Biome for formatting and general linting, with minimal ESLint\nconfiguration for React Hooks rules only. This provides:\n\n- Fast formatting via Biome (10-100x faster than Prettier)\n- General JS/TS linting via Biome\n- React Hooks validation via ESLint (rules-of-hooks)\n- Import organization via Biome\n\nConfiguration files:\n- biome.jsonc: Biome config with custom rule overrides\n- eslint.config.js: Minimal flat config for React Hooks only\n- .gitignore: Added Biome cache exclusion\n- LINTING.md: Documentation for the setup\n\nScripts added to package.json:\n- npm run lint: Check all files\n- npm run lint:fix: Auto-fix issues\n- npm run format: Format all files\n- npm run check: Full Biome check\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
"Bash(git commit:*)"
],
"deny": [],
"ask": []
}
}

50
apps/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,50 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# vitest
/.vitest
# storybook
storybook-static
# panda css
styled-system
# generated
src/generated/build-info.json
# biome
.biome

View File

@@ -1,36 +1,31 @@
import type { StorybookConfig } from '@storybook/nextjs';
import type { StorybookConfig } from '@storybook/nextjs'
import { join, dirname } from "path"
import { dirname, join } from 'path'
/**
* This function is used to resolve the absolute path of a package.
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
*/
* This function is used to resolve the absolute path of a package.
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
*/
function getAbsolutePath(value: string): any {
return dirname(require.resolve(join(value, 'package.json')))
}
const config: StorybookConfig = {
"stories": [
"../src/**/*.mdx",
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
],
"addons": [
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
getAbsolutePath('@storybook/addon-docs'),
getAbsolutePath('@storybook/addon-onboarding')
getAbsolutePath('@storybook/addon-onboarding'),
],
"framework": {
"name": getAbsolutePath('@storybook/nextjs'),
"options": {
"nextConfigPath": "../next.config.js"
}
framework: {
name: getAbsolutePath('@storybook/nextjs'),
options: {
nextConfigPath: '../next.config.js',
},
},
"staticDirs": [
"../public"
],
"typescript": {
"reactDocgen": "react-docgen-typescript"
staticDirs: ['../public'],
typescript: {
reactDocgen: 'react-docgen-typescript',
},
"webpackFinal": async (config) => {
webpackFinal: async (config) => {
// Handle PandaCSS styled-system imports
if (config.resolve) {
config.resolve.alias = {
@@ -39,10 +34,10 @@ const config: StorybookConfig = {
'../../styled-system/css': join(__dirname, '../styled-system/css/index.mjs'),
'../../styled-system/patterns': join(__dirname, '../styled-system/patterns/index.mjs'),
'../styled-system/css': join(__dirname, '../styled-system/css/index.mjs'),
'../styled-system/patterns': join(__dirname, '../styled-system/patterns/index.mjs')
'../styled-system/patterns': join(__dirname, '../styled-system/patterns/index.mjs'),
}
}
return config
}
};
export default config;
},
}
export default config

View File

@@ -5,11 +5,11 @@ const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
}
export default preview;
export default preview

104
apps/web/LINTING.md Normal file
View File

@@ -0,0 +1,104 @@
# Linting & Formatting Setup
This project uses **Biome** for formatting and general linting, with **ESLint** handling React Hooks rules only.
## Tools
- **@biomejs/biome** - Fast formatter + linter + import organizer
- **eslint** + **eslint-plugin-react-hooks** - React Hooks validation only
## Scripts
```bash
# Check formatting and lint (non-destructive)
npm run check
# Lint all files
npm run lint
# Fix lint issues
npm run lint:fix
# Format all files
npm run format
# Check formatting (dry run)
npm run format:check
```
## Configuration Files
- `biome.jsonc` - Biome configuration (format + lint)
- `eslint.config.js` - Minimal ESLint flat config for React Hooks only
- `.gitignore` - Includes patterns for Biome cache
## What Each Tool Does
### Biome
- Code formatting (Prettier-compatible)
- General JavaScript/TypeScript linting
- Import organization (alphabetical, remove unused)
- Dead code detection
- Performance optimizations
### ESLint (React Hooks only)
- `react-hooks/rules-of-hooks` - Ensures hooks are called unconditionally
- `react-hooks/exhaustive-deps` - Warns about incomplete dependency arrays
## IDE Integration
### VS Code
Install the Biome extension:
```
code --install-extension biomejs.biome
```
Add to `.vscode/settings.json`:
```json
{
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
```
## CI/CD
Add to your GitHub Actions workflow:
```yaml
- name: Lint
run: npm run lint
- name: Check formatting
run: npm run format:check
```
## Migration from ESLint + Prettier
This setup replaces most ESLint and Prettier functionality:
- ✅ Removed `eslint-config-next` inline config from `package.json`
- ✅ No `.eslintrc.js` or `.prettierrc` files needed
- ✅ ESLint now only runs React Hooks rules
- ✅ Biome handles all formatting and general linting
## Why This Setup?
1. **Speed** - Biome is 10-100x faster than ESLint + Prettier
2. **Simplicity** - Single tool for most concerns
3. **Accuracy** - ESLint still catches React-specific issues Biome can't yet handle
4. **Low Maintenance** - Minimal config overlap
## Customization
To add custom lint rules, edit:
- `biome.jsonc` for general rules
- `eslint.config.js` for React Hooks rules

View File

@@ -0,0 +1,344 @@
/**
* @vitest-environment node
*/
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { db, schema } from '../src/db'
/**
* API Abacus Settings E2E Tests
*
* These tests verify the abacus-settings API endpoints work correctly.
*/
describe('Abacus Settings API', () => {
let testUserId: string
let testGuestId: string
beforeEach(async () => {
// Create a test user with unique guest ID
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning()
testUserId = user.id
})
afterEach(async () => {
// Clean up: delete test user (cascade deletes settings)
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
})
describe('GET /api/abacus-settings', () => {
it('creates settings with defaults if none exist', async () => {
const [settings] = await db
.insert(schema.abacusSettings)
.values({ userId: testUserId })
.returning()
expect(settings).toBeDefined()
expect(settings.colorScheme).toBe('place-value')
expect(settings.beadShape).toBe('diamond')
expect(settings.colorPalette).toBe('default')
expect(settings.hideInactiveBeads).toBe(false)
expect(settings.coloredNumerals).toBe(false)
expect(settings.scaleFactor).toBe(1.0)
expect(settings.showNumbers).toBe(true)
expect(settings.animated).toBe(true)
expect(settings.interactive).toBe(false)
expect(settings.gestures).toBe(false)
expect(settings.soundEnabled).toBe(true)
expect(settings.soundVolume).toBe(0.8)
})
it('returns existing settings', async () => {
// Create settings
await db.insert(schema.abacusSettings).values({
userId: testUserId,
colorScheme: 'monochrome',
beadShape: 'circle',
soundEnabled: false,
soundVolume: 0.5,
})
const settings = await db.query.abacusSettings.findFirst({
where: eq(schema.abacusSettings.userId, testUserId),
})
expect(settings).toBeDefined()
expect(settings?.colorScheme).toBe('monochrome')
expect(settings?.beadShape).toBe('circle')
expect(settings?.soundEnabled).toBe(false)
expect(settings?.soundVolume).toBe(0.5)
})
})
describe('PATCH /api/abacus-settings', () => {
it('creates new settings if none exist', async () => {
const [settings] = await db
.insert(schema.abacusSettings)
.values({
userId: testUserId,
soundEnabled: false,
})
.returning()
expect(settings).toBeDefined()
expect(settings.soundEnabled).toBe(false)
})
it('updates existing settings', async () => {
// Create initial settings
await db.insert(schema.abacusSettings).values({
userId: testUserId,
colorScheme: 'place-value',
beadShape: 'diamond',
})
// Update
const [updated] = await db
.update(schema.abacusSettings)
.set({
colorScheme: 'heaven-earth',
beadShape: 'square',
})
.where(eq(schema.abacusSettings.userId, testUserId))
.returning()
expect(updated.colorScheme).toBe('heaven-earth')
expect(updated.beadShape).toBe('square')
})
it('updates only provided fields', async () => {
// Create initial settings
await db.insert(schema.abacusSettings).values({
userId: testUserId,
colorScheme: 'place-value',
soundEnabled: true,
soundVolume: 0.8,
})
// Update only soundEnabled
const [updated] = await db
.update(schema.abacusSettings)
.set({ soundEnabled: false })
.where(eq(schema.abacusSettings.userId, testUserId))
.returning()
expect(updated.soundEnabled).toBe(false)
expect(updated.colorScheme).toBe('place-value') // unchanged
expect(updated.soundVolume).toBe(0.8) // unchanged
})
it('prevents setting invalid userId via foreign key constraint', async () => {
// Create initial settings
await db.insert(schema.abacusSettings).values({
userId: testUserId,
})
// Try to update with invalid userId - should fail
await expect(async () => {
await db
.update(schema.abacusSettings)
.set({
userId: 'HACKER_ID_INVALID',
soundEnabled: false,
})
.where(eq(schema.abacusSettings.userId, testUserId))
}).rejects.toThrow()
})
it('allows updating all display settings', async () => {
await db.insert(schema.abacusSettings).values({
userId: testUserId,
})
const [updated] = await db
.update(schema.abacusSettings)
.set({
colorScheme: 'alternating',
beadShape: 'circle',
colorPalette: 'colorblind',
hideInactiveBeads: true,
coloredNumerals: true,
scaleFactor: 1.5,
showNumbers: false,
animated: false,
interactive: true,
gestures: true,
soundEnabled: false,
soundVolume: 0.3,
})
.where(eq(schema.abacusSettings.userId, testUserId))
.returning()
expect(updated.colorScheme).toBe('alternating')
expect(updated.beadShape).toBe('circle')
expect(updated.colorPalette).toBe('colorblind')
expect(updated.hideInactiveBeads).toBe(true)
expect(updated.coloredNumerals).toBe(true)
expect(updated.scaleFactor).toBe(1.5)
expect(updated.showNumbers).toBe(false)
expect(updated.animated).toBe(false)
expect(updated.interactive).toBe(true)
expect(updated.gestures).toBe(true)
expect(updated.soundEnabled).toBe(false)
expect(updated.soundVolume).toBe(0.3)
})
})
describe('Cascade delete behavior', () => {
it('deletes settings when user is deleted', async () => {
// Create settings
await db.insert(schema.abacusSettings).values({
userId: testUserId,
soundEnabled: false,
})
// Verify settings exist
let settings = await db.query.abacusSettings.findFirst({
where: eq(schema.abacusSettings.userId, testUserId),
})
expect(settings).toBeDefined()
// Delete user
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
// Verify settings are gone
settings = await db.query.abacusSettings.findFirst({
where: eq(schema.abacusSettings.userId, testUserId),
})
expect(settings).toBeUndefined()
})
})
describe('Data isolation', () => {
it('ensures settings are isolated per user', async () => {
// Create another user
const testGuestId2 = `test-guest-2-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [user2] = await db.insert(schema.users).values({ guestId: testGuestId2 }).returning()
try {
// Create settings for both users
await db.insert(schema.abacusSettings).values({
userId: testUserId,
colorScheme: 'monochrome',
})
await db.insert(schema.abacusSettings).values({
userId: user2.id,
colorScheme: 'place-value',
})
// Verify isolation
const settings1 = await db.query.abacusSettings.findFirst({
where: eq(schema.abacusSettings.userId, testUserId),
})
const settings2 = await db.query.abacusSettings.findFirst({
where: eq(schema.abacusSettings.userId, user2.id),
})
expect(settings1?.colorScheme).toBe('monochrome')
expect(settings2?.colorScheme).toBe('place-value')
} finally {
// Clean up second user
await db.delete(schema.users).where(eq(schema.users.id, user2.id))
}
})
})
describe('Security: userId injection prevention', () => {
it('rejects attempts to update settings with non-existent userId', async () => {
// Create initial settings
await db.insert(schema.abacusSettings).values({
userId: testUserId,
soundEnabled: true,
})
// Attempt to inject a fake userId
await expect(async () => {
await db
.update(schema.abacusSettings)
.set({
userId: 'HACKER_ID_NON_EXISTENT',
soundEnabled: false,
})
.where(eq(schema.abacusSettings.userId, testUserId))
}).rejects.toThrow(/FOREIGN KEY constraint failed/)
})
it("prevents modifying another user's settings via userId injection", async () => {
// Create victim user
const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [victimUser] = await db
.insert(schema.users)
.values({ guestId: victimGuestId })
.returning()
try {
// Create settings for both users
await db.insert(schema.abacusSettings).values({
userId: testUserId,
colorScheme: 'monochrome',
soundEnabled: true,
})
await db.insert(schema.abacusSettings).values({
userId: victimUser.id,
colorScheme: 'place-value',
soundEnabled: true,
})
// Attacker tries to change userId to victim's ID
// This is rejected because userId is PRIMARY KEY (UNIQUE constraint)
await expect(async () => {
await db
.update(schema.abacusSettings)
.set({
userId: victimUser.id, // Trying to inject victim's ID
soundEnabled: false,
})
.where(eq(schema.abacusSettings.userId, testUserId))
}).rejects.toThrow(/UNIQUE constraint failed/)
// Verify victim's settings are unchanged
const victimSettings = await db.query.abacusSettings.findFirst({
where: eq(schema.abacusSettings.userId, victimUser.id),
})
expect(victimSettings?.soundEnabled).toBe(true)
expect(victimSettings?.colorScheme).toBe('place-value')
} finally {
await db.delete(schema.users).where(eq(schema.users.id, victimUser.id))
}
})
it('prevents creating settings for another user via userId injection', async () => {
// Create victim user
const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [victimUser] = await db
.insert(schema.users)
.values({ guestId: victimGuestId })
.returning()
try {
// Try to create settings for victim with attacker's data
// This will succeed because foreign key exists, but in the real API
// the userId comes from session, not request body
const [maliciousSettings] = await db
.insert(schema.abacusSettings)
.values({
userId: victimUser.id,
colorScheme: 'alternating', // Attacker's preference
})
.returning()
// This test shows that at the DB level, we CAN insert for any valid userId
// The security comes from the API layer filtering userId from request body
// and deriving it from the session cookie instead
expect(maliciousSettings.userId).toBe(victimUser.id)
expect(maliciousSettings.colorScheme).toBe('alternating')
} finally {
await db.delete(schema.users).where(eq(schema.users.id, victimUser.id))
}
})
})
})

View File

@@ -0,0 +1,500 @@
/**
* @vitest-environment node
*/
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { db, schema } from '../src/db'
/**
* API Players E2E Tests
*
* These tests verify the players API endpoints work correctly.
* They use the actual database and test the full request/response cycle.
*/
describe('Players API', () => {
let testUserId: string
let testGuestId: string
beforeEach(async () => {
// Create a test user with unique guest ID
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning()
testUserId = user.id
})
afterEach(async () => {
// Clean up: delete test user (cascade deletes players)
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
})
describe('POST /api/players', () => {
it('creates a player with valid data', async () => {
const playerData = {
name: 'Test Player',
emoji: '😀',
color: '#3b82f6',
isActive: true,
}
// Simulate creating via DB (API would do this)
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
...playerData,
})
.returning()
expect(player).toBeDefined()
expect(player.name).toBe(playerData.name)
expect(player.emoji).toBe(playerData.emoji)
expect(player.color).toBe(playerData.color)
expect(player.isActive).toBe(true)
expect(player.userId).toBe(testUserId)
})
it('sets isActive to false by default', async () => {
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Inactive Player',
emoji: '😴',
color: '#999999',
})
.returning()
expect(player.isActive).toBe(false)
})
})
describe('GET /api/players', () => {
it('returns all players for a user', async () => {
// Create multiple players
await db.insert(schema.players).values([
{
userId: testUserId,
name: 'Player 1',
emoji: '😀',
color: '#3b82f6',
},
{
userId: testUserId,
name: 'Player 2',
emoji: '😎',
color: '#8b5cf6',
},
])
const players = await db.query.players.findMany({
where: eq(schema.players.userId, testUserId),
})
expect(players).toHaveLength(2)
expect(players[0].name).toBe('Player 1')
expect(players[1].name).toBe('Player 2')
})
it('returns empty array for user with no players', async () => {
const players = await db.query.players.findMany({
where: eq(schema.players.userId, testUserId),
})
expect(players).toHaveLength(0)
})
})
describe('PATCH /api/players/[id]', () => {
it('updates player fields', async () => {
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Original Name',
emoji: '😀',
color: '#3b82f6',
})
.returning()
const [updated] = await db
.update(schema.players)
.set({
name: 'Updated Name',
emoji: '🎉',
})
.where(eq(schema.players.id, player.id))
.returning()
expect(updated.name).toBe('Updated Name')
expect(updated.emoji).toBe('🎉')
expect(updated.color).toBe('#3b82f6') // unchanged
})
it('toggles isActive status', async () => {
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Test Player',
emoji: '😀',
color: '#3b82f6',
isActive: false,
})
.returning()
const [updated] = await db
.update(schema.players)
.set({ isActive: true })
.where(eq(schema.players.id, player.id))
.returning()
expect(updated.isActive).toBe(true)
})
})
describe('DELETE /api/players/[id]', () => {
it('deletes a player', async () => {
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'To Delete',
emoji: '👋',
color: '#ef4444',
})
.returning()
const [deleted] = await db
.delete(schema.players)
.where(eq(schema.players.id, player.id))
.returning()
expect(deleted).toBeDefined()
expect(deleted.id).toBe(player.id)
// Verify it's gone
const found = await db.query.players.findFirst({
where: eq(schema.players.id, player.id),
})
expect(found).toBeUndefined()
})
})
describe('Cascade delete behavior', () => {
it('deletes players when user is deleted', async () => {
// Create players
await db.insert(schema.players).values([
{
userId: testUserId,
name: 'Player 1',
emoji: '😀',
color: '#3b82f6',
},
{
userId: testUserId,
name: 'Player 2',
emoji: '😎',
color: '#8b5cf6',
},
])
// Verify players exist
let players = await db.query.players.findMany({
where: eq(schema.players.userId, testUserId),
})
expect(players).toHaveLength(2)
// Delete user
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
// Verify players are gone
players = await db.query.players.findMany({
where: eq(schema.players.userId, testUserId),
})
expect(players).toHaveLength(0)
})
})
describe('Arcade Session: isActive Modification Restrictions', () => {
it('prevents isActive changes when user has an active arcade session', async () => {
// Create a player
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Test Player',
emoji: '😀',
color: '#3b82f6',
isActive: false,
})
.returning()
// Create an active arcade session
const now = new Date()
await db.insert(schema.arcadeSessions).values({
userId: testGuestId,
currentGame: 'matching',
gameUrl: '/arcade/matching',
gameState: JSON.stringify({}),
activePlayers: JSON.stringify([player.id]),
startedAt: now,
lastActivityAt: now,
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
version: 1,
})
// Attempt to update isActive should be prevented at API level
// This test validates the logic that the API route implements
const activeSession = await db.query.arcadeSessions.findFirst({
where: eq(schema.arcadeSessions.userId, testGuestId),
})
expect(activeSession).toBeDefined()
expect(activeSession?.currentGame).toBe('matching')
// Clean up session
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testGuestId))
})
it('allows isActive changes when user has no active arcade session', async () => {
// Create a player
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Test Player',
emoji: '😀',
color: '#3b82f6',
isActive: false,
})
.returning()
// Verify no active session
const activeSession = await db.query.arcadeSessions.findFirst({
where: eq(schema.arcadeSessions.userId, testGuestId),
})
expect(activeSession).toBeUndefined()
// Should be able to update isActive
const [updated] = await db
.update(schema.players)
.set({ isActive: true })
.where(eq(schema.players.id, player.id))
.returning()
expect(updated.isActive).toBe(true)
})
it('allows non-isActive changes even with active session', async () => {
// Create a player
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Test Player',
emoji: '😀',
color: '#3b82f6',
isActive: true,
})
.returning()
// Create an active arcade session
const now = new Date()
await db.insert(schema.arcadeSessions).values({
userId: testGuestId,
currentGame: 'matching',
gameUrl: '/arcade/matching',
gameState: JSON.stringify({}),
activePlayers: JSON.stringify([player.id]),
startedAt: now,
lastActivityAt: now,
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
version: 1,
})
try {
// Should be able to update name, emoji, color (non-isActive fields)
const [updated] = await db
.update(schema.players)
.set({
name: 'Updated Name',
emoji: '🎉',
color: '#ff0000',
})
.where(eq(schema.players.id, player.id))
.returning()
expect(updated.name).toBe('Updated Name')
expect(updated.emoji).toBe('🎉')
expect(updated.color).toBe('#ff0000')
expect(updated.isActive).toBe(true) // Unchanged
} finally {
// Clean up session
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testGuestId))
}
})
it('session ends, then isActive changes are allowed again', async () => {
// Create a player
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Test Player',
emoji: '😀',
color: '#3b82f6',
isActive: true,
})
.returning()
// Create an active arcade session
const now = new Date()
await db.insert(schema.arcadeSessions).values({
userId: testGuestId,
currentGame: 'matching',
gameUrl: '/arcade/matching',
gameState: JSON.stringify({}),
activePlayers: JSON.stringify([player.id]),
startedAt: now,
lastActivityAt: now,
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
version: 1,
})
// Verify session exists
let activeSession = await db.query.arcadeSessions.findFirst({
where: eq(schema.arcadeSessions.userId, testGuestId),
})
expect(activeSession).toBeDefined()
// End the session
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testGuestId))
// Verify session is gone
activeSession = await db.query.arcadeSessions.findFirst({
where: eq(schema.arcadeSessions.userId, testGuestId),
})
expect(activeSession).toBeUndefined()
// Now should be able to update isActive
const [updated] = await db
.update(schema.players)
.set({ isActive: false })
.where(eq(schema.players.id, player.id))
.returning()
expect(updated.isActive).toBe(false)
})
})
describe('Security: userId injection prevention', () => {
it('rejects creating player with non-existent userId', async () => {
// Attempt to create a player with a fake userId
await expect(async () => {
await db.insert(schema.players).values({
userId: 'HACKER_ID_NON_EXISTENT',
name: 'Hacker Player',
emoji: '🦹',
color: '#ff0000',
})
}).rejects.toThrow(/FOREIGN KEY constraint failed/)
})
it("prevents modifying another user's player via userId injection (DB layer alone is insufficient)", async () => {
// Create victim user and their player
const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [victimUser] = await db
.insert(schema.users)
.values({ guestId: victimGuestId })
.returning()
try {
// Create attacker's player
const [attackerPlayer] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Attacker Player',
emoji: '😈',
color: '#ff0000',
})
.returning()
const [_victimPlayer] = await db
.insert(schema.players)
.values({
userId: victimUser.id,
name: 'Victim Player',
emoji: '👤',
color: '#00ff00',
isActive: true,
})
.returning()
// IMPORTANT: At the DB level, changing userId to another valid userId SUCCEEDS
// This is why API layer MUST filter userId from request body!
const [updated] = await db
.update(schema.players)
.set({
userId: victimUser.id, // This WILL succeed at DB level!
name: 'Stolen Player',
})
.where(eq(schema.players.id, attackerPlayer.id))
.returning()
// The update succeeded - the player now belongs to victim!
expect(updated.userId).toBe(victimUser.id)
expect(updated.name).toBe('Stolen Player')
// This test demonstrates why the API route MUST:
// 1. Strip userId from request body
// 2. Derive userId from session cookie
// 3. Use WHERE clause to scope updates to current user's data only
} finally {
await db.delete(schema.users).where(eq(schema.users.id, victimUser.id))
}
})
it('ensures players are isolated per user', async () => {
// Create another user
const user2GuestId = `user2-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [user2] = await db.insert(schema.users).values({ guestId: user2GuestId }).returning()
try {
// Create players for both users
await db.insert(schema.players).values({
userId: testUserId,
name: 'User 1 Player',
emoji: '🎮',
color: '#0000ff',
})
await db.insert(schema.players).values({
userId: user2.id,
name: 'User 2 Player',
emoji: '🎯',
color: '#ff00ff',
})
// Verify each user only sees their own players
const user1Players = await db.query.players.findMany({
where: eq(schema.players.userId, testUserId),
})
const user2Players = await db.query.players.findMany({
where: eq(schema.players.userId, user2.id),
})
expect(user1Players).toHaveLength(1)
expect(user1Players[0].name).toBe('User 1 Player')
expect(user2Players).toHaveLength(1)
expect(user2Players[0].name).toBe('User 2 Player')
} finally {
await db.delete(schema.users).where(eq(schema.users.id, user2.id))
}
})
})
})

View File

@@ -0,0 +1,186 @@
/**
* @vitest-environment node
*/
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { db, schema } from '../src/db'
/**
* API User Stats E2E Tests
*
* These tests verify the user-stats API endpoints work correctly.
*/
describe('User Stats API', () => {
let testUserId: string
let testGuestId: string
beforeEach(async () => {
// Create a test user with unique guest ID
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning()
testUserId = user.id
})
afterEach(async () => {
// Clean up: delete test user (cascade deletes stats)
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
})
describe('GET /api/user-stats', () => {
it('creates stats with defaults if none exist', async () => {
const [stats] = await db.insert(schema.userStats).values({ userId: testUserId }).returning()
expect(stats).toBeDefined()
expect(stats.gamesPlayed).toBe(0)
expect(stats.totalWins).toBe(0)
expect(stats.favoriteGameType).toBeNull()
expect(stats.bestTime).toBeNull()
expect(stats.highestAccuracy).toBe(0)
})
it('returns existing stats', async () => {
// Create stats
await db.insert(schema.userStats).values({
userId: testUserId,
gamesPlayed: 10,
totalWins: 7,
favoriteGameType: 'abacus-numeral',
bestTime: 5000,
highestAccuracy: 0.95,
})
const stats = await db.query.userStats.findFirst({
where: eq(schema.userStats.userId, testUserId),
})
expect(stats).toBeDefined()
expect(stats?.gamesPlayed).toBe(10)
expect(stats?.totalWins).toBe(7)
expect(stats?.favoriteGameType).toBe('abacus-numeral')
expect(stats?.bestTime).toBe(5000)
expect(stats?.highestAccuracy).toBe(0.95)
})
})
describe('PATCH /api/user-stats', () => {
it('creates new stats if none exist', async () => {
const [stats] = await db
.insert(schema.userStats)
.values({
userId: testUserId,
gamesPlayed: 1,
totalWins: 1,
})
.returning()
expect(stats).toBeDefined()
expect(stats.gamesPlayed).toBe(1)
expect(stats.totalWins).toBe(1)
})
it('updates existing stats', async () => {
// Create initial stats
await db.insert(schema.userStats).values({
userId: testUserId,
gamesPlayed: 5,
totalWins: 3,
})
// Update
const [updated] = await db
.update(schema.userStats)
.set({
gamesPlayed: 6,
totalWins: 4,
favoriteGameType: 'complement-pairs',
})
.where(eq(schema.userStats.userId, testUserId))
.returning()
expect(updated.gamesPlayed).toBe(6)
expect(updated.totalWins).toBe(4)
expect(updated.favoriteGameType).toBe('complement-pairs')
})
it('updates only provided fields', async () => {
// Create initial stats
await db.insert(schema.userStats).values({
userId: testUserId,
gamesPlayed: 10,
totalWins: 5,
bestTime: 3000,
})
// Update only gamesPlayed
const [updated] = await db
.update(schema.userStats)
.set({ gamesPlayed: 11 })
.where(eq(schema.userStats.userId, testUserId))
.returning()
expect(updated.gamesPlayed).toBe(11)
expect(updated.totalWins).toBe(5) // unchanged
expect(updated.bestTime).toBe(3000) // unchanged
})
it('allows setting favoriteGameType', async () => {
await db.insert(schema.userStats).values({
userId: testUserId,
})
const [updated] = await db
.update(schema.userStats)
.set({ favoriteGameType: 'abacus-numeral' })
.where(eq(schema.userStats.userId, testUserId))
.returning()
expect(updated.favoriteGameType).toBe('abacus-numeral')
})
it('allows setting bestTime and highestAccuracy', async () => {
await db.insert(schema.userStats).values({
userId: testUserId,
})
const [updated] = await db
.update(schema.userStats)
.set({
bestTime: 2500,
highestAccuracy: 0.98,
})
.where(eq(schema.userStats.userId, testUserId))
.returning()
expect(updated.bestTime).toBe(2500)
expect(updated.highestAccuracy).toBe(0.98)
})
})
describe('Cascade delete behavior', () => {
it('deletes stats when user is deleted', async () => {
// Create stats
await db.insert(schema.userStats).values({
userId: testUserId,
gamesPlayed: 10,
totalWins: 5,
})
// Verify stats exist
let stats = await db.query.userStats.findFirst({
where: eq(schema.userStats.userId, testUserId),
})
expect(stats).toBeDefined()
// Delete user
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
// Verify stats are gone
stats = await db.query.userStats.findFirst({
where: eq(schema.userStats.userId, testUserId),
})
expect(stats).toBeUndefined()
})
})
})

View File

@@ -0,0 +1,124 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it } from 'vitest'
import { GUEST_COOKIE_NAME, verifyGuestToken } from '../src/lib/guest-token'
import { middleware } from '../src/middleware'
describe('Middleware E2E', () => {
beforeEach(() => {
process.env.AUTH_SECRET = 'test-secret-for-middleware'
})
it('sets guest cookie on first request', async () => {
const req = new NextRequest('http://localhost:3000/')
const res = await middleware(req)
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
expect(cookie).toBeDefined()
expect(cookie?.value).toBeDefined()
expect(cookie?.httpOnly).toBe(true)
expect(cookie?.sameSite).toBe('lax')
expect(cookie?.path).toBe('/')
})
it('creates valid guest token', async () => {
const req = new NextRequest('http://localhost:3000/')
const res = await middleware(req)
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
expect(cookie).toBeDefined()
// Verify the token is valid
const verified = await verifyGuestToken(cookie!.value)
expect(verified.sid).toBeDefined()
expect(typeof verified.sid).toBe('string')
})
it('preserves existing guest cookie', async () => {
// First request - creates cookie
const req1 = new NextRequest('http://localhost:3000/')
const res1 = await middleware(req1)
const cookie1 = res1.cookies.get(GUEST_COOKIE_NAME)
// Second request - with existing cookie
const req2 = new NextRequest('http://localhost:3000/')
req2.cookies.set(GUEST_COOKIE_NAME, cookie1!.value)
const res2 = await middleware(req2)
const cookie2 = res2.cookies.get(GUEST_COOKIE_NAME)
// Cookie should not be set again (preserves existing)
expect(cookie2).toBeUndefined()
})
it('sets different guest IDs for different visitors', async () => {
const req1 = new NextRequest('http://localhost:3000/')
const req2 = new NextRequest('http://localhost:3000/')
const res1 = await middleware(req1)
const res2 = await middleware(req2)
const cookie1 = res1.cookies.get(GUEST_COOKIE_NAME)
const cookie2 = res2.cookies.get(GUEST_COOKIE_NAME)
const verified1 = await verifyGuestToken(cookie1!.value)
const verified2 = await verifyGuestToken(cookie2!.value)
// Different visitors get different guest IDs
expect(verified1.sid).not.toBe(verified2.sid)
})
it('sets secure flag in production', async () => {
const originalEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'production'
const req = new NextRequest('http://localhost:3000/')
const res = await middleware(req)
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
expect(cookie?.secure).toBe(true)
process.env.NODE_ENV = originalEnv
})
it('does not set secure flag in development', async () => {
const originalEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'development'
const req = new NextRequest('http://localhost:3000/')
const res = await middleware(req)
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
expect(cookie?.secure).toBe(false)
process.env.NODE_ENV = originalEnv
})
it('sets maxAge correctly', async () => {
const req = new NextRequest('http://localhost:3000/')
const res = await middleware(req)
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
expect(cookie?.maxAge).toBe(60 * 60 * 24 * 30) // 30 days
})
it('runs on valid paths', async () => {
const paths = [
'http://localhost:3000/',
'http://localhost:3000/games',
'http://localhost:3000/tutorial-editor',
'http://localhost:3000/some/deep/path',
]
for (const path of paths) {
const req = new NextRequest(path)
const res = await middleware(req)
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
expect(cookie).toBeDefined()
}
})
})

69
apps/web/biome.jsonc Normal file
View File

@@ -0,0 +1,69 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"a11y": {
"useButtonType": "off",
"noSvgWithoutTitle": "off",
"noLabelWithoutControl": "off",
"noStaticElementInteractions": "off",
"useKeyWithClickEvents": "off",
"useSemanticElements": "off"
},
"suspicious": {
"noExplicitAny": "off",
"noArrayIndexKey": "off",
"noImplicitAnyLet": "off",
"noAssignInExpressions": "off",
"useIterableCallbackReturn": "off"
},
"style": {
"useNodejsImportProtocol": "off",
"noNonNullAssertion": "off",
"noDescendingSpecificity": "off"
},
"correctness": {
"noUnusedVariables": "off",
"noUnusedFunctionParameters": "off",
"useUniqueElementIds": "off",
"noChildrenProp": "off",
"useExhaustiveDependencies": "off",
"noInvalidUseBeforeDeclaration": "off",
"useHookAtTopLevel": "off",
"noNestedComponentDefinitions": "off",
"noUnreachable": "off"
},
"security": {
"noDangerouslySetInnerHtml": "off"
},
"performance": {
"noAccumulatingSpread": "off"
}
}
},
"files": {
"ignoreUnknown": true
},
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true,
"defaultBranch": "main"
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"jsxQuoteStyle": "double",
"semicolons": "asNeeded",
"trailingCommas": "es5"
}
}
}

View File

@@ -0,0 +1,109 @@
# Arcade Rooms Implementation Task List
This is the detailed implementation task list for the arcade rooms feature. Use this to restore the TodoWrite list if the session is interrupted.
## Phase 1: Database & API Foundation
- [ ] Phase 1.1: Create database migration for arcade_rooms and room_members tables
- [ ] Phase 1.2: Implement room-manager.ts with CRUD operations (create, get, update, delete)
- [ ] Phase 1.3: Implement room-membership.ts for member management
- [ ] Phase 1.4: Build API endpoints for room CRUD (/api/arcade/rooms/*)
- [ ] Phase 1.5: Add room code generation utility
- [ ] Phase 1.6: Implement TTL cleanup system for rooms
### Testing Checkpoint 1
- [ ] TESTING CHECKPOINT 1: Write unit tests for all room manager functions
- [ ] TESTING CHECKPOINT 1: Write unit tests for room membership functions
- [ ] TESTING CHECKPOINT 1: Write API endpoint tests for room CRUD operations
- [ ] TESTING CHECKPOINT 1: Manual testing of room creation, joining, and TTL cleanup
## Phase 2: Socket.IO Integration
- [ ] Phase 2.1: Update socket-server.ts for room namespacing
- [ ] Phase 2.2: Implement room-scoped broadcasts in socket handlers
- [ ] Phase 2.3: Add presence tracking for room members
- [ ] Phase 2.4: Update session-manager.ts to support roomId
- [ ] Phase 2.5: Update game state sync to respect room boundaries
### Testing Checkpoint 2
- [ ] TESTING CHECKPOINT 2: Write integration tests for multi-user room sessions
- [ ] TESTING CHECKPOINT 2: Write tests for room-scoped broadcasts
- [ ] TESTING CHECKPOINT 2: Manual testing of multi-tab synchronization within rooms
- [ ] TESTING CHECKPOINT 2: Verify backward compatibility with solo play (no roomId)
## Phase 3: Guest User System
- [ ] Phase 3.1: Implement guest ID generation and storage
- [ ] Phase 3.2: Create useGuestUser hook for guest authentication
- [ ] Phase 3.3: Update auth flow to support optional guest access
- [ ] Phase 3.4: Update API endpoints to accept guest user IDs
### Testing Checkpoint 3
- [ ] TESTING CHECKPOINT 3: Write unit tests for guest ID system
- [ ] TESTING CHECKPOINT 3: Manual testing of guest join flow
- [ ] TESTING CHECKPOINT 3: Test guest user persistence across page refreshes
## Phase 4: UI Components
- [ ] Phase 4.1: Build CreateRoomDialog component with form validation
- [ ] Phase 4.2: Build RoomLobby component showing current room state
- [ ] Phase 4.3: Build RoomLobbyBrowser for public room discovery
- [ ] Phase 4.4: Add RoomContextIndicator to navigation bar
- [ ] Phase 4.5: Wire up useRoom and useRoomMembership hooks
- [ ] Phase 4.6: Implement player selection UI when joining room
### Testing Checkpoint 4
- [ ] TESTING CHECKPOINT 4: Write component unit tests for all room UI components
- [ ] TESTING CHECKPOINT 4: Manual UI testing of room creation flow
- [ ] TESTING CHECKPOINT 4: Manual UI testing of room browser and filtering
- [ ] TESTING CHECKPOINT 4: Test player selection flow when joining rooms
## Phase 5: Routes & Navigation
- [ ] Phase 5.1: Create /arcade/rooms route structure
- [ ] Phase 5.2: Create /arcade/rooms/:roomId route with room lobby
- [ ] Phase 5.3: Create /arcade/rooms/:roomId/:game routes for in-room gameplay
- [ ] Phase 5.4: Update arcade home to include room access entry points
- [ ] Phase 5.5: Add room selector/switcher to navigation
- [ ] Phase 5.6: Implement join-by-code flow with code input dialog
- [ ] Phase 5.7: Add share room functionality (copy link, share code)
### Testing Checkpoint 5
- [ ] TESTING CHECKPOINT 5: Write E2E tests for room creation navigation flow
- [ ] TESTING CHECKPOINT 5: Write E2E tests for join-by-URL flow
- [ ] TESTING CHECKPOINT 5: Write E2E tests for join-by-code flow
- [ ] TESTING CHECKPOINT 5: Manual testing of room navigation across different states
## Phase 6: Final Testing & Polish
- [ ] Phase 6.1: Write E2E tests for complete room creation and join flow
- [ ] Phase 6.2: Write E2E tests for multi-user gameplay in rooms
- [ ] Phase 6.3: Write tests for TTL expiration and cleanup behavior
- [ ] Phase 6.4: Write E2E tests for guest user complete flow
- [ ] Phase 6.5: Write tests for room creator permissions (kick, lock, delete)
- [ ] Phase 6.6: Performance testing with multiple concurrent rooms
- [ ] Phase 6.7: Performance testing with many users in single room
- [ ] Phase 6.8: Add error states and loading states to all UI components
- [ ] Phase 6.9: Add user feedback toasts for room operations
- [ ] Phase 6.10: Final manual user testing of complete room system
- [ ] Phase 6.11: Cross-browser testing (Chrome, Firefox, Safari)
- [ ] Phase 6.12: Mobile responsiveness testing for room UI
---
## Notes
- Total: 62 tasks across 6 phases
- 20 dedicated testing tasks (32% of total)
- Reference: `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/docs/arcade-rooms-technical-plan.md`
## Restoring TodoWrite
To restore this list to TodoWrite format, convert each task to:
```json
{
"content": "Task description",
"status": "pending|in_progress|completed",
"activeForm": "Present continuous form (e.g., 'Creating...')"
}
```

View File

@@ -0,0 +1,895 @@
# 🎮 Arcade Room System - Complete Technical Plan
**Date:** 2025-01-06
**Status:** Ready for Implementation
## Executive Summary
Transform the current singleton arcade session into a multi-room system where users can create, manage, and share public game rooms. Rooms are URL-addressable, support guest users, have configurable TTL, and give creators full moderation control.
---
## 1. Core Requirements
### Room Features
-**Public by default** - All rooms visible in public lobby
-**No capacity limits** - Unlimited players per room
-**Configurable TTL** - Rooms expire based on inactivity (similar to existing session TTL)
-**URL-addressable** - Direct links to join rooms (`/arcade/rooms/{roomId}`)
-**Guest access** - Unauthenticated users can join with temp guest IDs
-**Anyone can create** - No authentication required to create rooms
-**Creator moderation** - Only room creator can kick, lock, or delete room
---
## 2. Database Schema
### New Tables
```typescript
// arcade_rooms table
interface ArcadeRoom {
id: string // UUID - primary key
code: string // 6-char join code (e.g., "ABC123") - unique
name: string // User-defined room name (max 50 chars)
// Creator info
createdBy: string // User/guest ID of creator
creatorName: string // Display name at creation time
createdAt: Date
// Lifecycle
lastActivity: Date // Updated on any room activity
ttlMinutes: number // Time to live in minutes (default: 60)
isLocked: boolean // Creator can lock room (no new joins)
// Game configuration
gameName: string // 'matching', 'complement-race', etc.
gameConfig: JSON // Game-specific settings (difficulty, etc.)
// Current state
status: 'lobby' | 'playing' | 'finished'
currentSessionId: string | null // FK to arcade_sessions (when game active)
// Metadata
totalGamesPlayed: number // Track room usage
}
// room_members table
interface RoomMember {
id: string // UUID - primary key
roomId: string // FK to arcade_rooms - indexed
userId: string // User/guest ID - indexed
displayName: string // Name shown in room
isCreator: boolean // True for room creator
joinedAt: Date
lastSeen: Date // Updated on any activity
isOnline: boolean // Currently connected via socket
}
// Modify existing: arcade_sessions
interface ArcadeSession {
id: string
userId: string
// ... existing fields
roomId: string | null // FK to arcade_rooms (null for solo play)
// When roomId is set, session is shared across room members
}
```
### Indexes
```sql
CREATE INDEX idx_rooms_code ON arcade_rooms(code);
CREATE INDEX idx_rooms_status ON arcade_rooms(status);
CREATE INDEX idx_rooms_last_activity ON arcade_rooms(lastActivity);
CREATE INDEX idx_room_members_room_id ON room_members(roomId);
CREATE INDEX idx_room_members_user_id ON room_members(userId);
CREATE INDEX idx_room_members_online ON room_members(isOnline) WHERE isOnline = true;
```
---
## 3. API Endpoints
### Room CRUD (`/api/arcade/rooms`)
```typescript
// Create room
POST /api/arcade/rooms
Body: {
name: string // Room name
gameName: string // Which game
gameConfig?: object // Game settings
ttlMinutes?: number // Default: 60
creatorName: string // Display name
}
Response: {
room: ArcadeRoom
joinUrl: string // Full URL to share
}
// Get room details
GET /api/arcade/rooms/:roomId
Response: {
room: ArcadeRoom
members: RoomMember[]
canModerate: boolean // True if requester is creator
}
// Update room (creator only)
PATCH /api/arcade/rooms/:roomId
Body: {
name?: string
isLocked?: boolean
ttlMinutes?: number
}
Response: { room: ArcadeRoom }
// Delete room (creator only)
DELETE /api/arcade/rooms/:roomId
Response: { success: boolean }
// Join room by code
GET /api/arcade/rooms/join/:code
Response: {
roomId: string
redirectUrl: string
}
```
### Room Discovery
```typescript
// Public room lobby (list all active rooms)
GET /api/arcade/rooms/lobby
Query: {
gameName?: string // Filter by game
status?: string // Filter by status
limit?: number // Default: 50
offset?: number
}
Response: {
rooms: Array<{
id: string
name: string
code: string
gameName: string
status: string
memberCount: number
createdAt: Date
creatorName: string
}>
total: number
}
// Get user's rooms (all rooms user has joined)
GET /api/arcade/rooms/my-rooms
Query: { userId: string }
Response: {
rooms: Array<RoomWithMemberInfo>
}
```
### Room Membership
```typescript
// Join room
POST /api/arcade/rooms/:roomId/join
Body: {
userId: string // User or guest ID
displayName: string
}
Response: {
member: RoomMember
room: ArcadeRoom
}
// Leave room
POST /api/arcade/rooms/:roomId/leave
Body: { userId: string }
Response: { success: boolean }
// Get members
GET /api/arcade/rooms/:roomId/members
Response: {
members: RoomMember[]
onlineCount: number
}
// Kick member (creator only)
DELETE /api/arcade/rooms/:roomId/members/:userId
Response: { success: boolean }
```
### Room Game Session
```typescript
// Start game in room
POST /api/arcade/rooms/:roomId/start-game
Body: {
initiatedBy: string // Must be room member
activePlayers: string[] // Subset of room members
}
Response: {
sessionId: string
gameState: any
}
// End game (return to lobby)
POST /api/arcade/rooms/:roomId/end-game
Body: { initiatedBy: string }
Response: { success: boolean }
```
---
## 4. WebSocket Protocol
### Socket.IO Room Namespacing
```typescript
// Join room's socket.io room
socket.emit('join-room', {
roomId: string
userId: string
})
// Leave room
socket.emit('leave-room', {
roomId: string
userId: string
})
// Update member presence
socket.emit('update-presence', {
roomId: string
userId: string
isOnline: boolean
})
```
### Server → Client Events (room-scoped broadcasts)
```typescript
// Room state changes
socket.on('room-updated', {
room: ArcadeRoom
})
// Member events
socket.on('member-joined', {
member: RoomMember
memberCount: number
})
socket.on('member-left', {
userId: string
memberCount: number
})
socket.on('member-kicked', {
kickedUserId: string
reason: string
})
socket.on('members-updated', {
members: RoomMember[]
})
// Game session events
socket.on('game-starting', {
sessionId: string
activePlayers: string[]
})
socket.on('game-ended', {
results: any
})
// Room lifecycle
socket.on('room-locked', {
isLocked: boolean
})
socket.on('room-deleted', {
reason: string
})
// Existing game moves (now room-scoped)
socket.on('game-move', { roomId, userId, move })
socket.on('move-accepted', { roomId, gameState, version })
socket.on('move-rejected', { roomId, error })
```
---
## 5. URL Structure & Routing
### New Routes
```typescript
/arcade/rooms // Public room lobby (list all rooms)
/arcade/rooms/create // Create room modal/page
/arcade/rooms/:roomId // Room lobby (pre-game)
/arcade/rooms/:roomId/matching // Game with room context
/arcade/rooms/:roomId/complement-race // Another game
/arcade/join/:code // Short link: redirects to room
// Existing routes (backward compatible)
/arcade // My rooms + quick play
/arcade/matching // Solo play (no room)
```
### Navigation Flow
```
User Journey A: Create Room
1. /arcade → Click "Create Room"
2. /arcade/rooms/create → Fill form, submit
3. /arcade/rooms/{roomId} → Room lobby, share link
4. Click "Start Game" → /arcade/rooms/{roomId}/matching
User Journey B: Join via Link
1. Receive link: example.com/arcade/rooms/{roomId}
2. Opens lobby, automatically joins
3. Wait for game start or click ready
User Journey C: Join via Code
1. /arcade → Click "Join Room", enter ABC123
2. Resolves code → /arcade/rooms/{roomId}
3. Join and wait
User Journey D: Browse Lobby
1. /arcade/rooms → See public room list
2. Click room → /arcade/rooms/{roomId}
3. Join and play
```
---
## 6. UI Components Architecture
### Component Hierarchy
```
/src/components/arcade/rooms/
├── RoomLobbyBrowser.tsx // Public room list (/arcade/rooms)
│ ├── RoomCard.tsx // Individual room preview
│ └── RoomFilters.tsx // Filter by game, status
├── RoomLobby.tsx // Pre-game lobby (/arcade/rooms/:roomId)
│ ├── RoomHeader.tsx // Room name, code, share button
│ ├── RoomMemberList.tsx // Online members
│ ├── RoomSettings.tsx // Creator-only settings
│ └── RoomActions.tsx // Start game, leave, etc.
├── CreateRoomDialog.tsx // Room creation modal
│ ├── GameSelector.tsx // Choose game type
│ ├── RoomNameInput.tsx // Name the room
│ └── AdvancedSettings.tsx // TTL, etc.
├── JoinRoomDialog.tsx // Join by code modal
├── RoomContextIndicator.tsx // Shows room info during game
│ └── RoomMemberAvatars.tsx // Small member list
└── RoomModeration.tsx // Creator controls (kick, lock, delete)
```
### Navigation Updates
```typescript
// Update GameContextNav.tsx
interface GameContextNavProps {
// ... existing props
roomContext?: {
roomId: string
roomName: string
roomCode: string
memberCount: number
isCreator: boolean
}
}
// Shows in nav during room gameplay:
// [🏠 Friday Night (ABC123) • 5 players ▼]
```
---
## 7. State Management
### New Hooks
```typescript
// useArcadeRoom.ts - Room state and membership
export function useArcadeRoom(roomId: string) {
return {
room: ArcadeRoom | null
members: RoomMember[]
isCreator: boolean
isOnline: boolean
joinRoom: (displayName: string) => Promise<void>
leaveRoom: () => Promise<void>
updateRoom: (updates: Partial<ArcadeRoom>) => Promise<void>
deleteRoom: () => Promise<void>
kickMember: (userId: string) => Promise<void>
startGame: (activePlayers: string[]) => Promise<void>
endGame: () => Promise<void>
}
}
// useRoomMembers.ts - Real-time member presence
export function useRoomMembers(roomId: string) {
return {
members: RoomMember[]
onlineMembers: RoomMember[]
onlineCount: number
updatePresence: (isOnline: boolean) => void
}
}
// useRoomLobby.ts - Public room discovery
export function useRoomLobby(filters?: RoomFilters) {
return {
rooms: RoomPreview[]
loading: boolean
refresh: () => void
loadMore: () => void
}
}
// Update useArcadeSession.ts
export function useArcadeSession<TState>(options: {
userId: string
roomId?: string // NEW: optional room context
// ... existing options
}) {
// If roomId provided, session is room-scoped
// All moves broadcast to room members
}
```
---
## 8. Server Implementation
### New Files
```
/src/lib/arcade/
├── room-manager.ts # Core room operations
│ ├── createRoom()
│ ├── getRoomById()
│ ├── updateRoom()
│ ├── deleteRoom()
│ ├── getRoomByCode()
│ └── getPublicRooms()
├── room-membership.ts # Member management
│ ├── joinRoom()
│ ├── leaveRoom()
│ ├── kickMember()
│ ├── getRoomMembers()
│ └── updateMemberPresence()
├── room-validation.ts # Access control
│ ├── canModerateRoom()
│ ├── canJoinRoom()
│ ├── canStartGame()
│ └── validateRoomName()
├── room-ttl.ts # TTL management (reuse existing pattern)
│ ├── scheduleRoomCleanup()
│ ├── updateRoomActivity()
│ └── cleanupExpiredRooms()
└── session-manager.ts # Update for room support
└── createArcadeSession() - accept roomId param
```
### Socket Server Updates
```typescript
// socket-server.ts modifications
io.on('connection', (socket) => {
// Join room (socket.io namespace)
socket.on('join-room', async ({ roomId, userId }) => {
// Validate membership
const member = await getRoomMember(roomId, userId)
if (!member) {
socket.emit('room-error', { error: 'Not a room member' })
return
}
// Join socket.io room
socket.join(`room:${roomId}`)
// Update presence
await updateMemberPresence(roomId, userId, true)
// Broadcast to room
io.to(`room:${roomId}`).emit('member-joined', { member })
// Send current state
const room = await getRoomById(roomId)
const members = await getRoomMembers(roomId)
socket.emit('room-state', { room, members })
})
// Leave room
socket.on('leave-room', async ({ roomId, userId }) => {
socket.leave(`room:${roomId}`)
await updateMemberPresence(roomId, userId, false)
io.to(`room:${roomId}`).emit('member-left', { userId })
})
// Game moves (room-scoped)
socket.on('game-move', async ({ roomId, userId, move }) => {
// Validate room membership
const member = await getRoomMember(roomId, userId)
if (!member) return
// Apply move to room's session
const result = await applyGameMove(userId, move, roomId)
if (result.success) {
// Broadcast to all room members
io.to(`room:${roomId}`).emit('move-accepted', {
gameState: result.session.gameState,
version: result.session.version,
move
})
}
})
// Disconnect handling
socket.on('disconnect', () => {
// Update presence for all rooms user was in
updateAllUserPresence(userId, false)
})
})
```
---
## 9. Guest User System
### Guest ID Generation
```typescript
// /src/lib/guest-id.ts
export function generateGuestId(): string {
// Format: guest_{timestamp}_{random}
// Example: guest_1704556800000_a3f2e1
const timestamp = Date.now()
const random = Math.random().toString(36).substring(2, 8)
return `guest_${timestamp}_${random}`
}
export function isGuestId(userId: string): boolean {
return userId.startsWith('guest_')
}
export function getGuestDisplayName(guestId: string): string {
// Generate friendly name like "Guest 1234"
const hash = guestId.split('_')[2]
const num = parseInt(hash, 36) % 10000
return `Guest ${num}`
}
```
### Guest Session Storage
```typescript
// Store guest ID in localStorage
const GUEST_ID_KEY = 'soroban_guest_id'
export function getOrCreateGuestId(): string {
let guestId = localStorage.getItem(GUEST_ID_KEY)
if (!guestId) {
guestId = generateGuestId()
localStorage.setItem(GUEST_ID_KEY, guestId)
}
return guestId
}
```
---
## 10. TTL Implementation
### Reuse Existing Session TTL Pattern
```typescript
// room-ttl.ts
const DEFAULT_ROOM_TTL_MINUTES = 60
// Cleanup job (run every 5 minutes)
setInterval(async () => {
await cleanupExpiredRooms()
}, 5 * 60 * 1000)
async function cleanupExpiredRooms() {
const now = new Date()
// Find expired rooms
const expiredRooms = await db.query(`
SELECT id FROM arcade_rooms
WHERE status != 'playing'
AND lastActivity < NOW() - INTERVAL '1 minute' * ttlMinutes
`)
for (const room of expiredRooms) {
// Notify members
io.to(`room:${room.id}`).emit('room-deleted', {
reason: 'Room expired due to inactivity'
})
// Delete room and members
await deleteRoom(room.id)
}
}
// Update activity on any room action
export async function touchRoom(roomId: string) {
await db.query(`
UPDATE arcade_rooms
SET lastActivity = NOW()
WHERE id = $1
`, [roomId])
}
```
---
## 11. Room Code Generation
```typescript
// /src/lib/arcade/room-code.ts
const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // No ambiguous chars
const CODE_LENGTH = 6
export async function generateUniqueRoomCode(): Promise<string> {
let attempts = 0
const maxAttempts = 10
while (attempts < maxAttempts) {
const code = generateCode()
const existing = await getRoomByCode(code)
if (!existing) return code
attempts++
}
throw new Error('Failed to generate unique room code')
}
function generateCode(): string {
let code = ''
for (let i = 0; i < CODE_LENGTH; i++) {
const idx = Math.floor(Math.random() * CODE_CHARS.length)
code += CODE_CHARS[idx]
}
return code
}
```
---
## 12. Migration Plan
### Phase 1: Database & API Foundation (Day 1-2)
1. Create database tables and indexes
2. Implement room-manager.ts and room-membership.ts
3. Build API endpoints
4. Add room-code generation
5. Implement TTL cleanup
6. Write unit tests
### Phase 2: Socket.IO Integration (Day 2-3)
1. Update socket-server.ts for room namespacing
2. Implement room-scoped broadcasts
3. Add presence tracking
4. Update session-manager.ts for roomId support
5. Test multi-user room sessions
### Phase 3: Guest User System (Day 3)
1. Implement guest ID generation
2. Add guest user hooks
3. Update auth flow for optional guest access
4. Test guest join/leave flow
### Phase 4: UI Components (Day 4-5)
1. Build CreateRoomDialog
2. Build RoomLobby component
3. Build RoomLobbyBrowser (public lobby)
4. Add RoomContextIndicator to nav
5. Wire up all hooks
### Phase 5: Routes & Navigation (Day 5-6)
1. Create /arcade/rooms routes
2. Update arcade home for room access
3. Add room selector to nav
4. Implement join-by-code flow
5. Add share room functionality
### Phase 6: Testing & Polish (Day 6-7)
1. E2E tests for room creation/join
2. Multi-user gameplay tests
3. TTL and cleanup tests
4. Guest user flow tests
5. Performance testing
6. UI polish and error states
---
## 13. Backward Compatibility
### Solo Play Preserved
- Existing `/arcade/matching` routes work unchanged
- `roomId = null` for solo sessions
- No breaking changes to `useArcadeSession`
- All current functionality remains intact
### Migration Strategy
- Add `roomId` column to `arcade_sessions` (nullable)
- Existing sessions have `roomId = null`
- New room-based sessions have `roomId` populated
- Session logic checks: `if (roomId) { /* room mode */ }`
---
## 14. Security Considerations
### Room Access
- ✅ Validate room membership before allowing game moves
- ✅ Check `isCreator` flag for moderation actions
- ✅ Prevent joining locked rooms
- ✅ Rate limit room creation per IP/user
- ✅ Sanitize room names (max length, no XSS)
### Guest Users
- ✅ Guest IDs stored client-side only (localStorage)
- ✅ No sensitive data in guest profiles
- ✅ Guest names sanitized
- ✅ Rate limit guest actions
- ✅ Allow authenticated users to "claim" guest activity
### Room Moderation
- ✅ Only creator can kick/lock/delete
- ✅ Kicked users cannot rejoin unless creator unlocks
- ✅ Room deletion clears all associated data
- ✅ Audit log for moderation actions
---
## 15. Testing Strategy
### Unit Tests
- Room CRUD operations
- Member join/leave logic
- Code generation uniqueness
- TTL cleanup
- Guest ID generation
- Access control validation
### Integration Tests
- Full room creation → join → play → leave flow
- Multi-user concurrent gameplay
- Socket.IO room broadcasts
- Session synchronization across tabs
- TTL expiration and cleanup
### E2E Tests (Playwright)
- Create room → share link → join as guest
- Browse lobby → join room → play game
- Creator kicks member
- Room locks and unlock
- Room TTL expiration
### Load Tests
- 100+ concurrent rooms
- 10+ players per room
- Rapid join/leave cycles
- Socket.IO message throughput
---
## 16. Performance Optimizations
### Database
- Index on `room_members.roomId` for fast member queries
- Index on `arcade_rooms.code` for quick code lookups
- Index on `room_members.isOnline` for presence queries
- Partition `arcade_rooms` by `createdAt` for TTL cleanup
### Caching
- Cache active room list (1-minute TTL)
- Cache room member counts
- Redis pub/sub for cross-server socket broadcasts (future)
### Socket.IO
- Use socket.io rooms for efficient broadcasting
- Batch presence updates (debounce member online status)
- Compress socket messages for large game states
---
## 17. Future Enhancements (Post-MVP)
1. **Room Templates** - Save room configurations
2. **Private Rooms** - Invite-only with passwords
3. **Room Chat** - Text chat in lobby
4. **Spectator Mode** - Watch games without playing
5. **Room History** - Past games and stats
6. **Tournament Brackets** - Multi-round competitions
7. **Room Search** - Search by name/tag
8. **Room Tags** - Categorize rooms
9. **Friend Integration** - Invite friends directly
10. **Room Analytics** - Popular times, average players, etc.
---
## 18. Open Questions / Decisions Needed
1. **Room Name Validation** - Max length? Profanity filter?
2. **Default TTL** - 60 minutes good default?
3. **Code Reuse** - Can codes be reused after room expires?
4. **Member Limit** - Even though unlimited, warn at certain threshold?
5. **Lobby Pagination** - How many rooms to show initially?
---
## 19. Success Metrics
- ✅ Users can create and join rooms
- ✅ Guest users can participate without auth
- ✅ Multi-user gameplay synchronized across all room members
- ✅ Room creators can moderate effectively
- ✅ Rooms expire correctly based on TTL
- ✅ Public lobby shows active rooms
- ✅ No regressions in solo play mode
- ✅ All tests passing (unit, integration, e2e)
---
## 20. Dependencies
### Existing Systems to Leverage
- ✅ Current arcade session management
- ✅ Existing WebSocket infrastructure (socket-server.ts)
- ✅ Database setup (PostgreSQL)
- ✅ Guest player system (from GameModeContext)
### New Dependencies (if needed)
- None! All can be built with existing stack
---
## Implementation Checklist
- [ ] Create database migration
- [ ] Implement room-manager.ts
- [ ] Implement room-membership.ts
- [ ] Build API endpoints
- [ ] Add room code generation
- [ ] Update socket-server.ts
- [ ] Implement guest ID system
- [ ] Build CreateRoomDialog
- [ ] Build RoomLobby component
- [ ] Build RoomLobbyBrowser
- [ ] Add room routes
- [ ] Update navigation
- [ ] Write unit tests
- [ ] Write integration tests
- [ ] Write e2e tests
- [ ] Documentation
- [ ] Deploy
---
**Ready to implement! 🚀**

View File

@@ -0,0 +1,12 @@
import type { Config } from 'drizzle-kit'
export default {
schema: './src/db/schema/index.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: process.env.DATABASE_URL || './data/sqlite.db',
},
verbose: true,
strict: true,
} satisfies Config

View File

@@ -0,0 +1,32 @@
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`guest_id` text NOT NULL,
`created_at` integer NOT NULL,
`upgraded_at` integer,
`email` text,
`name` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_guest_id_unique` ON `users` (`guest_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint
CREATE TABLE `players` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`name` text NOT NULL,
`emoji` text NOT NULL,
`color` text NOT NULL,
`is_active` integer DEFAULT false NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `players_user_id_idx` ON `players` (`user_id`);--> statement-breakpoint
CREATE TABLE `user_stats` (
`user_id` text PRIMARY KEY NOT NULL,
`games_played` integer DEFAULT 0 NOT NULL,
`total_wins` integer DEFAULT 0 NOT NULL,
`favorite_game_type` text,
`best_time` integer,
`highest_accuracy` real DEFAULT 0 NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);

View File

@@ -0,0 +1,16 @@
CREATE TABLE `abacus_settings` (
`user_id` text PRIMARY KEY NOT NULL,
`color_scheme` text DEFAULT 'place-value' NOT NULL,
`bead_shape` text DEFAULT 'diamond' NOT NULL,
`color_palette` text DEFAULT 'default' NOT NULL,
`hide_inactive_beads` integer DEFAULT false NOT NULL,
`colored_numerals` integer DEFAULT false NOT NULL,
`scale_factor` real DEFAULT 1 NOT NULL,
`show_numbers` integer DEFAULT true NOT NULL,
`animated` integer DEFAULT true NOT NULL,
`interactive` integer DEFAULT false NOT NULL,
`gestures` integer DEFAULT false NOT NULL,
`sound_enabled` integer DEFAULT true NOT NULL,
`sound_volume` real DEFAULT 0.8 NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);

View File

@@ -0,0 +1,13 @@
CREATE TABLE `arcade_sessions` (
`user_id` text PRIMARY KEY NOT NULL,
`current_game` text NOT NULL,
`game_url` text NOT NULL,
`game_state` text NOT NULL,
`active_players` text NOT NULL,
`started_at` integer NOT NULL,
`last_activity_at` integer NOT NULL,
`expires_at` integer NOT NULL,
`is_active` integer DEFAULT true NOT NULL,
`version` integer DEFAULT 1 NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);

View File

@@ -0,0 +1,31 @@
CREATE TABLE `arcade_rooms` (
`id` text PRIMARY KEY NOT NULL,
`code` text(6) NOT NULL,
`name` text(50) NOT NULL,
`created_by` text NOT NULL,
`creator_name` text(50) NOT NULL,
`created_at` integer NOT NULL,
`last_activity` integer NOT NULL,
`ttl_minutes` integer DEFAULT 60 NOT NULL,
`is_locked` integer DEFAULT false NOT NULL,
`game_name` text NOT NULL,
`game_config` text NOT NULL,
`status` text DEFAULT 'lobby' NOT NULL,
`current_session_id` text,
`total_games_played` integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `arcade_rooms_code_unique` ON `arcade_rooms` (`code`);--> statement-breakpoint
CREATE TABLE `room_members` (
`id` text PRIMARY KEY NOT NULL,
`room_id` text NOT NULL,
`user_id` text NOT NULL,
`display_name` text(50) NOT NULL,
`is_creator` integer DEFAULT false NOT NULL,
`joined_at` integer NOT NULL,
`last_seen` integer NOT NULL,
`is_online` integer DEFAULT true NOT NULL,
FOREIGN KEY (`room_id`) REFERENCES `arcade_rooms`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
ALTER TABLE `arcade_sessions` ADD `room_id` text REFERENCES arcade_rooms(id);

View File

@@ -0,0 +1,222 @@
{
"version": "6",
"dialect": "sqlite",
"id": "949424bf-1933-497c-af2d-cab6ee81083d",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guest_id": {
"name": "guest_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"upgraded_at": {
"name": "upgraded_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"users_guest_id_unique": {
"name": "users_guest_id_unique",
"columns": ["guest_id"],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": ["email"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"players": {
"name": "players",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"emoji": {
"name": "emoji",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"players_user_id_idx": {
"name": "players_user_id_idx",
"columns": ["user_id"],
"isUnique": false
}
},
"foreignKeys": {
"players_user_id_users_id_fk": {
"name": "players_user_id_users_id_fk",
"tableFrom": "players",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_stats": {
"name": "user_stats",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"games_played": {
"name": "games_played",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"total_wins": {
"name": "total_wins",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"favorite_game_type": {
"name": "favorite_game_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"best_time": {
"name": "best_time",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"highest_accuracy": {
"name": "highest_accuracy",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"user_stats_user_id_users_id_fk": {
"name": "user_stats_user_id_users_id_fk",
"tableFrom": "user_stats",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,345 @@
{
"version": "6",
"dialect": "sqlite",
"id": "25d98633-0bae-4f6e-845b-ed87f53fc233",
"prevId": "949424bf-1933-497c-af2d-cab6ee81083d",
"tables": {
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guest_id": {
"name": "guest_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"upgraded_at": {
"name": "upgraded_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"users_guest_id_unique": {
"name": "users_guest_id_unique",
"columns": ["guest_id"],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": ["email"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"players": {
"name": "players",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"emoji": {
"name": "emoji",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"players_user_id_idx": {
"name": "players_user_id_idx",
"columns": ["user_id"],
"isUnique": false
}
},
"foreignKeys": {
"players_user_id_users_id_fk": {
"name": "players_user_id_users_id_fk",
"tableFrom": "players",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_stats": {
"name": "user_stats",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"games_played": {
"name": "games_played",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"total_wins": {
"name": "total_wins",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"favorite_game_type": {
"name": "favorite_game_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"best_time": {
"name": "best_time",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"highest_accuracy": {
"name": "highest_accuracy",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"user_stats_user_id_users_id_fk": {
"name": "user_stats_user_id_users_id_fk",
"tableFrom": "user_stats",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"abacus_settings": {
"name": "abacus_settings",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"color_scheme": {
"name": "color_scheme",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'place-value'"
},
"bead_shape": {
"name": "bead_shape",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'diamond'"
},
"color_palette": {
"name": "color_palette",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'default'"
},
"hide_inactive_beads": {
"name": "hide_inactive_beads",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"colored_numerals": {
"name": "colored_numerals",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"scale_factor": {
"name": "scale_factor",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"show_numbers": {
"name": "show_numbers",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"animated": {
"name": "animated",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"interactive": {
"name": "interactive",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"gestures": {
"name": "gestures",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"sound_enabled": {
"name": "sound_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"sound_volume": {
"name": "sound_volume",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0.8
}
},
"indexes": {},
"foreignKeys": {
"abacus_settings_user_id_users_id_fk": {
"name": "abacus_settings_user_id_users_id_fk",
"tableFrom": "abacus_settings",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,437 @@
{
"version": "6",
"dialect": "sqlite",
"id": "194ccc68-7173-44c9-879a-55d20cf3ae1f",
"prevId": "25d98633-0bae-4f6e-845b-ed87f53fc233",
"tables": {
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guest_id": {
"name": "guest_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"upgraded_at": {
"name": "upgraded_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"users_guest_id_unique": {
"name": "users_guest_id_unique",
"columns": ["guest_id"],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": ["email"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"players": {
"name": "players",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"emoji": {
"name": "emoji",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"players_user_id_idx": {
"name": "players_user_id_idx",
"columns": ["user_id"],
"isUnique": false
}
},
"foreignKeys": {
"players_user_id_users_id_fk": {
"name": "players_user_id_users_id_fk",
"tableFrom": "players",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_stats": {
"name": "user_stats",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"games_played": {
"name": "games_played",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"total_wins": {
"name": "total_wins",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"favorite_game_type": {
"name": "favorite_game_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"best_time": {
"name": "best_time",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"highest_accuracy": {
"name": "highest_accuracy",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"user_stats_user_id_users_id_fk": {
"name": "user_stats_user_id_users_id_fk",
"tableFrom": "user_stats",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"abacus_settings": {
"name": "abacus_settings",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"color_scheme": {
"name": "color_scheme",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'place-value'"
},
"bead_shape": {
"name": "bead_shape",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'diamond'"
},
"color_palette": {
"name": "color_palette",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'default'"
},
"hide_inactive_beads": {
"name": "hide_inactive_beads",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"colored_numerals": {
"name": "colored_numerals",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"scale_factor": {
"name": "scale_factor",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"show_numbers": {
"name": "show_numbers",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"animated": {
"name": "animated",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"interactive": {
"name": "interactive",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"gestures": {
"name": "gestures",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"sound_enabled": {
"name": "sound_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"sound_volume": {
"name": "sound_volume",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0.8
}
},
"indexes": {},
"foreignKeys": {
"abacus_settings_user_id_users_id_fk": {
"name": "abacus_settings_user_id_users_id_fk",
"tableFrom": "abacus_settings",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"arcade_sessions": {
"name": "arcade_sessions",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"current_game": {
"name": "current_game",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"game_url": {
"name": "game_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"game_state": {
"name": "game_state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"active_players": {
"name": "active_players",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_activity_at": {
"name": "last_activity_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
}
},
"indexes": {},
"foreignKeys": {
"arcade_sessions_user_id_users_id_fk": {
"name": "arcade_sessions_user_id_users_id_fk",
"tableFrom": "arcade_sessions",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,649 @@
{
"version": "6",
"dialect": "sqlite",
"id": "68cc273f-0d84-4a46-ae41-124a3e06096b",
"prevId": "194ccc68-7173-44c9-879a-55d20cf3ae1f",
"tables": {
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"guest_id": {
"name": "guest_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"upgraded_at": {
"name": "upgraded_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"users_guest_id_unique": {
"name": "users_guest_id_unique",
"columns": ["guest_id"],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": ["email"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"players": {
"name": "players",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"emoji": {
"name": "emoji",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"players_user_id_idx": {
"name": "players_user_id_idx",
"columns": ["user_id"],
"isUnique": false
}
},
"foreignKeys": {
"players_user_id_users_id_fk": {
"name": "players_user_id_users_id_fk",
"tableFrom": "players",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_stats": {
"name": "user_stats",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"games_played": {
"name": "games_played",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"total_wins": {
"name": "total_wins",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"favorite_game_type": {
"name": "favorite_game_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"best_time": {
"name": "best_time",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"highest_accuracy": {
"name": "highest_accuracy",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"user_stats_user_id_users_id_fk": {
"name": "user_stats_user_id_users_id_fk",
"tableFrom": "user_stats",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"abacus_settings": {
"name": "abacus_settings",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"color_scheme": {
"name": "color_scheme",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'place-value'"
},
"bead_shape": {
"name": "bead_shape",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'diamond'"
},
"color_palette": {
"name": "color_palette",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'default'"
},
"hide_inactive_beads": {
"name": "hide_inactive_beads",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"colored_numerals": {
"name": "colored_numerals",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"scale_factor": {
"name": "scale_factor",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"show_numbers": {
"name": "show_numbers",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"animated": {
"name": "animated",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"interactive": {
"name": "interactive",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"gestures": {
"name": "gestures",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"sound_enabled": {
"name": "sound_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"sound_volume": {
"name": "sound_volume",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0.8
}
},
"indexes": {},
"foreignKeys": {
"abacus_settings_user_id_users_id_fk": {
"name": "abacus_settings_user_id_users_id_fk",
"tableFrom": "abacus_settings",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"arcade_rooms": {
"name": "arcade_rooms",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"code": {
"name": "code",
"type": "text(6)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"creator_name": {
"name": "creator_name",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_activity": {
"name": "last_activity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ttl_minutes": {
"name": "ttl_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 60
},
"is_locked": {
"name": "is_locked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"game_name": {
"name": "game_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"game_config": {
"name": "game_config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'lobby'"
},
"current_session_id": {
"name": "current_session_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_games_played": {
"name": "total_games_played",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"arcade_rooms_code_unique": {
"name": "arcade_rooms_code_unique",
"columns": ["code"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"room_members": {
"name": "room_members",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"room_id": {
"name": "room_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"display_name": {
"name": "display_name",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_creator": {
"name": "is_creator",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"joined_at": {
"name": "joined_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_seen": {
"name": "last_seen",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_online": {
"name": "is_online",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {},
"foreignKeys": {
"room_members_room_id_arcade_rooms_id_fk": {
"name": "room_members_room_id_arcade_rooms_id_fk",
"tableFrom": "room_members",
"tableTo": "arcade_rooms",
"columnsFrom": ["room_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"arcade_sessions": {
"name": "arcade_sessions",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"current_game": {
"name": "current_game",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"game_url": {
"name": "game_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"game_state": {
"name": "game_state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"active_players": {
"name": "active_players",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"room_id": {
"name": "room_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_activity_at": {
"name": "last_activity_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
}
},
"indexes": {},
"foreignKeys": {
"arcade_sessions_user_id_users_id_fk": {
"name": "arcade_sessions_user_id_users_id_fk",
"tableFrom": "arcade_sessions",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"arcade_sessions_room_id_arcade_rooms_id_fk": {
"name": "arcade_sessions_room_id_arcade_rooms_id_fk",
"tableFrom": "arcade_sessions",
"tableTo": "arcade_rooms",
"columnsFrom": ["room_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,34 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1759701472375,
"tag": "0000_third_carnage",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1759706755851,
"tag": "0001_friendly_stingray",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1759752980924,
"tag": "0002_loose_ultimatum",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1759781243105,
"tag": "0003_naive_reptil",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,338 @@
import { expect, test } from '@playwright/test'
/**
* Arcade Modal Session E2E Tests
*
* These tests verify that the arcade modal session system works correctly:
* - Users are locked into games once they start
* - Automatic redirects to active games
* - Player modification is blocked during games
* - "Return to Arcade" button properly ends sessions
*/
test.describe('Arcade Modal Session - Redirects', () => {
test.beforeEach(async ({ page }) => {
// Clear arcade session before each test
await page.goto('/arcade')
await page.waitForLoadState('networkidle')
// Click "Return to Arcade" button if it exists (to clear any existing session)
const returnButton = page.locator('button:has-text("Return to Arcade")')
if (await returnButton.isVisible({ timeout: 1000 }).catch(() => false)) {
await returnButton.click()
await page.waitForLoadState('networkidle')
}
})
test('should stay on arcade lobby when no active session', async ({ page }) => {
await page.goto('/arcade')
await page.waitForLoadState('networkidle')
// Should see "Champion Arena" title
const title = page.locator('h1:has-text("Champion Arena")')
await expect(title).toBeVisible()
// Should be able to select players
const playerSection = page.locator('text=/Player|Select|Add/i')
await expect(playerSection.first()).toBeVisible()
})
test('should redirect from arcade to active game when session exists', async ({ page }) => {
// Start a game to create a session
await page.goto('/arcade')
await page.waitForLoadState('networkidle')
// Find and click a player card to activate
const playerCard = page.locator('[data-testid="player-card"]').first()
if (await playerCard.isVisible({ timeout: 2000 }).catch(() => false)) {
await playerCard.click()
await page.waitForTimeout(500)
}
// Navigate to matching game to create session
await page.goto('/arcade/matching')
await page.waitForLoadState('networkidle')
// Start the game (click Start button if visible)
const startButton = page.locator('button:has-text("Start")')
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await startButton.click()
await page.waitForTimeout(1000)
}
// Try to navigate back to arcade lobby
await page.goto('/arcade')
await page.waitForTimeout(2000) // Give time for redirect
// Should be redirected back to the game
await expect(page).toHaveURL(/\/arcade\/matching/)
const gameTitle = page.locator('h1:has-text("Memory Pairs")')
await expect(gameTitle).toBeVisible()
})
test('should redirect to correct game when navigating to wrong game', async ({ page }) => {
// Create a session with matching game
await page.goto('/arcade')
await page.waitForLoadState('networkidle')
// Activate a player
const addPlayerButton = page.locator('button:has-text("Add Player"), button:has-text("+")')
if (
await addPlayerButton
.first()
.isVisible({ timeout: 2000 })
.catch(() => false)
) {
await addPlayerButton.first().click()
await page.waitForTimeout(500)
}
// Go to matching game
await page.goto('/arcade/matching')
await page.waitForLoadState('networkidle')
// Start game if needed
const startButton = page.locator('button:has-text("Start")')
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await startButton.click()
await page.waitForTimeout(1000)
}
// Try to navigate to a different game
await page.goto('/arcade/memory-quiz')
await page.waitForTimeout(2000) // Give time for redirect
// Should be redirected back to matching
await expect(page).toHaveURL(/\/arcade\/matching/)
})
test('should NOT redirect when on correct game page', async ({ page }) => {
// Navigate to matching game
await page.goto('/arcade/matching')
await page.waitForLoadState('networkidle')
// Should stay on matching page
await expect(page).toHaveURL(/\/arcade\/matching/)
const gameTitle = page.locator('h1:has-text("Memory Pairs")')
await expect(gameTitle).toBeVisible()
})
})
test.describe('Arcade Modal Session - Player Modification Blocking', () => {
test.beforeEach(async ({ page }) => {
// Clear session
await page.goto('/arcade')
await page.waitForLoadState('networkidle')
const returnButton = page.locator('button:has-text("Return to Arcade")')
if (await returnButton.isVisible({ timeout: 1000 }).catch(() => false)) {
await returnButton.click()
await page.waitForLoadState('networkidle')
}
})
test('should allow player modification in arcade lobby with no session', async ({ page }) => {
await page.goto('/arcade')
await page.waitForLoadState('networkidle')
// Look for add player button (should be enabled)
const addPlayerButton = page.locator('button:has-text("Add Player"), button:has-text("+")')
const firstButton = addPlayerButton.first()
if (await firstButton.isVisible({ timeout: 2000 }).catch(() => false)) {
// Should be clickable
await expect(firstButton).toBeEnabled()
// Try to click it
await firstButton.click()
await page.waitForTimeout(500)
// Should see player added
const activePlayer = page.locator('[data-testid="active-player"]')
await expect(activePlayer.first()).toBeVisible({ timeout: 3000 })
}
})
test('should block player modification during active game', async ({ page }) => {
// Start a game
await page.goto('/arcade/matching')
await page.waitForLoadState('networkidle')
// Start game
const startButton = page.locator('button:has-text("Start")')
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await startButton.click()
await page.waitForTimeout(1000)
}
// Look for player modification controls
// They should be disabled or have reduced opacity
const playerControls = page.locator('[data-testid="player-controls"], .player-list')
if (await playerControls.isVisible({ timeout: 1000 }).catch(() => false)) {
// Check if controls have pointer-events: none or low opacity
const opacity = await playerControls.evaluate((el) => {
return window.getComputedStyle(el).opacity
})
// If controls are visible, they should be dimmed (opacity < 1)
if (parseFloat(opacity) < 1) {
expect(parseFloat(opacity)).toBeLessThan(1)
}
}
// "Add Player" button should not be visible during game
const addPlayerButton = page.locator('button:has-text("Add Player")')
if (await addPlayerButton.isVisible({ timeout: 500 }).catch(() => false)) {
// If visible, should be disabled
const isDisabled = await addPlayerButton.isDisabled()
expect(isDisabled).toBe(true)
}
})
test('should show "Return to Arcade" button during game', async ({ page }) => {
// Start a game
await page.goto('/arcade/matching')
await page.waitForLoadState('networkidle')
// Look for "Return to Arcade" button
const returnButton = page.locator('button:has-text("Return to Arcade")')
// During game setup, might see "Setup" button instead
const setupButton = page.locator('button:has-text("Setup")')
// One of these should be visible
const hasReturnButton = await returnButton.isVisible({ timeout: 2000 }).catch(() => false)
const hasSetupButton = await setupButton.isVisible({ timeout: 2000 }).catch(() => false)
expect(hasReturnButton || hasSetupButton).toBe(true)
})
test('should NOT show "Setup" button in arcade lobby with no session', async ({ page }) => {
await page.goto('/arcade')
await page.waitForLoadState('networkidle')
// Should NOT see "Return to Arcade" or "Setup" button in lobby
const returnButton = page.locator('button:has-text("Return to Arcade")')
const setupButton = page.locator('button:has-text("Setup")')
const hasReturnButton = await returnButton.isVisible({ timeout: 1000 }).catch(() => false)
const hasSetupButton = await setupButton.isVisible({ timeout: 1000 }).catch(() => false)
// Neither should be visible in empty lobby
expect(hasReturnButton).toBe(false)
expect(hasSetupButton).toBe(false)
})
})
test.describe('Arcade Modal Session - Return to Arcade Button', () => {
test.beforeEach(async ({ page }) => {
// Clear session
await page.goto('/arcade')
await page.waitForLoadState('networkidle')
})
test('should end session and return to arcade when clicking "Return to Arcade"', async ({
page,
}) => {
// Start a game
await page.goto('/arcade/matching')
await page.waitForLoadState('networkidle')
// Start game if needed
const startButton = page.locator('button:has-text("Start")')
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await startButton.click()
await page.waitForTimeout(1000)
}
// Find and click "Return to Arcade" button
const returnButton = page.locator('button:has-text("Return to Arcade")')
if (await returnButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await returnButton.click()
await page.waitForTimeout(1000)
// Should be redirected to arcade lobby
await expect(page).toHaveURL(/\/arcade\/?$/)
// Should see arcade lobby title
const title = page.locator('h1:has-text("Champion Arena")')
await expect(title).toBeVisible()
// Now should be able to modify players again
const addPlayerButton = page.locator('button:has-text("Add Player"), button:has-text("+")')
if (
await addPlayerButton
.first()
.isVisible({ timeout: 2000 })
.catch(() => false)
) {
await expect(addPlayerButton.first()).toBeEnabled()
}
}
})
test('should allow navigating to different game after returning to arcade', async ({ page }) => {
// Start matching game
await page.goto('/arcade/matching')
await page.waitForLoadState('networkidle')
// Return to arcade
const returnButton = page.locator(
'button:has-text("Return to Arcade"), button:has-text("Setup")'
)
if (
await returnButton
.first()
.isVisible({ timeout: 2000 })
.catch(() => false)
) {
await returnButton.first().click()
await page.waitForTimeout(1000)
}
// Should be in arcade lobby
await expect(page).toHaveURL(/\/arcade\/?$/)
// Now navigate to different game - should NOT redirect back to matching
await page.goto('/arcade/memory-quiz')
await page.waitForTimeout(2000)
// Should stay on memory-quiz (not redirect back to matching)
await expect(page).toHaveURL(/\/arcade\/memory-quiz/)
// Should see memory quiz title
const title = page.locator('h1:has-text("Memory Lightning")')
await expect(title).toBeVisible({ timeout: 3000 })
})
})
test.describe('Arcade Modal Session - Session Persistence', () => {
test('should maintain active session across page reloads', async ({ page }) => {
// Start a game
await page.goto('/arcade/matching')
await page.waitForLoadState('networkidle')
// Start game
const startButton = page.locator('button:has-text("Start")')
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await startButton.click()
await page.waitForTimeout(1000)
}
// Reload the page
await page.reload()
await page.waitForLoadState('networkidle')
// Should still be on matching game
await expect(page).toHaveURL(/\/arcade\/matching/)
const gameTitle = page.locator('h1:has-text("Memory Pairs")')
await expect(gameTitle).toBeVisible()
// Try to navigate to arcade
await page.goto('/arcade')
await page.waitForTimeout(2000)
// Should be redirected back to matching
await expect(page).toHaveURL(/\/arcade\/matching/)
})
})

View File

@@ -1,7 +1,9 @@
import { test, expect } from '@playwright/test'
import { expect, test } from '@playwright/test'
test.describe('Mini Navigation Game Name Persistence', () => {
test('should not show game name when navigating back to games page from a specific game', async ({ page }) => {
test('should not show game name when navigating back to games page from a specific game', async ({
page,
}) => {
// Override baseURL for this test to match running dev server
const baseURL = 'http://localhost:3000'
@@ -73,7 +75,9 @@ test.describe('Mini Navigation Game Name Persistence', () => {
await expect(page.locator('text=🧠 Memory Lightning')).not.toBeVisible()
})
test('should not persist game name when navigating through intermediate pages', async ({ page }) => {
test('should not persist game name when navigating through intermediate pages', async ({
page,
}) => {
// Override baseURL for this test to match running dev server
const baseURL = 'http://localhost:3000'
@@ -108,4 +112,4 @@ test.describe('Mini Navigation Game Name Persistence', () => {
await expect(memoryLightningName).not.toBeVisible()
await expect(memoryPairsName).not.toBeVisible()
})
})
})

View File

@@ -1,7 +1,9 @@
import { test, expect } from '@playwright/test'
import { expect, test } from '@playwright/test'
test.describe('Game navigation slots', () => {
test('should show Memory Pairs game name in nav when navigating to matching game', async ({ page }) => {
test('should show Memory Pairs game name in nav when navigating to matching game', async ({
page,
}) => {
await page.goto('/games/matching')
// Wait for the page to load
@@ -13,7 +15,9 @@ test.describe('Game navigation slots', () => {
await expect(gameNav).toContainText('Memory Pairs')
})
test('should show Memory Lightning game name in nav when navigating to memory quiz', async ({ page }) => {
test('should show Memory Lightning game name in nav when navigating to memory quiz', async ({
page,
}) => {
await page.goto('/games/memory-quiz')
// Wait for the page to load
@@ -70,4 +74,4 @@ test.describe('Game navigation slots', () => {
const gameNavs = page.locator('h1:has-text("Memory Pairs"), h1:has-text("Memory Lightning")')
await expect(gameNavs).toHaveCount(0)
})
})
})

View File

@@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test'
import { expect, test } from '@playwright/test'
test.describe('Sound Settings Persistence', () => {
test.beforeEach(async ({ page }) => {
@@ -14,13 +14,13 @@ test.describe('Sound Settings Persistence', () => {
await page.getByRole('button', { name: /style/i }).click()
// Find and toggle the sound switch (should be off by default)
const soundSwitch = page.locator('[role="switch"]').filter({ hasText: /sound/i }).or(
page.locator('input[type="checkbox"]').filter({ hasText: /sound/i })
).or(
page.getByLabel(/sound/i)
).or(
page.locator('button').filter({ hasText: /sound/i })
).first()
const soundSwitch = page
.locator('[role="switch"]')
.filter({ hasText: /sound/i })
.or(page.locator('input[type="checkbox"]').filter({ hasText: /sound/i }))
.or(page.getByLabel(/sound/i))
.or(page.locator('button').filter({ hasText: /sound/i }))
.first()
await soundSwitch.click()
@@ -37,13 +37,13 @@ test.describe('Sound Settings Persistence', () => {
await page.reload()
await page.getByRole('button', { name: /style/i }).click()
const soundSwitchAfterReload = page.locator('[role="switch"]').filter({ hasText: /sound/i }).or(
page.locator('input[type="checkbox"]').filter({ hasText: /sound/i })
).or(
page.getByLabel(/sound/i)
).or(
page.locator('button').filter({ hasText: /sound/i })
).first()
const soundSwitchAfterReload = page
.locator('[role="switch"]')
.filter({ hasText: /sound/i })
.or(page.locator('input[type="checkbox"]').filter({ hasText: /sound/i }))
.or(page.getByLabel(/sound/i))
.or(page.locator('button').filter({ hasText: /sound/i }))
.first()
await expect(soundSwitchAfterReload).toBeChecked()
})
@@ -55,9 +55,10 @@ test.describe('Sound Settings Persistence', () => {
await page.getByRole('button', { name: /style/i }).click()
// Find volume slider
const volumeSlider = page.locator('input[type="range"]').or(
page.locator('[role="slider"]')
).first()
const volumeSlider = page
.locator('input[type="range"]')
.or(page.locator('[role="slider"]'))
.first()
// Set volume to a specific value (e.g., 0.6)
await volumeSlider.fill('60') // Assuming 0-100 range
@@ -75,9 +76,10 @@ test.describe('Sound Settings Persistence', () => {
await page.reload()
await page.getByRole('button', { name: /style/i }).click()
const volumeSliderAfterReload = page.locator('input[type="range"]').or(
page.locator('[role="slider"]')
).first()
const volumeSliderAfterReload = page
.locator('input[type="range"]')
.or(page.locator('[role="slider"]'))
.first()
const volumeValue = await volumeSliderAfterReload.inputValue()
expect(parseFloat(volumeValue)).toBeCloseTo(60, 0) // Allow for some variance
@@ -116,4 +118,4 @@ test.describe('Sound Settings Persistence', () => {
expect(storedConfig.soundEnabled).toBe(true)
expect(storedConfig.soundVolume).toBe(0.8)
})
})
})

42
apps/web/eslint.config.js Normal file
View File

@@ -0,0 +1,42 @@
// Minimal ESLint flat config ONLY for react-hooks rules
import tsParser from '@typescript-eslint/parser'
import reactHooks from 'eslint-plugin-react-hooks'
const config = [
{ ignores: ['dist', '.next', 'coverage', 'node_modules', 'styled-system', 'storybook-static'] },
{
files: ['**/*.tsx', '**/*.ts', '**/*.jsx', '**/*.js'],
languageOptions: {
parser: tsParser,
ecmaVersion: 2022,
sourceType: 'module',
globals: {
React: 'readonly',
JSX: 'readonly',
console: 'readonly',
process: 'readonly',
module: 'readonly',
require: 'readonly',
window: 'readonly',
document: 'readonly',
localStorage: 'readonly',
sessionStorage: 'readonly',
fetch: 'readonly',
global: 'readonly',
Buffer: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
},
},
plugins: {
'react-hooks': reactHooks,
},
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'off',
},
},
]
export default config

View File

@@ -64,4 +64,4 @@ const nextConfig = {
},
}
module.exports = nextConfig
module.exports = nextConfig

View File

@@ -3,27 +3,33 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "concurrently \"next dev\" \"npx @pandacss/dev --watch\"",
"build": "next build",
"start": "next start",
"lint": "next lint",
"dev": "concurrently \"node server.js\" \"npx @pandacss/dev --watch\"",
"build": "node scripts/generate-build-info.js && next build",
"start": "NODE_ENV=production node server.js",
"lint": "npx @biomejs/biome lint . && npx eslint .",
"lint:fix": "npx @biomejs/biome lint . --write && npx eslint . --fix",
"format": "npx @biomejs/biome format . --write",
"format:check": "npx @biomejs/biome format .",
"check": "npx @biomejs/biome check .",
"test": "vitest",
"test:run": "vitest run",
"type-check": "tsc --noEmit",
"clean": "rm -rf .next",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:drop": "drizzle-kit drop"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@myriaddreamin/typst-all-in-one.ts": "0.6.1-rc3",
"@myriaddreamin/typst-ts-renderer": "0.6.1-rc3",
"@myriaddreamin/typst-ts-web-compiler": "0.6.1-rc3",
"@myriaddreamin/typst.ts": "0.6.1-rc3",
"@number-flow/react": "^0.5.10",
"@pandacss/dev": "^0.20.0",
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
@@ -45,15 +51,23 @@
"@soroban/core": "workspace:*",
"@soroban/templates": "workspace:*",
"@tanstack/react-form": "^0.19.0",
"@tanstack/react-query": "^5.90.2",
"@types/jsdom": "^21.1.7",
"better-sqlite3": "^12.4.1",
"drizzle-orm": "^0.44.6",
"emojibase-data": "^16.0.3",
"jose": "^6.1.0",
"lucide-react": "^0.294.0",
"make-plural": "^7.4.0",
"nanoid": "^5.1.6",
"next": "^14.2.32",
"next-auth": "5.0.0-beta.29",
"python-bridge": "^1.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-resizable-layout": "^0.7.3"
"react-resizable-layout": "^0.7.3",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@playwright/test": "^1.55.1",
@@ -62,17 +76,20 @@
"@storybook/nextjs": "^9.1.7",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^5.0.2",
"concurrently": "^8.0.0",
"drizzle-kit": "^0.31.5",
"eslint": "^8.0.0",
"eslint-config-next": "^14.0.0",
"eslint-plugin-storybook": "^9.1.7",
"happy-dom": "^18.0.1",
"jsdom": "^27.0.0",
"storybook": "^9.1.7",
"tsx": "^4.20.5",
"typescript": "^5.0.0",
"vitest": "^1.0.0"
},

View File

@@ -36,17 +36,23 @@ export default defineConfig({
wood: { value: '#8B4513' },
bead: { value: '#2C1810' },
inactive: { value: '#D3D3D3' },
bar: { value: '#654321' }
}
bar: { value: '#654321' },
},
},
fonts: {
body: { value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' },
heading: { value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' },
mono: { value: 'Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace' }
body: {
value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
},
heading: {
value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
},
mono: {
value: 'Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace',
},
},
shadows: {
card: { value: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)' },
modal: { value: '0 25px 50px -12px rgba(0, 0, 0, 0.25)' }
modal: { value: '0 25px 50px -12px rgba(0, 0, 0, 0.25)' },
},
animations: {
// Shake animation for errors (web_generator.py line 3419)
@@ -60,49 +66,51 @@ export default defineConfig({
bounce: { value: 'bounce 1s infinite alternate' },
bounceIn: { value: 'bounceIn 1s ease-out' },
// Glow animation (line 6260)
glow: { value: 'glow 1s ease-in-out infinite alternate' }
}
glow: { value: 'glow 1s ease-in-out infinite alternate' },
},
},
keyframes: {
// Shake - horizontal oscillation for errors (line 3419)
shake: {
'0%, 100%': { transform: 'translateX(0)' },
'25%': { transform: 'translateX(-5px)' },
'75%': { transform: 'translateX(5px)' }
'75%': { transform: 'translateX(5px)' },
},
// Success pulse - gentle scale for correct answers (line 2004)
successPulse: {
'0%, 100%': { transform: 'scale(1)' },
'50%': { transform: 'scale(1.05)' }
'50%': { transform: 'scale(1.05)' },
},
// Pulse - continuous breathing effect (line 6255)
pulse: {
'0%, 100%': { transform: 'scale(1)' },
'50%': { transform: 'scale(1.05)' }
'50%': { transform: 'scale(1.05)' },
},
// Error shake - stronger horizontal oscillation (line 2009)
errorShake: {
'0%, 100%': { transform: 'translateX(0)' },
'25%': { transform: 'translateX(-10px)' },
'75%': { transform: 'translateX(10px)' }
'75%': { transform: 'translateX(10px)' },
},
// Bounce - vertical oscillation (line 6271)
bounce: {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-10px)' }
'50%': { transform: 'translateY(-10px)' },
},
// Bounce in - entry animation with scale and rotate (line 6265)
bounceIn: {
'0%': { transform: 'scale(0.3) rotate(-10deg)', opacity: '0' },
'50%': { transform: 'scale(1.1) rotate(5deg)' },
'100%': { transform: 'scale(1) rotate(0deg)', opacity: '1' }
'100%': { transform: 'scale(1) rotate(0deg)', opacity: '1' },
},
// Glow - expanding box shadow (line 6260)
glow: {
'0%': { boxShadow: '0 0 5px rgba(255, 255, 255, 0.5)' },
'100%': { boxShadow: '0 0 20px rgba(255, 255, 255, 0.8), 0 0 30px rgba(255, 255, 255, 0.6)' }
}
}
}
}
})
'100%': {
boxShadow: '0 0 20px rgba(255, 255, 255, 0.8), 0 0 30px rgba(255, 255, 255, 0.6)',
},
},
},
},
},
})

View File

@@ -24,4 +24,4 @@ export default defineConfig({
url: 'http://localhost:3002',
reuseExistingServer: !process.env.CI,
},
})
})

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env node
/**
* Generate build information for deployment tracking
* This script captures git commit, branch, timestamp, and other metadata
*/
const { execSync } = require('child_process')
const fs = require('fs')
const path = require('path')
function exec(command) {
try {
return execSync(command, { encoding: 'utf-8' }).trim()
} catch (_error) {
return null
}
}
function getBuildInfo() {
const gitCommit = exec('git rev-parse HEAD')
const gitCommitShort = exec('git rev-parse --short HEAD')
const gitBranch = exec('git rev-parse --abbrev-ref HEAD')
const gitTag = exec('git describe --tags --exact-match 2>/dev/null')
const gitDirty = exec('git diff --quiet || echo "dirty"') === 'dirty'
const packageJson = require('../package.json')
return {
version: packageJson.version,
buildTime: new Date().toISOString(),
buildTimestamp: Date.now(),
git: {
commit: gitCommit,
commitShort: gitCommitShort,
branch: gitBranch,
tag: gitTag,
isDirty: gitDirty,
},
environment: process.env.NODE_ENV || 'development',
buildNumber: process.env.BUILD_NUMBER || null,
nodeVersion: process.version,
}
}
const buildInfo = getBuildInfo()
const outputPath = path.join(__dirname, '..', 'src', 'generated', 'build-info.json')
// Ensure directory exists
const dir = path.dirname(outputPath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
fs.writeFileSync(outputPath, JSON.stringify(buildInfo, null, 2))
console.log('✅ Build info generated:', outputPath)
console.log(JSON.stringify(buildInfo, null, 2))

51
apps/web/server.js Normal file
View File

@@ -0,0 +1,51 @@
const { createServer } = require('http')
const { parse } = require('url')
const next = require('next')
const dev = process.env.NODE_ENV !== 'production'
const hostname = 'localhost'
const port = parseInt(process.env.PORT || '3000', 10)
const app = next({ dev, hostname, port })
const handle = app.getRequestHandler()
// Run migrations before starting server
console.log('🔄 Running database migrations...')
require('tsx/cjs')
const { migrate } = require('drizzle-orm/better-sqlite3/migrator')
const { db } = require('./src/db/index.ts')
try {
migrate(db, { migrationsFolder: './drizzle' })
console.log('✅ Migrations complete')
} catch (error) {
console.error('❌ Migration failed:', error)
process.exit(1)
}
app.prepare().then(() => {
const server = createServer(async (req, res) => {
try {
const parsedUrl = parse(req.url, true)
await handle(req, res, parsedUrl)
} catch (err) {
console.error('Error occurred handling', req.url, err)
res.statusCode = 500
res.end('internal server error')
}
})
// Initialize Socket.IO (load TypeScript with tsx)
require('tsx/cjs')
const { initializeSocketServer } = require('./socket-server.ts')
initializeSocketServer(server)
server
.once('error', (err) => {
console.error(err)
process.exit(1)
})
.listen(port, () => {
console.log(`> Ready on http://${hostname}:${port}`)
})
})

29
apps/web/socket-server.js Normal file
View File

@@ -0,0 +1,29 @@
const { Server } = require('socket.io')
function initializeSocketServer(httpServer) {
const io = new Server(httpServer, {
path: '/api/socket',
cors: {
origin: process.env.NEXT_PUBLIC_URL || 'http://localhost:3000',
credentials: true,
},
})
io.on('connection', (socket) => {
console.log('🔌 Client connected:', socket.id)
socket.on('join-arcade-session', ({ userId }) => {
socket.join(`arcade:${userId}`)
console.log(`👤 User ${userId} joined arcade room`)
})
socket.on('disconnect', () => {
console.log('🔌 Client disconnected:', socket.id)
})
})
console.log('✅ Socket.IO initialized on /api/socket')
return io
}
module.exports = { initializeSocketServer }

176
apps/web/socket-server.ts Normal file
View File

@@ -0,0 +1,176 @@
import type { Server as HTTPServer } from 'http'
import { Server as SocketIOServer } from 'socket.io'
import {
applyGameMove,
createArcadeSession,
deleteArcadeSession,
getArcadeSession,
updateSessionActivity,
} from './src/lib/arcade/session-manager'
import type { GameMove } from './src/lib/arcade/validation'
import { matchingGameValidator } from './src/lib/arcade/validation/MatchingGameValidator'
export function initializeSocketServer(httpServer: HTTPServer) {
const io = new SocketIOServer(httpServer, {
path: '/api/socket',
cors: {
origin: process.env.NEXT_PUBLIC_URL || 'http://localhost:3000',
credentials: true,
},
})
io.on('connection', (socket) => {
console.log('🔌 Client connected:', socket.id)
let currentUserId: string | null = null
// Join arcade session room
socket.on('join-arcade-session', async ({ userId }: { userId: string }) => {
currentUserId = userId
socket.join(`arcade:${userId}`)
console.log(`👤 User ${userId} joined arcade room`)
// Send current session state if exists
try {
const session = await getArcadeSession(userId)
if (session) {
socket.emit('session-state', {
gameState: session.gameState,
currentGame: session.currentGame,
gameUrl: session.gameUrl,
activePlayers: session.activePlayers,
version: session.version,
})
} else {
socket.emit('no-active-session')
}
} catch (error) {
console.error('Error fetching session:', error)
socket.emit('session-error', { error: 'Failed to fetch session' })
}
})
// Handle game moves
socket.on('game-move', async (data: { userId: string; move: GameMove }) => {
console.log('🎮 Game move received:', {
userId: data.userId,
moveType: data.move.type,
playerId: data.move.playerId,
timestamp: data.move.timestamp,
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') {
const existingSession = 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,
})
return
}
// Get initial state from validator
const initialState = matchingGameValidator.getInitialState({
difficulty: 6,
gameType: 'abacus-numeral',
turnTimer: 30,
})
await createArcadeSession({
userId: data.userId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState,
activePlayers,
})
console.log('✅ Session created successfully')
// Notify all connected clients about the new session
const newSession = await getArcadeSession(data.userId)
if (newSession) {
io.to(`arcade:${data.userId}`).emit('session-state', {
gameState: newSession.gameState,
currentGame: newSession.currentGame,
gameUrl: newSession.gameUrl,
activePlayers: newSession.activePlayers,
version: newSession.version,
})
console.log('📢 Emitted session-state to notify clients of new session')
}
}
}
const result = await applyGameMove(data.userId, data.move)
if (result.success && result.session) {
// Broadcast the updated state to all devices for this user
io.to(`arcade:${data.userId}`).emit('move-accepted', {
gameState: result.session.gameState,
version: result.session.version,
move: data.move,
})
// Update activity timestamp
await updateSessionActivity(data.userId)
} else {
// Send rejection only to the requesting socket
socket.emit('move-rejected', {
error: result.error,
move: data.move,
versionConflict: result.versionConflict,
})
}
} catch (error) {
console.error('Error processing move:', error)
socket.emit('move-rejected', {
error: 'Server error processing move',
move: data.move,
})
}
})
// 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')
} catch (error) {
console.error('Error ending session:', error)
socket.emit('session-error', { error: 'Failed to end session' })
}
})
// Keep-alive ping
socket.on('ping-session', async ({ userId }: { userId: string }) => {
try {
await updateSessionActivity(userId)
socket.emit('pong-session')
} catch (error) {
console.error('Error updating activity:', error)
}
})
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`)
}
})
})
console.log('✅ Socket.IO initialized on /api/socket')
return io
}

View File

@@ -34,11 +34,7 @@ describe('RootLayout with nav slot', () => {
const navContent = <div>Memory Lightning</div>
const pageContent = <div>Page content</div>
render(
<RootLayout nav={navContent}>
{pageContent}
</RootLayout>
)
render(<RootLayout nav={navContent}>{pageContent}</RootLayout>)
expect(screen.getByTestId('app-nav-bar')).toBeInTheDocument()
expect(screen.getByTestId('nav-slot-content')).toBeInTheDocument()
@@ -49,14 +45,10 @@ describe('RootLayout with nav slot', () => {
it('works without nav slot', () => {
const pageContent = <div>Page content</div>
render(
<RootLayout nav={null}>
{pageContent}
</RootLayout>
)
render(<RootLayout nav={null}>{pageContent}</RootLayout>)
expect(screen.getByTestId('app-nav-bar')).toBeInTheDocument()
expect(screen.queryByTestId('nav-slot-content')).not.toBeInTheDocument()
expect(screen.getByText('Page content')).toBeInTheDocument()
})
})
})

View File

@@ -1,8 +1,8 @@
'use client'
import { AbacusReact } from '@soroban/abacus-react'
import { css } from '../../../styled-system/css'
import { useState } from 'react'
import { css } from '../../../styled-system/css'
export default function AbacusTestPage() {
const [value, setValue] = useState(0)
@@ -15,32 +15,36 @@ export default function AbacusTestPage() {
}
return (
<div className={css({
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
bg: 'gray.50',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '4'
})}>
<div
className={css({
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
bg: 'gray.50',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '4',
})}
>
{/* Debug info */}
<div className={css({
position: 'absolute',
top: '4',
left: '4',
bg: 'white',
p: '3',
rounded: 'md',
border: '1px solid',
borderColor: 'gray.300',
fontSize: 'sm',
fontFamily: 'mono'
})}>
<div
className={css({
position: 'absolute',
top: '4',
left: '4',
bg: 'white',
p: '3',
rounded: 'md',
border: '1px solid',
borderColor: 'gray.300',
fontSize: 'sm',
fontFamily: 'mono',
})}
>
<div>Current Value: {value}</div>
<div>{debugInfo}</div>
<button
@@ -53,7 +57,7 @@ export default function AbacusTestPage() {
color: 'white',
rounded: 'sm',
fontSize: 'xs',
cursor: 'pointer'
cursor: 'pointer',
})}
>
Reset to 0
@@ -68,20 +72,22 @@ export default function AbacusTestPage() {
color: 'white',
rounded: 'sm',
fontSize: 'xs',
cursor: 'pointer'
cursor: 'pointer',
})}
>
Set to 12345
</button>
</div>
<div style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AbacusReact
value={value}
columns={5}
@@ -97,4 +103,4 @@ export default function AbacusTestPage() {
</div>
</div>
)
}
}

View File

@@ -0,0 +1,101 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db } from '@/db'
import * as schema from '@/db/schema'
import { getViewerId } from '@/lib/viewer'
/**
* GET /api/abacus-settings
* Fetch abacus display settings for the current user
*/
export async function GET() {
try {
const viewerId = await getViewerId()
const user = await getOrCreateUser(viewerId)
// Find or create abacus settings
let settings = await db.query.abacusSettings.findFirst({
where: eq(schema.abacusSettings.userId, user.id),
})
// If no settings exist, create with defaults
if (!settings) {
const [newSettings] = await db
.insert(schema.abacusSettings)
.values({ userId: user.id })
.returning()
settings = newSettings
}
return NextResponse.json({ settings })
} catch (error) {
console.error('Failed to fetch abacus settings:', error)
return NextResponse.json({ error: 'Failed to fetch abacus settings' }, { status: 500 })
}
}
/**
* PATCH /api/abacus-settings
* Update abacus display settings for the current user
*/
export async function PATCH(req: NextRequest) {
try {
const viewerId = await getViewerId()
const body = await req.json()
// Security: Strip userId from request body - it must come from session only
const { userId: _, ...updates } = body
const user = await getOrCreateUser(viewerId)
// Ensure settings exist
const existingSettings = await db.query.abacusSettings.findFirst({
where: eq(schema.abacusSettings.userId, user.id),
})
if (!existingSettings) {
// Create new settings with updates
const [newSettings] = await db
.insert(schema.abacusSettings)
.values({ userId: user.id, ...updates })
.returning()
return NextResponse.json({ settings: newSettings })
}
// Update existing settings
const [updatedSettings] = await db
.update(schema.abacusSettings)
.set(updates)
.where(eq(schema.abacusSettings.userId, user.id))
.returning()
return NextResponse.json({ settings: updatedSettings })
} catch (error) {
console.error('Failed to update abacus settings:', error)
return NextResponse.json({ error: 'Failed to update abacus settings' }, { status: 500 })
}
}
/**
* Get or create a user record for the given viewer ID (guest or user)
*/
async function getOrCreateUser(viewerId: string) {
// Try to find existing user by guest ID
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
// If no user exists, create one
if (!user) {
const [newUser] = await db
.insert(schema.users)
.values({
guestId: viewerId,
})
.returning()
user = newUser
}
return user
}

View File

@@ -0,0 +1,176 @@
import { eq } from 'drizzle-orm'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { db, schema } from '@/db'
import { deleteArcadeSession } from '@/lib/arcade/session-manager'
import { DELETE, GET, POST } from '../route'
describe('Arcade Session API Routes', () => {
const testUserId = 'test-user-for-api-routes'
const testGuestId = 'test-guest-id-api-routes'
const baseUrl = 'http://localhost:3000'
beforeEach(async () => {
// Create test user
await db
.insert(schema.users)
.values({
id: testUserId,
guestId: testGuestId,
createdAt: new Date(),
})
.onConflictDoNothing()
})
afterEach(async () => {
// Clean up
await deleteArcadeSession(testUserId)
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
})
describe('POST /api/arcade-session', () => {
it('should create a new session', async () => {
const request = new NextRequest(`${baseUrl}/api/arcade-session`, {
method: 'POST',
body: JSON.stringify({
userId: testUserId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { test: 'state' },
activePlayers: [1],
}),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.session).toBeDefined()
expect(data.session.currentGame).toBe('matching')
expect(data.session.version).toBe(1)
})
it('should return 400 for missing fields', async () => {
const request = new NextRequest(`${baseUrl}/api/arcade-session`, {
method: 'POST',
body: JSON.stringify({
userId: testUserId,
// Missing required fields
}),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Missing required fields')
})
it('should return 500 for non-existent user (foreign key constraint)', async () => {
const request = new NextRequest(`${baseUrl}/api/arcade-session`, {
method: 'POST',
body: JSON.stringify({
userId: 'non-existent-user',
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: {},
activePlayers: [1],
}),
})
const response = await POST(request)
expect(response.status).toBe(500)
})
})
describe('GET /api/arcade-session', () => {
it('should retrieve an existing session', async () => {
// Create session first
const createRequest = new NextRequest(`${baseUrl}/api/arcade-session`, {
method: 'POST',
body: JSON.stringify({
userId: testUserId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: { test: 'state' },
activePlayers: [1],
}),
})
await POST(createRequest)
// Now retrieve it
const request = new NextRequest(`${baseUrl}/api/arcade-session?userId=${testUserId}`)
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.session).toBeDefined()
expect(data.session.currentGame).toBe('matching')
})
it('should return 404 for non-existent session', async () => {
const request = new NextRequest(`${baseUrl}/api/arcade-session?userId=non-existent`)
const response = await GET(request)
expect(response.status).toBe(404)
})
it('should return 400 for missing userId', async () => {
const request = new NextRequest(`${baseUrl}/api/arcade-session`)
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('userId required')
})
})
describe('DELETE /api/arcade-session', () => {
it('should delete an existing session', async () => {
// Create session first
const createRequest = new NextRequest(`${baseUrl}/api/arcade-session`, {
method: 'POST',
body: JSON.stringify({
userId: testUserId,
gameName: 'matching',
gameUrl: '/arcade/matching',
initialState: {},
activePlayers: [1],
}),
})
await POST(createRequest)
// Now delete it
const request = new NextRequest(`${baseUrl}/api/arcade-session?userId=${testUserId}`, {
method: 'DELETE',
})
const response = await DELETE(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
// Verify it's deleted
const getRequest = new NextRequest(`${baseUrl}/api/arcade-session?userId=${testUserId}`)
const getResponse = await GET(getRequest)
expect(getResponse.status).toBe(404)
})
it('should return 400 for missing userId', async () => {
const request = new NextRequest(`${baseUrl}/api/arcade-session`, {
method: 'DELETE',
})
const response = await DELETE(request)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('userId required')
})
})
})

View File

@@ -0,0 +1,99 @@
import { type NextRequest, NextResponse } from 'next/server'
import {
createArcadeSession,
deleteArcadeSession,
getArcadeSession,
} from '@/lib/arcade/session-manager'
import type { GameName } from '@/lib/arcade/validation'
/**
* GET /api/arcade-session?userId=xxx
* Get the active arcade session for a user
*/
export async function GET(request: NextRequest) {
try {
const userId = request.nextUrl.searchParams.get('userId')
if (!userId) {
return NextResponse.json({ error: 'userId required' }, { status: 400 })
}
const session = await getArcadeSession(userId)
if (!session) {
return NextResponse.json({ error: 'No active session' }, { status: 404 })
}
return NextResponse.json({
session: {
currentGame: session.currentGame,
gameUrl: session.gameUrl,
gameState: session.gameState,
activePlayers: session.activePlayers,
version: session.version,
expiresAt: session.expiresAt,
},
})
} catch (error) {
console.error('Error fetching arcade session:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* POST /api/arcade-session
* Create a new arcade session
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { userId, gameName, gameUrl, initialState, activePlayers } = body
if (!userId || !gameName || !gameUrl || !initialState || !activePlayers) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}
const session = await createArcadeSession({
userId,
gameName: gameName as GameName,
gameUrl,
initialState,
activePlayers,
})
return NextResponse.json({
session: {
currentGame: session.currentGame,
gameUrl: session.gameUrl,
gameState: session.gameState,
activePlayers: session.activePlayers,
version: session.version,
expiresAt: session.expiresAt,
},
})
} catch (error) {
console.error('Error creating arcade session:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
/**
* DELETE /api/arcade-session?userId=xxx
* Delete an arcade session
*/
export async function DELETE(request: NextRequest) {
try {
const userId = request.nextUrl.searchParams.get('userId')
if (!userId) {
return NextResponse.json({ error: 'userId required' }, { status: 400 })
}
await deleteArcadeSession(userId)
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting arcade session:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,18 @@
/**
* API response types for /api/arcade-session
*/
export interface ArcadeSessionResponse {
session: {
currentGame: string
gameUrl: string
gameState: unknown
activePlayers: number[]
version: number
expiresAt: Date | string
}
}
export interface ArcadeSessionErrorResponse {
error: string
}

View File

@@ -0,0 +1,17 @@
/**
* NextAuth v5 API route handlers
*
* Handles all NextAuth routes:
* - GET /api/auth/signin
* - POST /api/auth/signin/:provider
* - GET /api/auth/signout
* - POST /api/auth/signout
* - GET /api/auth/session
* - GET /api/auth/csrf
* - POST /api/auth/callback/:provider
* - etc.
*/
import { handlers } from '@/auth'
export const { GET, POST } = handlers

View File

@@ -0,0 +1,6 @@
import { NextResponse } from 'next/server'
import buildInfo from '@/generated/build-info.json'
export async function GET() {
return NextResponse.json(buildInfo)
}

View File

@@ -1,10 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { type NextRequest, NextResponse } from 'next/server'
import { assetStore } from '@/lib/asset-store'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
export async function GET(_request: NextRequest, { params }: { params: { id: string } }) {
try {
const { id } = params
@@ -15,9 +12,12 @@ export async function GET(
const asset = await assetStore.get(id)
if (!asset) {
console.log('❌ Asset not found in store')
return NextResponse.json({
error: 'Asset not found or expired'
}, { status: 404 })
return NextResponse.json(
{
error: 'Asset not found or expired',
},
{ status: 404 }
)
}
console.log('✅ Asset found, serving download')
@@ -30,15 +30,17 @@ export async function GET(
'Content-Disposition': `attachment; filename="${asset.filename}"`,
'Content-Length': asset.data.length.toString(),
'Cache-Control': 'private, no-cache, no-store, must-revalidate',
'Expires': '0',
'Pragma': 'no-cache'
}
Expires: '0',
Pragma: 'no-cache',
},
})
} catch (error) {
console.error('❌ Download failed:', error)
return NextResponse.json({
error: 'Failed to download file'
}, { status: 500 })
return NextResponse.json(
{
error: 'Failed to download file',
},
{ status: 500 }
)
}
}
}

View File

@@ -1,19 +1,13 @@
import { NextRequest, NextResponse } from 'next/server'
import { type NextRequest, NextResponse } from 'next/server'
import { assetStore } from '@/lib/asset-store'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
export async function GET(_request: NextRequest, { params }: { params: { id: string } }) {
try {
const { id } = params
const asset = await assetStore.get(id)
if (!asset) {
return NextResponse.json(
{ error: 'Asset not found' },
{ status: 404 }
)
return NextResponse.json({ error: 'Asset not found' }, { status: 404 })
}
// Set appropriate headers for download
@@ -25,14 +19,10 @@ export async function GET(
return new NextResponse(asset.data, {
status: 200,
headers
headers,
})
} catch (error) {
console.error('Asset download error:', error)
return NextResponse.json(
{ error: 'Failed to download asset' },
{ status: 500 }
)
return NextResponse.json({ error: 'Failed to download asset' }, { status: 500 })
}
}
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { SorobanGenerator } from '@soroban/core'
import { type NextRequest, NextResponse } from 'next/server'
import path from 'path'
// Global generator instance for better performance
@@ -36,14 +36,17 @@ export async function POST(request: NextRequest) {
// Check dependencies before generating
const deps = await gen.checkDependencies?.()
if (deps && (!deps.python || !deps.typst)) {
return NextResponse.json({
error: 'Missing system dependencies',
details: {
python: deps.python ? '✅ Available' : '❌ Missing Python 3',
typst: deps.typst ? '✅ Available' : '❌ Missing Typst',
qpdf: deps.qpdf ? '✅ Available' : '⚠️ Missing qpdf (optional)'
}
}, { status: 500 })
return NextResponse.json(
{
error: 'Missing system dependencies',
details: {
python: deps.python ? '✅ Available' : '❌ Missing Python 3',
typst: deps.typst ? '✅ Available' : ' Missing Typst',
qpdf: deps.qpdf ? '✅ Available' : '⚠️ Missing qpdf (optional)',
},
},
{ status: 500 }
)
}
// Generate flashcards using Python via TypeScript bindings
@@ -52,7 +55,7 @@ export async function POST(request: NextRequest) {
// SorobanGenerator.generate() returns PDF data directly as Buffer
if (!Buffer.isBuffer(result)) {
throw new Error('Expected PDF Buffer from generator, got: ' + typeof result)
throw new Error(`Expected PDF Buffer from generator, got: ${typeof result}`)
}
const pdfBuffer = result
// Create filename for download
@@ -63,25 +66,27 @@ export async function POST(request: NextRequest) {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': pdfBuffer.length.toString()
}
'Content-Length': pdfBuffer.length.toString(),
},
})
} catch (error) {
console.error('❌ Generation failed:', error)
return NextResponse.json({
error: 'Failed to generate flashcards',
details: error instanceof Error ? error.message : 'Unknown error',
success: false
}, { status: 500 })
return NextResponse.json(
{
error: 'Failed to generate flashcards',
details: error instanceof Error ? error.message : 'Unknown error',
success: false,
},
{ status: 500 }
)
}
}
// Helper functions to calculate metadata
function calculateCardCount(range: string, step: number): number {
function _calculateCardCount(range: string, step: number): number {
if (range.includes('-')) {
const [start, end] = range.split('-').map(n => parseInt(n) || 0)
const [start, end] = range.split('-').map((n) => parseInt(n, 10) || 0)
return Math.floor((end - start + 1) / step)
}
@@ -92,9 +97,9 @@ function calculateCardCount(range: string, step: number): number {
return 1
}
function generateNumbersFromRange(range: string, step: number): number[] {
function _generateNumbersFromRange(range: string, step: number): number[] {
if (range.includes('-')) {
const [start, end] = range.split('-').map(n => parseInt(n) || 0)
const [start, end] = range.split('-').map((n) => parseInt(n, 10) || 0)
const numbers: number[] = []
for (let i = start; i <= end; i += step) {
numbers.push(i)
@@ -104,26 +109,29 @@ function generateNumbersFromRange(range: string, step: number): number[] {
}
if (range.includes(',')) {
return range.split(',').map(n => parseInt(n.trim()) || 0)
return range.split(',').map((n) => parseInt(n.trim(), 10) || 0)
}
return [parseInt(range) || 0]
return [parseInt(range, 10) || 0]
}
// Health check endpoint
export async function GET() {
try {
const gen = await getGenerator()
const deps = await gen.checkDependencies?.() || { python: true, typst: true, qpdf: true }
const deps = (await gen.checkDependencies?.()) || { python: true, typst: true, qpdf: true }
return NextResponse.json({
status: 'healthy',
dependencies: deps
dependencies: deps,
})
} catch (error) {
return NextResponse.json({
status: 'unhealthy',
error: error instanceof Error ? error.message : 'Unknown error'
}, { status: 500 })
return NextResponse.json(
{
status: 'unhealthy',
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
}

View File

@@ -0,0 +1,100 @@
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getViewerId } from '@/lib/viewer'
/**
* PATCH /api/players/[id]
* Update a player (only if it belongs to the current viewer)
*/
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
try {
const viewerId = await getViewerId()
const body = await req.json()
// Get user record (must exist if player exists)
const user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
// Check if user has an active arcade session
// If so, prevent changing isActive status (players are locked during games)
if (body.isActive !== undefined) {
const activeSession = await db.query.arcadeSessions.findFirst({
where: eq(schema.arcadeSessions.userId, viewerId),
})
if (activeSession) {
return NextResponse.json(
{
error: 'Cannot modify active players during an active game session',
activeGame: activeSession.currentGame,
gameUrl: activeSession.gameUrl,
},
{ status: 403 }
)
}
}
// Security: Only allow updating specific fields (excludes userId)
// Update player (only if it belongs to this user)
const [updatedPlayer] = await db
.update(schema.players)
.set({
...(body.name !== undefined && { name: body.name }),
...(body.emoji !== undefined && { emoji: body.emoji }),
...(body.color !== undefined && { color: body.color }),
...(body.isActive !== undefined && { isActive: body.isActive }),
// userId is explicitly NOT included - it comes from session
})
.where(and(eq(schema.players.id, params.id), eq(schema.players.userId, user.id)))
.returning()
if (!updatedPlayer) {
return NextResponse.json({ error: 'Player not found or unauthorized' }, { status: 404 })
}
return NextResponse.json({ player: updatedPlayer })
} catch (error) {
console.error('Failed to update player:', error)
return NextResponse.json({ error: 'Failed to update player' }, { status: 500 })
}
}
/**
* DELETE /api/players/[id]
* Delete a player (only if it belongs to the current viewer)
*/
export async function DELETE(_req: NextRequest, { params }: { params: { id: string } }) {
try {
const viewerId = await getViewerId()
// Get user record (must exist if player exists)
const user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
// Delete player (only if it belongs to this user)
const [deletedPlayer] = await db
.delete(schema.players)
.where(and(eq(schema.players.id, params.id), eq(schema.players.userId, user.id)))
.returning()
if (!deletedPlayer) {
return NextResponse.json({ error: 'Player not found or unauthorized' }, { status: 404 })
}
return NextResponse.json({ success: true, player: deletedPlayer })
} catch (error) {
console.error('Failed to delete player:', error)
return NextResponse.json({ error: 'Failed to delete player' }, { status: 500 })
}
}

View File

@@ -0,0 +1,255 @@
/**
* @vitest-environment node
*/
import { eq } from 'drizzle-orm'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { db, schema } from '../../../../db'
import { PATCH } from '../[id]/route'
/**
* Arcade Session Validation E2E Tests
*
* These tests verify that the PATCH /api/players/[id] endpoint
* correctly prevents isActive changes when user has an active arcade session.
*/
describe('PATCH /api/players/[id] - Arcade Session Validation', () => {
let testUserId: string
let testGuestId: string
let testPlayerId: string
beforeEach(async () => {
// Create a test user with unique guest ID
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning()
testUserId = user.id
// Create a test player
const [player] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Test Player',
emoji: '😀',
color: '#3b82f6',
isActive: false,
})
.returning()
testPlayerId = player.id
})
afterEach(async () => {
// Clean up: delete test arcade session (if exists)
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testGuestId))
// Clean up: delete test user (cascade deletes players)
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
})
it('should return 403 when trying to change isActive with active arcade session', async () => {
// Create an active arcade session
const now = new Date()
await db.insert(schema.arcadeSessions).values({
userId: testGuestId,
currentGame: 'matching',
gameUrl: '/arcade/matching',
gameState: JSON.stringify({}),
activePlayers: JSON.stringify([testPlayerId]),
startedAt: now,
lastActivityAt: now,
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
version: 1,
})
// Mock request to change isActive
const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Cookie: `guest_id=${testGuestId}`,
},
body: JSON.stringify({ isActive: true }),
})
// Mock getViewerId by setting cookie
const response = await PATCH(mockRequest, { params: { id: testPlayerId } })
const data = await response.json()
// Should be rejected with 403
expect(response.status).toBe(403)
expect(data.error).toContain('Cannot modify active players during an active game session')
expect(data.activeGame).toBe('matching')
expect(data.gameUrl).toBe('/arcade/matching')
// Verify player isActive was NOT changed
const player = await db.query.players.findFirst({
where: eq(schema.players.id, testPlayerId),
})
expect(player?.isActive).toBe(false) // Still false
})
it('should allow isActive change when no active arcade session', async () => {
// No arcade session created
// Mock request to change isActive
const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Cookie: `guest_id=${testGuestId}`,
},
body: JSON.stringify({ isActive: true }),
})
const response = await PATCH(mockRequest, { params: { id: testPlayerId } })
const data = await response.json()
// Should succeed
expect(response.status).toBe(200)
expect(data.player.isActive).toBe(true)
// Verify player isActive was changed
const player = await db.query.players.findFirst({
where: eq(schema.players.id, testPlayerId),
})
expect(player?.isActive).toBe(true)
})
it('should allow non-isActive changes even with active arcade session', async () => {
// Create an active arcade session
const now = new Date()
await db.insert(schema.arcadeSessions).values({
userId: testGuestId,
currentGame: 'matching',
gameUrl: '/arcade/matching',
gameState: JSON.stringify({}),
activePlayers: JSON.stringify([testPlayerId]),
startedAt: now,
lastActivityAt: now,
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
version: 1,
})
// Mock request to change name/emoji/color (NOT isActive)
const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Cookie: `guest_id=${testGuestId}`,
},
body: JSON.stringify({
name: 'Updated Name',
emoji: '🎉',
color: '#ff0000',
}),
})
const response = await PATCH(mockRequest, { params: { id: testPlayerId } })
const data = await response.json()
// Should succeed
expect(response.status).toBe(200)
expect(data.player.name).toBe('Updated Name')
expect(data.player.emoji).toBe('🎉')
expect(data.player.color).toBe('#ff0000')
// Verify changes were applied
const player = await db.query.players.findFirst({
where: eq(schema.players.id, testPlayerId),
})
expect(player?.name).toBe('Updated Name')
expect(player?.emoji).toBe('🎉')
expect(player?.color).toBe('#ff0000')
})
it('should allow isActive change after arcade session ends', async () => {
// Create an active arcade session
const now = new Date()
await db.insert(schema.arcadeSessions).values({
userId: testGuestId,
currentGame: 'matching',
gameUrl: '/arcade/matching',
gameState: JSON.stringify({}),
activePlayers: JSON.stringify([testPlayerId]),
startedAt: now,
lastActivityAt: now,
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
version: 1,
})
// End the session
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testGuestId))
// Mock request to change isActive
const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Cookie: `guest_id=${testGuestId}`,
},
body: JSON.stringify({ isActive: true }),
})
const response = await PATCH(mockRequest, { params: { id: testPlayerId } })
const data = await response.json()
// Should succeed
expect(response.status).toBe(200)
expect(data.player.isActive).toBe(true)
})
it('should handle multiple players with different isActive states', async () => {
// Create additional players
const [player2] = await db
.insert(schema.players)
.values({
userId: testUserId,
name: 'Player 2',
emoji: '😎',
color: '#8b5cf6',
isActive: true,
})
.returning()
// Create arcade session
const now2 = new Date()
await db.insert(schema.arcadeSessions).values({
userId: testGuestId,
currentGame: 'matching',
gameUrl: '/arcade/matching',
gameState: JSON.stringify({}),
activePlayers: JSON.stringify([testPlayerId, player2.id]),
startedAt: now2,
lastActivityAt: now2,
expiresAt: new Date(now2.getTime() + 3600000), // 1 hour from now
version: 1,
})
// Try to toggle player1 (inactive -> active) - should fail
const request1 = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Cookie: `guest_id=${testGuestId}`,
},
body: JSON.stringify({ isActive: true }),
})
const response1 = await PATCH(request1, { params: { id: testPlayerId } })
expect(response1.status).toBe(403)
// Try to toggle player2 (active -> inactive) - should also fail
const request2 = new NextRequest(`http://localhost:3000/api/players/${player2.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Cookie: `guest_id=${testGuestId}`,
},
body: JSON.stringify({ isActive: false }),
})
const response2 = await PATCH(request2, { params: { id: player2.id } })
expect(response2.status).toBe(403)
})
})

View File

@@ -0,0 +1,91 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getViewerId } from '@/lib/viewer'
/**
* GET /api/players
* List all players for the current viewer (guest or user)
*/
export async function GET() {
try {
const viewerId = await getViewerId()
// Get or create user record
const user = await getOrCreateUser(viewerId)
// Get all players for this user
const players = await db.query.players.findMany({
where: eq(schema.players.userId, user.id),
orderBy: (players, { desc }) => [desc(players.createdAt)],
})
return NextResponse.json({ players })
} catch (error) {
console.error('Failed to fetch players:', error)
return NextResponse.json({ error: 'Failed to fetch players' }, { status: 500 })
}
}
/**
* POST /api/players
* Create a new player for the current viewer
*/
export async function POST(req: NextRequest) {
try {
const viewerId = await getViewerId()
const body = await req.json()
// Validate required fields
if (!body.name || !body.emoji || !body.color) {
return NextResponse.json(
{ error: 'Missing required fields: name, emoji, color' },
{ status: 400 }
)
}
// Get or create user record
const user = await getOrCreateUser(viewerId)
// Create player
const [player] = await db
.insert(schema.players)
.values({
userId: user.id,
name: body.name,
emoji: body.emoji,
color: body.color,
isActive: body.isActive ?? false,
})
.returning()
return NextResponse.json({ player }, { status: 201 })
} catch (error) {
console.error('Failed to create player:', error)
return NextResponse.json({ error: 'Failed to create player' }, { status: 500 })
}
}
/**
* Get or create a user record for the given viewer ID (guest or user)
*/
async function getOrCreateUser(viewerId: string) {
// Try to find existing user by guest ID
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
// If no user exists, create one
if (!user) {
const [newUser] = await db
.insert(schema.users)
.values({
guestId: viewerId,
})
.returning()
user = newUser
}
return user
}

View File

@@ -1,164 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { generateSorobanSVG } from '@/lib/typst-soroban'
export async function POST(request: NextRequest) {
try {
const config = await request.json()
// Debug: log the received config
console.log('🔍 Preview config:', JSON.stringify(config, null, 2))
// Ensure range is set with a default
if (!config.range) {
config.range = '0-9'
}
// For preview, limit to a few numbers and use SVG format for fast rendering
const previewConfig = {
...config,
range: getPreviewRange(config.range),
format: 'svg', // Use SVG format for preview
cardsPerPage: 6 // Standard card layout
}
console.log('🔍 Processed preview config:', JSON.stringify(previewConfig, null, 2))
// Generate real SVG preview using typst.ts
console.log('🚀 Generating soroban SVG preview via typst.ts')
try {
// Parse the numbers from the range for individual cards
const numbers = parseNumbersFromRange(getPreviewRange(config.range))
console.log('🔍 Generating individual SVGs for numbers:', numbers)
// Generate individual SVGs for each number using typst.ts
const samples = []
for (const number of numbers) {
try {
const typstConfig = {
number: number,
beadShape: previewConfig.beadShape || 'diamond',
colorScheme: previewConfig.colorScheme || 'place-value',
hideInactiveBeads: previewConfig.hideInactiveBeads || false,
scaleFactor: previewConfig.scaleFactor || 1.0,
width: '200pt',
height: '250pt'
}
console.log(`🔍 Generating typst.ts SVG for number ${number}`)
const svg = await generateSorobanSVG(typstConfig)
console.log(`✅ Generated typst.ts SVG for ${number}, length: ${svg.length}`)
samples.push({
number,
front: svg,
back: number.toString()
})
} catch (error) {
console.error(`❌ Failed to generate SVG for number ${number}:`, error instanceof Error ? error.message : error)
samples.push({
number,
front: `<svg width="200" height="300" viewBox="0 0 200 300" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="10" width="180" height="280" fill="none" stroke="#ccc" stroke-width="2"/>
<line x1="20" y1="150" x2="180" y2="150" stroke="#ccc" stroke-width="2"/>
<text x="100" y="160" text-anchor="middle" font-size="24" fill="#666">SVG Error</text>
<text x="100" y="180" text-anchor="middle" font-size="16" fill="#999">${number}</text>
</svg>`,
back: number.toString()
})
}
}
return NextResponse.json({
count: numbers.length,
samples,
note: 'Real individual SVGs generated by typst.ts'
})
} catch (error) {
console.error('⚠️ Typst.ts SVG generation failed, using fallback preview:', error instanceof Error ? error.message : error)
return NextResponse.json(getMockPreviewData(config))
}
} catch (error) {
console.error('❌ Preview generation failed:', error)
// Always fall back to mock data for preview
const config = await request.json().catch(() => ({ range: '0-9' }))
return NextResponse.json(getMockPreviewData(config))
}
}
// Helper function to parse numbers from range string
function parseNumbersFromRange(range: string): number[] {
if (!range) return [0, 1, 2]
if (range.includes('-')) {
const [start] = range.split('-')
const startNum = parseInt(start) || 0
return [startNum, startNum + 1, startNum + 2]
}
if (range.includes(',')) {
return range.split(',').slice(0, 3).map(n => parseInt(n.trim()) || 0)
}
const num = parseInt(range) || 0
return [num, num + 1, num + 2]
}
// Helper function to limit range for preview
function getPreviewRange(range: string): string {
if (!range) return '0,1,2'
if (range.includes('-')) {
const [start] = range.split('-')
const startNum = parseInt(start) || 0
return `${startNum},${startNum + 1},${startNum + 2}`
}
if (range.includes(',')) {
const numbers = range.split(',').slice(0, 3)
return numbers.join(',')
}
return range
}
// Mock preview data for development and fallback
function getMockPreviewData(config: any) {
const range = config.range || '0-9'
let numbers: number[]
if (range.includes('-')) {
const [start] = range.split('-')
const startNum = parseInt(start) || 0
numbers = [startNum, startNum + 1, startNum + 2]
} else if (range.includes(',')) {
numbers = range.split(',').slice(0, 3).map((n: string) => parseInt(n.trim()) || 0)
} else {
const num = parseInt(range) || 0
numbers = [num, num + 1, num + 2]
}
return {
count: numbers.length,
samples: numbers.map(number => ({
number,
front: `<svg width="200" height="300" viewBox="0 0 200 300" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="10" width="180" height="280" fill="none" stroke="#ccc" stroke-width="2"/>
<line x1="20" y1="150" x2="180" y2="150" stroke="#ccc" stroke-width="2"/>
<text x="100" y="160" text-anchor="middle" font-size="24" fill="#666">Preview Error</text>
<text x="100" y="180" text-anchor="middle" font-size="16" fill="#999">${number}</text>
</svg>`,
back: number.toString()
}))
}
}
// Health check endpoint
export async function GET() {
return NextResponse.json({
status: 'healthy',
endpoint: 'preview',
message: 'Preview API is running'
})
}

View File

@@ -1,154 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { $typst } from '@myriaddreamin/typst.ts/dist/esm/contrib/snippet.mjs'
import fs from 'fs'
import path from 'path'
export interface TypstSVGRequest {
number: number
beadShape?: 'diamond' | 'circle' | 'square'
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating'
colorPalette?: 'default' | 'colorblind' | 'mnemonic' | 'grayscale' | 'nature'
hideInactiveBeads?: boolean
showEmptyColumns?: boolean
columns?: number | 'auto'
scaleFactor?: number
width?: string
height?: string
fontSize?: string
fontFamily?: string
transparent?: boolean
coloredNumerals?: boolean
}
// Cache for template content
let flashcardsTemplate: string | null = null
async function getFlashcardsTemplate(): Promise<string> {
if (flashcardsTemplate) {
return flashcardsTemplate
}
try {
const { getTemplatePath } = require('@soroban/templates')
const templatePath = getTemplatePath('flashcards.typ')
flashcardsTemplate = fs.readFileSync(templatePath, 'utf-8')
return flashcardsTemplate
} catch (error) {
console.error('Failed to load flashcards template:', error)
throw new Error('Template loading failed')
}
}
function processBeadAnnotations(svg: string): string {
const { extractBeadAnnotations } = require('@soroban/templates')
const result = extractBeadAnnotations(svg)
if (result.warnings.length > 0) {
console.log(' SVG bead processing warnings:', result.warnings)
}
console.log(`🔗 Processed ${result.count} bead links into data attributes`)
return result.processedSVG
}
function createTypstContent(config: TypstSVGRequest, template: string): string {
const {
number,
beadShape = 'diamond',
colorScheme = 'place-value',
colorPalette = 'default',
hideInactiveBeads = false,
showEmptyColumns = false,
columns = 'auto',
scaleFactor = 1.0,
width = '120pt',
height = '160pt',
fontSize = '48pt',
fontFamily = 'DejaVu Sans',
transparent = false,
coloredNumerals = false
} = config
return `
${template}
#set page(
width: ${width},
height: ${height},
margin: 0pt,
fill: ${transparent ? 'none' : 'white'}
)
#set text(font: "${fontFamily}", size: ${fontSize}, fallback: true)
#align(center + horizon)[
#box(
width: ${width} - 2 * (${width} * 0.05),
height: ${height} - 2 * (${height} * 0.05)
)[
#align(center + horizon)[
#scale(x: ${scaleFactor * 100}%, y: ${scaleFactor * 100}%)[
#draw-soroban(
${number},
columns: ${columns},
show-empty: ${showEmptyColumns},
hide-inactive: ${hideInactiveBeads},
bead-shape: "${beadShape}",
color-scheme: "${colorScheme}",
color-palette: "${colorPalette}",
base-size: 1.0
)
]
]
]
]
`
}
export async function POST(request: NextRequest) {
try {
const config: TypstSVGRequest = await request.json()
console.log('🎨 Generating typst.ts SVG for number:', config.number)
// Load template
const template = await getFlashcardsTemplate()
// Create typst content
const typstContent = createTypstContent(config, template)
// Generate SVG using typst.ts
const rawSvg = await $typst.svg({ mainContent: typstContent })
// Post-process to convert bead annotations to data attributes
const svg = processBeadAnnotations(rawSvg)
console.log('✅ Generated and processed typst.ts SVG, length:', svg.length)
return NextResponse.json({
svg,
success: true,
number: config.number
})
} catch (error) {
console.error('❌ Typst SVG generation failed:', error)
return NextResponse.json(
{
error: error instanceof Error ? error.message : 'Unknown error',
success: false
},
{ status: 500 }
)
}
}
// Health check
export async function GET() {
return NextResponse.json({
status: 'healthy',
endpoint: 'typst-svg',
message: 'Typst.ts SVG generation API is running'
})
}

View File

@@ -1,25 +0,0 @@
import { NextResponse } from 'next/server'
import fs from 'fs'
import { getTemplatePath } from '@soroban/templates'
// API endpoint to serve the flashcards.typ template content
export async function GET() {
try {
const templatePath = getTemplatePath('flashcards.typ');
const flashcardsTemplate = fs.readFileSync(templatePath, 'utf-8')
return NextResponse.json({
template: flashcardsTemplate,
success: true
})
} catch (error) {
console.error('Failed to load typst template:', error)
return NextResponse.json(
{
error: 'Failed to load template',
success: false
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,120 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getViewerId } from '@/lib/viewer'
/**
* GET /api/user-stats
* Get user statistics for the current viewer
*/
export async function GET() {
try {
const viewerId = await getViewerId()
// Get user record
const user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
// No user yet, return default stats
return NextResponse.json({
stats: {
gamesPlayed: 0,
totalWins: 0,
favoriteGameType: null,
bestTime: null,
highestAccuracy: 0,
},
})
}
// Get stats record
let stats = await db.query.userStats.findFirst({
where: eq(schema.userStats.userId, user.id),
})
// If no stats record exists, create one with defaults
if (!stats) {
const [newStats] = await db
.insert(schema.userStats)
.values({
userId: user.id,
})
.returning()
stats = newStats
}
return NextResponse.json({ stats })
} catch (error) {
console.error('Failed to fetch user stats:', error)
return NextResponse.json({ error: 'Failed to fetch user stats' }, { status: 500 })
}
}
/**
* PATCH /api/user-stats
* Update user statistics for the current viewer
*/
export async function PATCH(req: NextRequest) {
try {
const viewerId = await getViewerId()
const body = await req.json()
// Get or create user record
let user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
// Create user if it doesn't exist
const [newUser] = await db
.insert(schema.users)
.values({
guestId: viewerId,
})
.returning()
user = newUser
}
// Get existing stats
const stats = await db.query.userStats.findFirst({
where: eq(schema.userStats.userId, user.id),
})
// Prepare update values
const updates: any = {}
if (body.gamesPlayed !== undefined) updates.gamesPlayed = body.gamesPlayed
if (body.totalWins !== undefined) updates.totalWins = body.totalWins
if (body.favoriteGameType !== undefined) updates.favoriteGameType = body.favoriteGameType
if (body.bestTime !== undefined) updates.bestTime = body.bestTime
if (body.highestAccuracy !== undefined) updates.highestAccuracy = body.highestAccuracy
if (stats) {
// Update existing stats
const [updatedStats] = await db
.update(schema.userStats)
.set(updates)
.where(eq(schema.userStats.userId, user.id))
.returning()
return NextResponse.json({ stats: updatedStats })
} else {
// Create new stats record
const [newStats] = await db
.insert(schema.userStats)
.values({
userId: user.id,
...updates,
})
.returning()
return NextResponse.json({ stats: newStats }, { status: 201 })
}
} catch (error) {
console.error('Failed to update user stats:', error)
return NextResponse.json({ error: 'Failed to update user stats' }, { status: 500 })
}
}

View File

@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server'
import { getViewerId } from '@/lib/viewer'
/**
* GET /api/viewer
*
* Returns the current viewer's ID (guest or authenticated user)
*/
export async function GET() {
try {
const viewerId = await getViewerId()
return NextResponse.json({ viewerId })
} catch (_error) {
return NextResponse.json({ error: 'No valid viewer session found' }, { status: 401 })
}
}

View File

@@ -0,0 +1,62 @@
'use client'
import { useEffect, useState } from 'react'
interface SpeechBubbleProps {
message: string
onHide: () => void
}
export function SpeechBubble({ message, onHide }: SpeechBubbleProps) {
const [isVisible, setIsVisible] = useState(true)
useEffect(() => {
// Auto-hide after 3.5s (line 11749-11752)
const timer = setTimeout(() => {
setIsVisible(false)
setTimeout(onHide, 300) // Wait for fade-out animation
}, 3500)
return () => clearTimeout(timer)
}, [onHide])
return (
<div
style={{
position: 'absolute',
bottom: 'calc(100% + 10px)',
left: '50%',
transform: 'translateX(-50%)',
background: 'white',
borderRadius: '15px',
padding: '10px 15px',
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
fontSize: '14px',
whiteSpace: 'nowrap',
opacity: isVisible ? 1 : 0,
transition: 'opacity 0.3s ease',
zIndex: 10,
pointerEvents: 'none',
maxWidth: '250px',
textAlign: 'center',
}}
>
{message}
{/* Tail pointing down */}
<div
style={{
position: 'absolute',
bottom: '-8px',
left: '50%',
transform: 'translateX(-50%)',
width: 0,
height: 0,
borderLeft: '8px solid transparent',
borderRight: '8px solid transparent',
borderTop: '8px solid white',
filter: 'drop-shadow(0 2px 2px rgba(0,0,0,0.1))',
}}
/>
</div>
)
}

View File

@@ -0,0 +1,154 @@
import type { AIRacer } from '../../lib/gameTypes'
export type CommentaryContext =
| 'ahead'
| 'behind'
| 'adaptive_struggle'
| 'adaptive_mastery'
| 'player_passed'
| 'ai_passed'
| 'lapped'
| 'desperate_catchup'
// Swift AI - Competitive personality (lines 11768-11834)
export const swiftAICommentary: Record<CommentaryContext, string[]> = {
ahead: [
'💨 Eat my dust!',
'🔥 Too slow for me!',
"⚡ You can't catch me!",
"🚀 I'm built for speed!",
'🏃‍♂️ This is way too easy!',
],
behind: [
'😤 Not over yet!',
"💪 I'm just getting started!",
'🔥 Watch me catch up to you!',
"⚡ I'm coming for you!",
'🏃‍♂️ This is my comeback!',
],
adaptive_struggle: [
'😏 You struggling much?',
'🤖 Math is easy for me!',
'⚡ You need to think faster!',
'🔥 Need me to slow down?',
],
adaptive_mastery: [
"😮 You're actually impressive!",
"🤔 You're getting faster...",
'😤 Time for me to step it up!',
'⚡ Not bad for a human!',
],
player_passed: [
'😠 No way you just passed me!',
"🔥 This isn't over!",
"💨 I'm just getting warmed up!",
"😤 Your lucky streak won't last!",
"⚡ I'll be back in front of you soon!",
],
ai_passed: [
'💨 See ya later, slowpoke!',
'😎 Thanks for the warm-up!',
"🔥 This is how it's done!",
"⚡ I'll see you at the finish line!",
'💪 Try to keep up with me!',
],
lapped: [
'😡 You just lapped me?! No way!',
'🤬 This is embarrassing for me!',
"😤 I'm not going down without a fight!",
'💢 How did you get so far ahead?!',
'🔥 Time to show you my real speed!',
"😠 You won't stay ahead for long!",
],
desperate_catchup: [
"🚨 TURBO MODE ACTIVATED! I'm coming for you!",
'💥 You forced me to unleash my true power!',
'🔥 NO MORE MR. NICE AI! Time to go all out!',
"⚡ I'm switching to MAXIMUM OVERDRIVE!",
"😤 You made me angry - now you'll see what I can do!",
"🚀 AFTERBURNERS ENGAGED! This isn't over!",
],
}
// Math Bot - Analytical personality (lines 11835-11901)
export const mathBotCommentary: Record<CommentaryContext, string[]> = {
ahead: [
'📊 My performance is optimal!',
'🤖 My logic beats your speed!',
'📈 I have 87% win probability!',
"⚙️ I'm perfectly calibrated!",
'🔬 Science prevails over you!',
],
behind: [
'🤔 Recalculating my strategy...',
"📊 You're exceeding my projections!",
'⚙️ Adjusting my parameters!',
"🔬 I'm analyzing your technique!",
"📈 You're a statistical anomaly!",
],
adaptive_struggle: [
'📊 I detect inefficiencies in you!',
'🔬 You should focus on patterns!',
'⚙️ Use that extra time wisely!',
'📈 You have room for improvement!',
],
adaptive_mastery: [
'🤖 Your optimization is excellent!',
'📊 Your metrics are impressive!',
"⚙️ I'm updating my models because of you!",
'🔬 You have near-AI efficiency!',
],
player_passed: [
'🤖 Your strategy is fascinating!',
"📊 You're an unexpected variable!",
"⚙️ I'm adjusting my algorithms...",
'🔬 Your execution is impressive!',
"📈 I'm recalculating the odds!",
],
ai_passed: [
'🤖 My efficiency is optimized!',
'📊 Just as I calculated!',
'⚙️ All my systems nominal!',
'🔬 My logic prevails over you!',
"📈 I'm at 96% confidence level!",
],
lapped: [
'🤖 Error: You have exceeded my projections!',
'📊 This outcome has 0.3% probability!',
'⚙️ I need to recalibrate my systems!',
'🔬 Your performance is... statistically improbable!',
'📈 My confidence level just dropped to 12%!',
'🤔 I must analyze your methodology!',
],
desperate_catchup: [
'🤖 EMERGENCY PROTOCOL ACTIVATED! Initiating maximum speed!',
'🚨 CRITICAL GAP DETECTED! Engaging catchup algorithms!',
'⚙️ OVERCLOCKING MY PROCESSORS! Prepare for rapid acceleration!',
'📊 PROBABILITY OF FAILURE: UNACCEPTABLE! Switching to turbo mode!',
"🔬 HYPOTHESIS: You're about to see my true potential!",
'📈 CONFIDENCE LEVEL: RISING! My comeback protocol is online!',
],
}
// Get AI commentary message (lines 11636-11657)
export function getAICommentary(
racer: AIRacer,
context: CommentaryContext,
_playerProgress: number,
_aiProgress: number
): string | null {
// Check cooldown (line 11759-11761)
const now = Date.now()
if (now - racer.lastComment < racer.commentCooldown) {
return null
}
// Select message set based on personality and context
const messages =
racer.personality === 'competitive' ? swiftAICommentary[context] : mathBotCommentary[context]
if (!messages || messages.length === 0) return null
// Return random message
return messages[Math.floor(Math.random() * messages.length)]
}

View File

@@ -0,0 +1,36 @@
'use client'
import { AbacusReact } from '@soroban/abacus-react'
interface AbacusTargetProps {
number: number // The complement number to display
}
/**
* Displays a small abacus showing a complement number inline in the equation
* Used to help learners recognize the abacus representation of complement numbers
*/
export function AbacusTarget({ number }: AbacusTargetProps) {
return (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
lineHeight: 0,
}}
>
<AbacusReact
value={number}
columns={1}
interactive={false}
showNumbers={false}
hideInactiveBeads={true}
scaleFactor={0.72}
customStyles={{
columnPosts: { opacity: 0 },
}}
/>
</div>
)
}

View File

@@ -0,0 +1,373 @@
'use client'
import { useComplementRace } from '../context/ComplementRaceContext'
import { GameControls } from './GameControls'
import { GameCountdown } from './GameCountdown'
import { GameDisplay } from './GameDisplay'
import { GameIntro } from './GameIntro'
import { GameResults } from './GameResults'
export function ComplementRaceGame() {
const { state } = useComplementRace()
return (
<div
data-component="game-page-root"
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
padding: '20px 8px',
minHeight: '100vh',
maxHeight: '100vh',
background:
state.style === 'sprint'
? 'linear-gradient(to bottom, #2563eb 0%, #60a5fa 100%)'
: 'radial-gradient(ellipse at center, #8db978 0%, #7ba565 40%, #6a9354 100%)',
position: 'relative',
}}
>
{/* Background pattern - subtle grass texture */}
{state.style !== 'sprint' && (
<div
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
pointerEvents: 'none',
zIndex: 0,
opacity: 0.15,
}}
>
<svg width="100%" height="100%">
<defs>
<pattern
id="grass-texture"
x="0"
y="0"
width="40"
height="40"
patternUnits="userSpaceOnUse"
>
<rect width="40" height="40" fill="transparent" />
<line x1="2" y1="5" x2="8" y2="5" stroke="#2d5016" strokeWidth="1" opacity="0.3" />
<line
x1="15"
y1="8"
x2="20"
y2="8"
stroke="#2d5016"
strokeWidth="1"
opacity="0.25"
/>
<line
x1="25"
y1="12"
x2="32"
y2="12"
stroke="#2d5016"
strokeWidth="1"
opacity="0.2"
/>
<line
x1="5"
y1="18"
x2="12"
y2="18"
stroke="#2d5016"
strokeWidth="1"
opacity="0.3"
/>
<line
x1="28"
y1="22"
x2="35"
y2="22"
stroke="#2d5016"
strokeWidth="1"
opacity="0.25"
/>
<line
x1="10"
y1="30"
x2="16"
y2="30"
stroke="#2d5016"
strokeWidth="1"
opacity="0.2"
/>
<line
x1="22"
y1="35"
x2="28"
y2="35"
stroke="#2d5016"
strokeWidth="1"
opacity="0.3"
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grass-texture)" />
</svg>
</div>
)}
{/* Subtle tree clusters around edges - top-down view with gentle sway */}
{state.style !== 'sprint' && (
<div
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
pointerEvents: 'none',
zIndex: 0,
}}
>
{/* Top-left tree cluster */}
<div
style={{
position: 'absolute',
top: '5%',
left: '3%',
width: '80px',
height: '80px',
borderRadius: '50%',
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
opacity: 0.2,
filter: 'blur(4px)',
animation: 'treeSway1 8s ease-in-out infinite',
}}
/>
{/* Top-right tree cluster */}
<div
style={{
position: 'absolute',
top: '8%',
right: '5%',
width: '100px',
height: '100px',
borderRadius: '50%',
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
opacity: 0.18,
filter: 'blur(5px)',
animation: 'treeSway2 10s ease-in-out infinite',
}}
/>
{/* Bottom-left tree cluster */}
<div
style={{
position: 'absolute',
bottom: '10%',
left: '8%',
width: '90px',
height: '90px',
borderRadius: '50%',
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
opacity: 0.15,
filter: 'blur(4px)',
animation: 'treeSway1 9s ease-in-out infinite reverse',
}}
/>
{/* Bottom-right tree cluster */}
<div
style={{
position: 'absolute',
bottom: '5%',
right: '4%',
width: '110px',
height: '110px',
borderRadius: '50%',
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
opacity: 0.2,
filter: 'blur(6px)',
animation: 'treeSway2 11s ease-in-out infinite',
}}
/>
{/* Additional smaller clusters for depth */}
<div
style={{
position: 'absolute',
top: '40%',
left: '2%',
width: '60px',
height: '60px',
borderRadius: '50%',
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
opacity: 0.12,
filter: 'blur(3px)',
animation: 'treeSway1 7s ease-in-out infinite',
}}
/>
<div
style={{
position: 'absolute',
top: '55%',
right: '3%',
width: '70px',
height: '70px',
borderRadius: '50%',
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
opacity: 0.14,
filter: 'blur(4px)',
animation: 'treeSway2 8.5s ease-in-out infinite reverse',
}}
/>
</div>
)}
{/* Flying bird shadows - very subtle from aerial view */}
{state.style !== 'sprint' && (
<div
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
pointerEvents: 'none',
zIndex: 0,
}}
>
<div
style={{
position: 'absolute',
top: '30%',
left: '-5%',
width: '15px',
height: '8px',
background: 'rgba(0, 0, 0, 0.08)',
borderRadius: '50%',
filter: 'blur(2px)',
animation: 'birdFly1 20s linear infinite',
}}
/>
<div
style={{
position: 'absolute',
top: '60%',
left: '-5%',
width: '12px',
height: '6px',
background: 'rgba(0, 0, 0, 0.06)',
borderRadius: '50%',
filter: 'blur(2px)',
animation: 'birdFly2 28s linear infinite',
}}
/>
<div
style={{
position: 'absolute',
top: '45%',
left: '-5%',
width: '10px',
height: '5px',
background: 'rgba(0, 0, 0, 0.05)',
borderRadius: '50%',
filter: 'blur(1px)',
animation: 'birdFly1 35s linear infinite',
animationDelay: '-12s',
}}
/>
</div>
)}
{/* Subtle cloud shadows moving across field */}
{state.style !== 'sprint' && (
<div
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
pointerEvents: 'none',
zIndex: 0,
}}
>
<div
style={{
position: 'absolute',
top: '-10%',
left: '-20%',
width: '300px',
height: '200px',
background: 'radial-gradient(ellipse, rgba(0, 0, 0, 0.03) 0%, transparent 60%)',
borderRadius: '50%',
filter: 'blur(20px)',
animation: 'cloudShadow1 45s linear infinite',
}}
/>
<div
style={{
position: 'absolute',
top: '-10%',
left: '-20%',
width: '250px',
height: '180px',
background: 'radial-gradient(ellipse, rgba(0, 0, 0, 0.025) 0%, transparent 60%)',
borderRadius: '50%',
filter: 'blur(25px)',
animation: 'cloudShadow2 60s linear infinite',
animationDelay: '-20s',
}}
/>
</div>
)}
{/* CSS animations */}
<style>{`
@keyframes treeSway1 {
0%, 100% { transform: scale(1) translate(0, 0); }
25% { transform: scale(1.02) translate(2px, -1px); }
50% { transform: scale(0.98) translate(-1px, 1px); }
75% { transform: scale(1.01) translate(-2px, -1px); }
}
@keyframes treeSway2 {
0%, 100% { transform: scale(1) translate(0, 0); }
30% { transform: scale(1.015) translate(-2px, 1px); }
60% { transform: scale(0.985) translate(2px, -1px); }
80% { transform: scale(1.01) translate(1px, 1px); }
}
@keyframes birdFly1 {
0% { transform: translate(0, 0); }
100% { transform: translate(calc(100vw + 100px), -20vh); }
}
@keyframes birdFly2 {
0% { transform: translate(0, 0); }
100% { transform: translate(calc(100vw + 100px), 15vh); }
}
@keyframes cloudShadow1 {
0% { transform: translate(0, 0); }
100% { transform: translate(calc(100vw + 400px), 30vh); }
}
@keyframes cloudShadow2 {
0% { transform: translate(0, 0); }
100% { transform: translate(calc(100vw + 350px), -20vh); }
}
`}</style>
<div
style={{
maxWidth: '100%',
margin: '0 auto',
flex: 1,
display: 'flex',
flexDirection: 'column',
position: 'relative',
zIndex: 1,
}}
>
{state.gamePhase === 'intro' && <GameIntro />}
{state.gamePhase === 'controls' && <GameControls />}
{state.gamePhase === 'countdown' && <GameCountdown />}
{state.gamePhase === 'playing' && <GameDisplay />}
{state.gamePhase === 'results' && <GameResults />}
</div>
</div>
)
}

View File

@@ -0,0 +1,475 @@
'use client'
import { useComplementRace } from '../context/ComplementRaceContext'
import type { ComplementDisplay, GameMode, GameStyle, TimeoutSetting } from '../lib/gameTypes'
import { AbacusTarget } from './AbacusTarget'
export function GameControls() {
const { state, dispatch } = useComplementRace()
const handleModeSelect = (mode: GameMode) => {
dispatch({ type: 'SET_MODE', mode })
}
const handleStyleSelect = (style: GameStyle) => {
dispatch({ type: 'SET_STYLE', style })
// Start the game immediately - no navigation needed
if (style === 'sprint') {
dispatch({ type: 'BEGIN_GAME' })
} else {
dispatch({ type: 'START_COUNTDOWN' })
}
}
const handleTimeoutSelect = (timeout: TimeoutSetting) => {
dispatch({ type: 'SET_TIMEOUT', timeout })
}
return (
<div
style={{
height: '100vh',
display: 'flex',
flexDirection: 'column',
background: 'linear-gradient(to bottom, #0f172a 0%, #1e293b 50%, #334155 100%)',
overflow: 'hidden',
position: 'relative',
}}
>
{/* Animated background pattern */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage:
'radial-gradient(circle at 20% 50%, rgba(59, 130, 246, 0.1) 0%, transparent 50%), radial-gradient(circle at 80% 80%, rgba(139, 92, 246, 0.1) 0%, transparent 50%)',
pointerEvents: 'none',
}}
/>
{/* Header */}
<div
style={{
textAlign: 'center',
padding: '20px',
position: 'relative',
zIndex: 1,
}}
>
<h1
style={{
fontSize: '32px',
fontWeight: 'bold',
background: 'linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
margin: 0,
letterSpacing: '-0.5px',
}}
>
Complement Race
</h1>
</div>
{/* Settings Bar */}
<div
style={{
padding: '0 20px 16px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
position: 'relative',
zIndex: 1,
}}
>
{/* Number Mode & Display */}
<div
style={{
background: 'rgba(30, 41, 59, 0.8)',
backdropFilter: 'blur(20px)',
borderRadius: '16px',
padding: '16px',
border: '1px solid rgba(148, 163, 184, 0.2)',
}}
>
<div
style={{
display: 'flex',
gap: '20px',
flexWrap: 'wrap',
alignItems: 'center',
}}
>
{/* Number Mode Pills */}
<div
style={{
display: 'flex',
gap: '8px',
alignItems: 'center',
flex: 1,
minWidth: '200px',
}}
>
<span
style={{
fontSize: '13px',
color: '#94a3b8',
fontWeight: '600',
marginRight: '4px',
}}
>
Mode:
</span>
{[
{ mode: 'friends5' as GameMode, label: '5' },
{ mode: 'friends10' as GameMode, label: '10' },
{ mode: 'mixed' as GameMode, label: 'Mix' },
].map(({ mode, label }) => (
<button
key={mode}
onClick={() => handleModeSelect(mode)}
style={{
padding: '8px 16px',
borderRadius: '20px',
border: 'none',
background:
state.mode === mode
? 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)'
: 'rgba(148, 163, 184, 0.2)',
color: state.mode === mode ? 'white' : '#94a3b8',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s ease',
fontSize: '13px',
}}
>
{label}
</button>
))}
</div>
{/* Complement Display Pills */}
<div
style={{
display: 'flex',
gap: '8px',
alignItems: 'center',
flex: 1,
minWidth: '200px',
}}
>
<span
style={{
fontSize: '13px',
color: '#94a3b8',
fontWeight: '600',
marginRight: '4px',
}}
>
Show:
</span>
{(['number', 'abacus', 'random'] as ComplementDisplay[]).map((displayMode) => (
<button
key={displayMode}
onClick={() => dispatch({ type: 'SET_COMPLEMENT_DISPLAY', display: displayMode })}
style={{
padding: '8px 16px',
borderRadius: '20px',
border: 'none',
background:
state.complementDisplay === displayMode
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
: 'rgba(148, 163, 184, 0.2)',
color: state.complementDisplay === displayMode ? 'white' : '#94a3b8',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s ease',
fontSize: '13px',
}}
>
{displayMode === 'number' ? '123' : displayMode === 'abacus' ? '🧮' : '🎲'}
</button>
))}
</div>
{/* Speed Pills */}
<div
style={{
display: 'flex',
gap: '6px',
alignItems: 'center',
flex: 1,
minWidth: '200px',
flexWrap: 'wrap',
}}
>
<span
style={{
fontSize: '13px',
color: '#94a3b8',
fontWeight: '600',
marginRight: '4px',
}}
>
Speed:
</span>
{(
[
'preschool',
'kindergarten',
'relaxed',
'slow',
'normal',
'fast',
'expert',
] as TimeoutSetting[]
).map((timeout) => (
<button
key={timeout}
onClick={() => handleTimeoutSelect(timeout)}
style={{
padding: '6px 12px',
borderRadius: '16px',
border: 'none',
background:
state.timeoutSetting === timeout
? 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)'
: 'rgba(148, 163, 184, 0.2)',
color: state.timeoutSetting === timeout ? 'white' : '#94a3b8',
fontWeight: state.timeoutSetting === timeout ? 'bold' : 'normal',
cursor: 'pointer',
transition: 'all 0.2s ease',
fontSize: '11px',
}}
>
{timeout === 'preschool'
? 'Pre'
: timeout === 'kindergarten'
? 'K'
: timeout.charAt(0).toUpperCase()}
</button>
))}
</div>
</div>
{/* Preview - compact */}
<div
style={{
marginTop: '12px',
padding: '12px',
borderRadius: '12px',
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(148, 163, 184, 0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
}}
>
<span style={{ fontSize: '11px', color: '#94a3b8', fontWeight: '600' }}>Preview:</span>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
fontSize: '20px',
fontWeight: 'bold',
color: 'white',
}}
>
<div
style={{
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
color: 'white',
padding: '2px 10px',
borderRadius: '6px',
}}
>
?
</div>
<span style={{ fontSize: '16px', color: '#64748b' }}>+</span>
{state.complementDisplay === 'number' ? (
<span>3</span>
) : state.complementDisplay === 'abacus' ? (
<div style={{ transform: 'scale(0.8)' }}>
<AbacusTarget number={3} />
</div>
) : (
<span style={{ fontSize: '14px' }}>🎲</span>
)}
<span style={{ fontSize: '16px', color: '#64748b' }}>=</span>
<span style={{ color: '#10b981' }}>
{state.mode === 'friends5' ? '5' : state.mode === 'friends10' ? '10' : '?'}
</span>
</div>
</div>
</div>
</div>
{/* HERO SECTION - Race Cards */}
<div
data-component="race-cards-container"
style={{
flex: 1,
padding: '0 20px 20px',
display: 'flex',
flexDirection: 'column',
gap: '16px',
position: 'relative',
zIndex: 1,
overflow: 'auto',
}}
>
{[
{
style: 'practice' as GameStyle,
emoji: '🏁',
title: 'Practice Race',
desc: 'Race against AI to 20 correct answers',
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
shadowColor: 'rgba(16, 185, 129, 0.5)',
accentColor: '#34d399',
},
{
style: 'sprint' as GameStyle,
emoji: '🚂',
title: 'Steam Sprint',
desc: 'High-speed 60-second train journey',
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
shadowColor: 'rgba(245, 158, 11, 0.5)',
accentColor: '#fbbf24',
},
{
style: 'survival' as GameStyle,
emoji: '🔄',
title: 'Survival Circuit',
desc: 'Endless laps - beat your best time',
gradient: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)',
shadowColor: 'rgba(139, 92, 246, 0.5)',
accentColor: '#a78bfa',
},
].map(({ style, emoji, title, desc, gradient, shadowColor, accentColor }) => (
<button
key={style}
onClick={() => handleStyleSelect(style)}
style={{
position: 'relative',
padding: '0',
border: 'none',
borderRadius: '24px',
background: gradient,
cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: `0 10px 40px ${shadowColor}, 0 0 0 1px rgba(255, 255, 255, 0.1)`,
transform: 'translateY(0)',
flex: 1,
minHeight: '140px',
overflow: 'hidden',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-8px) scale(1.02)'
e.currentTarget.style.boxShadow = `0 20px 60px ${shadowColor}, 0 0 0 2px ${accentColor}`
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0) scale(1)'
e.currentTarget.style.boxShadow = `0 10px 40px ${shadowColor}, 0 0 0 1px rgba(255, 255, 255, 0.1)`
}}
>
{/* Shine effect overlay */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(135deg, rgba(255,255,255,0.2) 0%, transparent 50%)',
pointerEvents: 'none',
}}
/>
<div
style={{
padding: '28px 32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
position: 'relative',
zIndex: 1,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '20px',
flex: 1,
}}
>
<div
style={{
fontSize: '64px',
lineHeight: 1,
filter: 'drop-shadow(0 4px 8px rgba(0,0,0,0.3))',
}}
>
{emoji}
</div>
<div style={{ textAlign: 'left', flex: 1 }}>
<div
style={{
fontSize: '28px',
fontWeight: 'bold',
color: 'white',
marginBottom: '6px',
textShadow: '0 2px 8px rgba(0,0,0,0.3)',
}}
>
{title}
</div>
<div
style={{
fontSize: '15px',
color: 'rgba(255, 255, 255, 0.9)',
textShadow: '0 1px 4px rgba(0,0,0,0.2)',
}}
>
{desc}
</div>
</div>
</div>
{/* PLAY NOW button */}
<div
style={{
background: 'white',
color: gradient.includes('10b981')
? '#047857'
: gradient.includes('f59e0b')
? '#d97706'
: '#6b21a8',
padding: '16px 32px',
borderRadius: '16px',
fontWeight: 'bold',
fontSize: '18px',
boxShadow: '0 8px 24px rgba(0,0,0,0.25)',
display: 'flex',
alignItems: 'center',
gap: '10px',
whiteSpace: 'nowrap',
}}
>
<span>PLAY</span>
<span style={{ fontSize: '24px' }}></span>
</div>
</div>
</button>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,104 @@
'use client'
import { useEffect, useState } from 'react'
import { useComplementRace } from '../context/ComplementRaceContext'
import { useSoundEffects } from '../hooks/useSoundEffects'
export function GameCountdown() {
const { dispatch } = useComplementRace()
const { playSound } = useSoundEffects()
const [count, setCount] = useState(3)
const [showGo, setShowGo] = useState(false)
useEffect(() => {
const countdownInterval = setInterval(() => {
setCount((prevCount) => {
if (prevCount > 1) {
// Play countdown beep (volume 0.4)
playSound('countdown', 0.4)
return prevCount - 1
} else if (prevCount === 1) {
// Show GO!
setShowGo(true)
// Play race start fanfare (volume 0.6)
playSound('race_start', 0.6)
return 0
}
return prevCount
})
}, 1000)
return () => clearInterval(countdownInterval)
}, [playSound])
useEffect(() => {
if (showGo) {
// Hide countdown and start game after GO animation
const timer = setTimeout(() => {
dispatch({ type: 'BEGIN_GAME' })
}, 1000)
return () => clearTimeout(timer)
}
}, [showGo, dispatch])
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0, 0, 0, 0.9)',
zIndex: 1000,
}}
>
<div
style={{
fontSize: showGo ? '120px' : '160px',
fontWeight: 'bold',
color: showGo ? '#10b981' : 'white',
textShadow: '0 4px 20px rgba(0, 0, 0, 0.5)',
animation: showGo ? 'scaleUp 1s ease-out' : 'pulse 0.5s ease-in-out',
transition: 'all 0.3s ease',
}}
>
{showGo ? 'GO!' : count}
</div>
{!showGo && (
<div
style={{
marginTop: '32px',
fontSize: '24px',
color: 'rgba(255, 255, 255, 0.8)',
fontWeight: '500',
}}
>
Get Ready!
</div>
)}
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.8; }
}
@keyframes scaleUp {
0% { transform: scale(0.5); opacity: 0; }
50% { transform: scale(1.2); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
`,
}}
/>
</div>
)
}

View File

@@ -0,0 +1,409 @@
'use client'
import { useEffect, useState } from 'react'
import { useComplementRace } from '../context/ComplementRaceContext'
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'
import { LinearTrack } from './RaceTrack/LinearTrack'
import { SteamTrainJourney } from './RaceTrack/SteamTrainJourney'
import { RouteCelebration } from './RouteCelebration'
type FeedbackAnimation = 'correct' | 'incorrect' | null
export function GameDisplay() {
const { state, dispatch } = 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)
// Clear feedback animation after it plays (line 1996, 2001)
useEffect(() => {
if (feedbackAnimation) {
const timer = setTimeout(() => {
setFeedbackAnimation(null)
}, 500) // Match animation duration
return () => clearTimeout(timer)
}
}, [feedbackAnimation])
// Show adaptive feedback with auto-hide
useEffect(() => {
if (state.adaptiveFeedback) {
const timer = setTimeout(() => {
dispatch({ type: 'CLEAR_ADAPTIVE_FEEDBACK' })
}, 3000)
return () => clearTimeout(timer)
}
}, [state.adaptiveFeedback, dispatch])
// Check for finish line (player reaches race goal) - only for practice mode
useEffect(() => {
if (
state.correctAnswers >= state.raceGoal &&
state.isGameActive &&
state.style === 'practice'
) {
// Play celebration sound (line 14182)
playSound('celebration')
// End the game
dispatch({ type: 'END_RACE' })
// Show results after a short delay
setTimeout(() => {
dispatch({ type: 'SHOW_RESULTS' })
}, 1500)
}
}, [state.correctAnswers, state.raceGoal, state.isGameActive, state.style, dispatch, playSound])
// For survival mode (endless circuit), track laps but never end
// For sprint mode (steam sprint), end after 60 seconds (will implement later)
// Handle keyboard input
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
// Only process number keys
if (/^[0-9]$/.test(e.key)) {
const newInput = state.currentInput + e.key
dispatch({ type: 'UPDATE_INPUT', input: newInput })
// Check if answer is complete
if (state.currentQuestion) {
const answer = parseInt(newInput, 10)
const correctAnswer = state.currentQuestion.correctAnswer
// If we have enough digits to match the answer, submit
if (newInput.length >= correctAnswer.toString().length) {
const responseTime = Date.now() - state.questionStartTime
const isCorrect = answer === correctAnswer
const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}`
if (isCorrect) {
// Correct answer
dispatch({ type: 'SUBMIT_ANSWER', answer })
trackPerformance(true, responseTime)
// Trigger correct answer animation (line 1996)
setFeedbackAnimation('correct')
// Play appropriate sound based on performance (from web_generator.py lines 11530-11542)
const newStreak = state.streak + 1
if (newStreak > 0 && newStreak % 5 === 0) {
// Epic streak sound for every 5th correct answer
playSound('streak')
} else if (responseTime < 800) {
// Whoosh sound for very fast responses (under 800ms)
playSound('whoosh')
} else if (responseTime < 1200 && state.streak >= 3) {
// Combo sound for rapid answers while on a streak
playSound('combo')
} else {
// Regular correct sound
playSound('correct')
}
// Boost momentum for sprint mode
if (state.style === 'sprint') {
boostMomentum()
// Play train whistle for milestones in sprint mode (line 13222-13235)
if (newStreak >= 5 && newStreak % 3 === 0) {
// Major milestone - play train whistle
setTimeout(() => {
playSound('train_whistle', 0.4)
}, 200)
} else if (state.momentum >= 90) {
// High momentum celebration - occasional whistle
if (Math.random() < 0.3) {
setTimeout(() => {
playSound('train_whistle', 0.25)
}, 150)
}
}
}
// Show adaptive feedback
const feedback = getAdaptiveFeedbackMessage(pairKey, true, responseTime)
if (feedback) {
dispatch({ type: 'SHOW_ADAPTIVE_FEEDBACK', feedback })
}
dispatch({ type: 'NEXT_QUESTION' })
} else {
// Incorrect answer
trackPerformance(false, responseTime)
// Trigger incorrect answer animation (line 2001)
setFeedbackAnimation('incorrect')
// Play incorrect sound (from web_generator.py line 11589)
playSound('incorrect')
// Show adaptive feedback
const feedback = getAdaptiveFeedbackMessage(pairKey, false, responseTime)
if (feedback) {
dispatch({ type: 'SHOW_ADAPTIVE_FEEDBACK', feedback })
}
dispatch({ type: 'UPDATE_INPUT', input: '' })
}
}
}
} else if (e.key === 'Backspace') {
dispatch({ type: 'UPDATE_INPUT', input: state.currentInput.slice(0, -1) })
}
}
window.addEventListener('keydown', handleKeyPress)
return () => window.removeEventListener('keydown', handleKeyPress)
}, [
state.currentInput,
state.currentQuestion,
state.questionStartTime,
state.style,
state.streak,
dispatch,
trackPerformance,
getAdaptiveFeedbackMessage,
boostMomentum,
playSound,
state.momentum,
])
// Handle route celebration continue
const handleContinueToNextRoute = () => {
const nextRoute = state.currentRoute + 1
// Start new route (this also hides celebration)
dispatch({
type: 'START_NEW_ROUTE',
routeNumber: nextRoute,
stations: state.stations, // Keep same stations for now
})
// Generate new passengers
const newPassengers = generatePassengers(state.stations)
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
}
if (!state.currentQuestion) return null
return (
<div
data-component="game-display"
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
width: '100%',
}}
>
{/* Adaptive Feedback */}
{state.adaptiveFeedback && (
<div
data-component="adaptive-feedback"
style={{
position: 'fixed',
top: '80px',
left: '50%',
transform: 'translateX(-50%)',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
padding: '12px 24px',
borderRadius: '12px',
boxShadow: '0 4px 20px rgba(102, 126, 234, 0.4)',
fontSize: '16px',
fontWeight: 'bold',
zIndex: 1000,
animation: 'slideDown 0.3s ease-out',
maxWidth: '600px',
textAlign: 'center',
}}
>
{state.adaptiveFeedback.message}
</div>
)}
{/* Stats Header - constrained width, hidden for sprint mode */}
{state.style !== 'sprint' && (
<div
data-component="stats-container"
style={{
maxWidth: '1200px',
margin: '0 auto',
width: '100%',
padding: '0 20px',
marginTop: '10px',
}}
>
<div
data-component="stats-header"
style={{
display: 'flex',
justifyContent: 'space-around',
marginBottom: '10px',
background: 'white',
borderRadius: '12px',
padding: '10px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}}
>
<div data-stat="score" style={{ textAlign: 'center' }}>
<div style={{ color: '#6b7280', fontSize: '14px', marginBottom: '4px' }}>Score</div>
<div style={{ fontWeight: 'bold', fontSize: '24px', color: '#3b82f6' }}>
{state.score}
</div>
</div>
<div data-stat="streak" style={{ textAlign: 'center' }}>
<div style={{ color: '#6b7280', fontSize: '14px', marginBottom: '4px' }}>Streak</div>
<div style={{ fontWeight: 'bold', fontSize: '24px', color: '#10b981' }}>
{state.streak} 🔥
</div>
</div>
<div data-stat="progress" style={{ textAlign: 'center' }}>
<div style={{ color: '#6b7280', fontSize: '14px', marginBottom: '4px' }}>
Progress
</div>
<div style={{ fontWeight: 'bold', fontSize: '24px', color: '#f59e0b' }}>
{state.correctAnswers}/{state.raceGoal}
</div>
</div>
</div>
</div>
)}
{/* Race Track - full width, break out of padding */}
<div
data-component="track-container"
style={{
width: '100vw',
position: 'relative',
left: '50%',
right: '50%',
marginLeft: '-50vw',
marginRight: '-50vw',
padding: state.style === 'sprint' ? '0' : '0 20px',
display: 'flex',
justifyContent: state.style === 'sprint' ? 'stretch' : 'center',
background: 'transparent',
flex: state.style === 'sprint' ? 1 : 'initial',
minHeight: state.style === 'sprint' ? 0 : 'initial',
}}
>
{state.style === 'survival' ? (
<CircularTrack
playerProgress={state.correctAnswers}
playerLap={state.playerLap}
aiRacers={state.aiRacers}
aiLaps={state.aiLaps}
/>
) : state.style === 'sprint' ? (
<SteamTrainJourney
momentum={state.momentum}
trainPosition={state.trainPosition}
pressure={state.pressure}
elapsedTime={state.elapsedTime}
currentQuestion={state.currentQuestion}
currentInput={state.currentInput}
/>
) : (
<LinearTrack
playerProgress={state.correctAnswers}
aiRacers={state.aiRacers}
raceGoal={state.raceGoal}
showFinishLine={true}
/>
)}
</div>
{/* Question Display - only for non-sprint modes */}
{state.style !== 'sprint' && (
<div
data-component="question-container"
style={{
maxWidth: '1200px',
margin: '0 auto',
width: '100%',
padding: '0 20px',
display: 'flex',
justifyContent: 'center',
marginTop: '20px',
}}
>
<div
data-component="question-display"
style={{
background: 'rgba(255, 255, 255, 0.98)',
borderRadius: '24px',
padding: '28px 50px',
boxShadow: '0 16px 40px rgba(0, 0, 0, 0.3), 0 0 0 5px rgba(59, 130, 246, 0.4)',
backdropFilter: 'blur(12px)',
border: '4px solid rgba(255, 255, 255, 0.95)',
}}
>
{/* Complement equation as main focus */}
<div
data-element="question-equation"
style={{
fontSize: '96px',
fontWeight: 'bold',
color: '#1f2937',
lineHeight: '1.1',
display: 'flex',
alignItems: 'center',
gap: '20px',
justifyContent: 'center',
}}
>
<span
style={{
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
color: 'white',
padding: '12px 32px',
borderRadius: '16px',
minWidth: '140px',
display: 'inline-block',
textShadow: '0 3px 10px rgba(0, 0, 0, 0.3)',
}}
>
{state.currentInput || '?'}
</span>
<span style={{ color: '#6b7280' }}>+</span>
{state.currentQuestion.showAsAbacus ? (
<div
style={{
transform: 'scale(2.4) translateY(8%)',
transformOrigin: 'center center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AbacusTarget number={state.currentQuestion.number} />
</div>
) : (
<span>{state.currentQuestion.number}</span>
)}
<span style={{ color: '#6b7280' }}>=</span>
<span style={{ color: '#10b981' }}>{state.currentQuestion.targetSum}</span>
</div>
</div>
</div>
)}
{/* Route Celebration Modal */}
{state.showRouteCelebration && state.style === 'sprint' && (
<RouteCelebration
completedRouteNumber={state.currentRoute}
nextRouteNumber={state.currentRoute + 1}
onContinue={handleContinueToNextRoute}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,132 @@
'use client'
import { useComplementRace } from '../context/ComplementRaceContext'
export function GameIntro() {
const { dispatch } = useComplementRace()
const handleStartClick = () => {
dispatch({ type: 'SHOW_CONTROLS' })
}
return (
<div
style={{
textAlign: 'center',
padding: '40px 20px',
maxWidth: '800px',
margin: '20px auto 0',
}}
>
<h1
style={{
fontSize: '48px',
fontWeight: 'bold',
marginBottom: '16px',
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
Speed Complement Race
</h1>
<p
style={{
fontSize: '18px',
color: '#6b7280',
marginBottom: '32px',
lineHeight: '1.6',
}}
>
Race against AI opponents while solving complement problems! Find the missing number to
complete the equation.
</p>
<div
style={{
background: 'white',
borderRadius: '16px',
padding: '32px',
marginBottom: '32px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
textAlign: 'left',
}}
>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '16px',
color: '#1f2937',
}}
>
How to Play
</h2>
<ul
style={{
listStyle: 'none',
padding: 0,
margin: 0,
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
>
<li style={{ display: 'flex', gap: '12px', alignItems: 'start' }}>
<span style={{ fontSize: '24px' }}>🎯</span>
<span style={{ color: '#4b5563', lineHeight: '1.6' }}>
Find the complement number to reach the target sum
</span>
</li>
<li style={{ display: 'flex', gap: '12px', alignItems: 'start' }}>
<span style={{ fontSize: '24px' }}></span>
<span style={{ color: '#4b5563', lineHeight: '1.6' }}>
Type your answer quickly to move forward in the race
</span>
</li>
<li style={{ display: 'flex', gap: '12px', alignItems: 'start' }}>
<span style={{ fontSize: '24px' }}>🤖</span>
<span style={{ color: '#4b5563', lineHeight: '1.6' }}>
Compete against Swift AI and Math Bot with unique personalities
</span>
</li>
<li style={{ display: 'flex', gap: '12px', alignItems: 'start' }}>
<span style={{ fontSize: '24px' }}>🏆</span>
<span style={{ color: '#4b5563', lineHeight: '1.6' }}>
Earn points for correct answers and build up your streak
</span>
</li>
</ul>
</div>
<button
onClick={handleStartClick}
style={{
background: 'linear-gradient(135deg, #10b981, #059669)',
color: 'white',
border: 'none',
borderRadius: '12px',
padding: '16px 48px',
fontSize: '20px',
fontWeight: 'bold',
cursor: 'pointer',
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.3)',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 6px 16px rgba(16, 185, 129, 0.4)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.3)'
}}
>
Start Racing!
</button>
</div>
)
}

View File

@@ -0,0 +1,245 @@
'use client'
import { useComplementRace } from '../context/ComplementRaceContext'
export function GameResults() {
const { state, dispatch } = useComplementRace()
// Determine race outcome
const playerWon = state.aiRacers.every((racer) => state.correctAnswers > racer.position)
const playerPosition =
state.aiRacers.filter((racer) => racer.position >= state.correctAnswers).length + 1
return (
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '60px 40px 40px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
minHeight: '100vh',
}}
>
<div
style={{
background: 'white',
borderRadius: '24px',
padding: '48px',
maxWidth: '600px',
width: '100%',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
textAlign: 'center',
}}
>
{/* Result Header */}
<div
style={{
fontSize: '64px',
marginBottom: '16px',
}}
>
{playerWon ? '🏆' : playerPosition === 2 ? '🥈' : playerPosition === 3 ? '🥉' : '🎯'}
</div>
<h1
style={{
fontSize: '36px',
fontWeight: 'bold',
color: '#1f2937',
marginBottom: '8px',
}}
>
{playerWon ? 'Victory!' : `${playerPosition}${getOrdinalSuffix(playerPosition)} Place`}
</h1>
<p
style={{
fontSize: '18px',
color: '#6b7280',
marginBottom: '32px',
}}
>
{playerWon ? 'You beat all the AI racers!' : `You finished the race!`}
</p>
{/* Stats */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '16px',
marginBottom: '32px',
}}
>
<div
style={{
background: '#f3f4f6',
borderRadius: '12px',
padding: '16px',
}}
>
<div style={{ color: '#6b7280', fontSize: '14px', marginBottom: '4px' }}>
Final Score
</div>
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#3b82f6' }}>
{state.score}
</div>
</div>
<div
style={{
background: '#f3f4f6',
borderRadius: '12px',
padding: '16px',
}}
>
<div style={{ color: '#6b7280', fontSize: '14px', marginBottom: '4px' }}>
Best Streak
</div>
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#10b981' }}>
{state.bestStreak} 🔥
</div>
</div>
<div
style={{
background: '#f3f4f6',
borderRadius: '12px',
padding: '16px',
}}
>
<div style={{ color: '#6b7280', fontSize: '14px', marginBottom: '4px' }}>
Total Questions
</div>
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#f59e0b' }}>
{state.totalQuestions}
</div>
</div>
<div
style={{
background: '#f3f4f6',
borderRadius: '12px',
padding: '16px',
}}
>
<div style={{ color: '#6b7280', fontSize: '14px', marginBottom: '4px' }}>Accuracy</div>
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#8b5cf6' }}>
{state.totalQuestions > 0
? Math.round((state.correctAnswers / state.totalQuestions) * 100)
: 0}
%
</div>
</div>
</div>
{/* Final Standings */}
<div
style={{
marginBottom: '32px',
textAlign: 'left',
}}
>
<h3
style={{
fontSize: '18px',
fontWeight: 'bold',
color: '#1f2937',
marginBottom: '12px',
}}
>
Final Standings
</h3>
{[
{ name: 'You', position: state.correctAnswers, icon: '👤' },
...state.aiRacers.map((racer) => ({
name: racer.name,
position: racer.position,
icon: racer.icon,
})),
]
.sort((a, b) => b.position - a.position)
.map((racer, index) => (
<div
key={racer.name}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px',
background: racer.name === 'You' ? '#eff6ff' : '#f9fafb',
borderRadius: '8px',
marginBottom: '8px',
border: racer.name === 'You' ? '2px solid #3b82f6' : 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div
style={{
fontSize: '24px',
fontWeight: 'bold',
color: '#9ca3af',
minWidth: '32px',
}}
>
#{index + 1}
</div>
<div style={{ fontSize: '20px' }}>{racer.icon}</div>
<div style={{ fontWeight: racer.name === 'You' ? 'bold' : 'normal' }}>
{racer.name}
</div>
</div>
<div style={{ fontSize: '18px', fontWeight: 'bold', color: '#6b7280' }}>
{Math.floor(racer.position)}
</div>
</div>
))}
</div>
{/* Buttons */}
<div
style={{
display: 'flex',
gap: '12px',
}}
>
<button
onClick={() => dispatch({ type: 'RESET_GAME' })}
style={{
flex: 1,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
padding: '16px 32px',
borderRadius: '12px',
fontSize: '18px',
fontWeight: 'bold',
border: 'none',
cursor: 'pointer',
transition: 'transform 0.2s',
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.4)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
}}
>
Race Again
</button>
</div>
</div>
</div>
)
}
function getOrdinalSuffix(num: number): string {
if (num === 1) return 'st'
if (num === 2) return 'nd'
if (num === 3) return 'rd'
return 'th'
}

View File

@@ -0,0 +1,249 @@
'use client'
import { memo } from 'react'
import type { Passenger, Station } from '../lib/gameTypes'
interface PassengerCardProps {
passenger: Passenger
originStation: Station | undefined
destinationStation: Station | undefined
}
export const PassengerCard = memo(function PassengerCard({
passenger,
originStation,
destinationStation,
}: PassengerCardProps) {
if (!destinationStation || !originStation) return null
// Vintage train station colors
const bgColor = passenger.isDelivered
? '#1a3a1a' // Dark green for delivered
: !passenger.isBoarded
? '#2a2419' // Dark brown/sepia for waiting
: passenger.isUrgent
? '#3a2419' // Dark red-brown for urgent
: '#1a2a3a' // Dark blue for aboard
const accentColor = passenger.isDelivered
? '#4ade80' // Green
: !passenger.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'
return (
<div
style={{
background: bgColor,
border: `2px solid ${borderColor}`,
borderRadius: '4px',
padding: '8px 10px',
minWidth: '220px',
maxWidth: '280px',
boxShadow:
passenger.isUrgent && !passenger.isDelivered && passenger.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
? 'urgentFlicker 1.5s ease-in-out infinite'
: 'none',
transition: 'all 0.3s ease',
}}
>
{/* Top row: Passenger info and status */}
<div
style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: '6px',
borderBottom: `1px solid ${accentColor}33`,
paddingBottom: '4px',
paddingRight: '42px', // Make room for points badge
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
flex: 1,
}}
>
<div style={{ fontSize: '20px', lineHeight: '1' }}>
{passenger.isDelivered ? '✅' : passenger.avatar}
</div>
<div
style={{
fontSize: '11px',
fontWeight: 'bold',
color: accentColor,
letterSpacing: '0.5px',
textTransform: 'uppercase',
}}
>
{passenger.name}
</div>
</div>
{/* Status indicator */}
<div
style={{
fontSize: '9px',
color: accentColor,
fontWeight: 'bold',
letterSpacing: '0.5px',
background: `${accentColor}22`,
padding: '2px 6px',
borderRadius: '2px',
border: `1px solid ${accentColor}66`,
whiteSpace: 'nowrap',
marginTop: '0',
}}
>
{passenger.isDelivered ? 'DLVRD' : passenger.isBoarded ? 'BOARD' : 'WAIT'}
</div>
</div>
{/* Route information */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '3px',
fontSize: '10px',
color: '#e8d4a0',
}}
>
{/* From station */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<span
style={{
color: accentColor,
fontSize: '8px',
fontWeight: 'bold',
width: '28px',
letterSpacing: '0.3px',
}}
>
FROM:
</span>
<span style={{ fontSize: '14px', lineHeight: '1' }}>{originStation.icon}</span>
<span
style={{
fontWeight: '600',
fontSize: '10px',
letterSpacing: '0.3px',
}}
>
{originStation.name}
</span>
</div>
{/* To station */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<span
style={{
color: accentColor,
fontSize: '8px',
fontWeight: 'bold',
width: '28px',
letterSpacing: '0.3px',
}}
>
TO:
</span>
<span style={{ fontSize: '14px', lineHeight: '1' }}>{destinationStation.icon}</span>
<span
style={{
fontWeight: '600',
fontSize: '10px',
letterSpacing: '0.3px',
}}
>
{destinationStation.name}
</span>
</div>
</div>
{/* Points badge */}
{!passenger.isDelivered && (
<div
style={{
position: 'absolute',
top: '6px',
right: '6px',
background: `${accentColor}33`,
border: `1px solid ${accentColor}`,
borderRadius: '2px',
padding: '2px 6px',
fontSize: '10px',
fontWeight: 'bold',
color: accentColor,
letterSpacing: '0.5px',
}}
>
{passenger.isUrgent ? '+20' : '+10'}
</div>
)}
{/* Urgent indicator */}
{passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded && (
<div
style={{
position: 'absolute',
left: '8px',
bottom: '6px',
fontSize: '10px',
animation: 'urgentBlink 0.8s ease-in-out infinite',
filter: 'drop-shadow(0 0 4px rgba(255, 107, 53, 0.8))',
}}
>
</div>
)}
<style>{`
@keyframes urgentFlicker {
0%, 100% {
box-shadow: 0 0 16px rgba(255, 107, 53, 0.5);
border-color: #ff6b35;
}
50% {
box-shadow: 0 0 24px rgba(255, 107, 53, 0.8);
border-color: #ffaa35;
}
}
@keyframes urgentBlink {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
`}</style>
</div>
)
})

View File

@@ -0,0 +1,180 @@
'use client'
import { animated, useSpring } from '@react-spring/web'
import { AbacusReact } from '@soroban/abacus-react'
interface PressureGaugeProps {
pressure: number // 0-150 PSI
}
export function PressureGauge({ pressure }: PressureGaugeProps) {
const maxPressure = 150
// Animate pressure value smoothly with spring physics
const spring = useSpring({
pressure,
config: {
tension: 120,
friction: 14,
clamp: false,
},
})
// Calculate needle angle - sweeps 180° from left to right
// 0 PSI = 180° (pointing left), 150 PSI = 0° (pointing right)
const angle = spring.pressure.to((p) => 180 - (p / maxPressure) * 180)
// Get pressure color (animated)
const color = spring.pressure.to((p) => {
if (p < 50) return '#ef4444' // Red (low)
if (p < 100) return '#f59e0b' // Orange (medium)
return '#10b981' // Green (high)
})
return (
<div
style={{
position: 'relative',
background: 'rgba(255, 255, 255, 0.95)',
padding: '16px',
borderRadius: '12px',
minWidth: '220px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)',
}}
>
{/* Title */}
<div
style={{
fontSize: '12px',
color: '#6b7280',
marginBottom: '8px',
fontWeight: 'bold',
textAlign: 'center',
}}
>
PRESSURE
</div>
{/* SVG Gauge */}
<svg
viewBox="-40 -20 280 170"
style={{
width: '100%',
height: 'auto',
marginBottom: '8px',
}}
>
{/* Background arc - semicircle from left to right (bottom half) */}
<path
d="M 20 100 A 80 80 0 0 1 180 100"
fill="none"
stroke="#e5e7eb"
strokeWidth="8"
strokeLinecap="round"
/>
{/* Tick marks */}
{[0, 50, 100, 150].map((psi, index) => {
// Angle from 180° (left) to 0° (right)
const tickAngle = 180 - (psi / maxPressure) * 180
const tickRad = (tickAngle * Math.PI) / 180
const x1 = 100 + Math.cos(tickRad) * 70
const y1 = 100 - Math.sin(tickRad) * 70 // Subtract for SVG coords
const x2 = 100 + Math.cos(tickRad) * 80
const y2 = 100 - Math.sin(tickRad) * 80 // Subtract for SVG coords
// Position for abacus label
const labelX = 100 + Math.cos(tickRad) * 112
const labelY = 100 - Math.sin(tickRad) * 112
return (
<g key={`tick-${index}`}>
<line
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="#6b7280"
strokeWidth="2"
strokeLinecap="round"
/>
<foreignObject x={labelX - 30} y={labelY - 25} width="60" height="100">
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
lineHeight: 0,
}}
>
<AbacusReact
value={psi}
columns={3}
interactive={false}
showNumbers={false}
hideInactiveBeads={false}
scaleFactor={0.6}
customStyles={{
columnPosts: { opacity: 0 },
}}
/>
</div>
</foreignObject>
</g>
)
})}
{/* Center pivot */}
<circle cx="100" cy="100" r="4" fill="#1f2937" />
{/* Needle - animated */}
<animated.line
x1="100"
y1="100"
x2={angle.to((a) => 100 + Math.cos((a * Math.PI) / 180) * 70)}
y2={angle.to((a) => 100 - Math.sin((a * Math.PI) / 180) * 70)}
stroke={color}
strokeWidth="3"
strokeLinecap="round"
style={{
filter: color.to((c) => `drop-shadow(0 2px 3px ${c})`),
}}
/>
</svg>
{/* Abacus readout */}
<div
style={{
textAlign: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
minHeight: '32px',
}}
>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
lineHeight: 0,
}}
>
<AbacusReact
value={Math.round(pressure)}
columns={3}
interactive={false}
showNumbers={false}
hideInactiveBeads={true}
scaleFactor={0.35}
customStyles={{
columnPosts: { opacity: 0 },
}}
/>
</div>
<span style={{ fontSize: '12px', color: '#6b7280', fontWeight: 'bold' }}>PSI</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,489 @@
'use client'
import { useEffect, useState } from 'react'
import { useGameMode } from '@/contexts/GameModeContext'
import { useUserProfile } from '@/contexts/UserProfileContext'
import { useComplementRace } from '../../context/ComplementRaceContext'
import { useSoundEffects } from '../../hooks/useSoundEffects'
import type { AIRacer } from '../../lib/gameTypes'
import { SpeechBubble } from '../AISystem/SpeechBubble'
interface CircularTrackProps {
playerProgress: number
playerLap: number
aiRacers: AIRacer[]
aiLaps: Map<string, number>
}
export function CircularTrack({ playerProgress, playerLap, aiRacers, aiLaps }: CircularTrackProps) {
const { state, dispatch } = useComplementRace()
const { players } = 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 ?? '👤'
const [dimensions, setDimensions] = useState({ width: 600, height: 400 })
// Update dimensions on mount and resize
useEffect(() => {
const updateDimensions = () => {
const vw = window.innerWidth
const vh = window.innerHeight
const isLandscape = vw > vh
if (isLandscape) {
// Landscape: wider track (emphasize horizontal straights)
const width = Math.min(vw * 0.75, 800)
const height = Math.min(vh * 0.5, 350)
setDimensions({ width, height })
} else {
// Portrait: taller track (emphasize vertical straights)
const width = Math.min(vw * 0.85, 350)
const height = Math.min(vh * 0.5, 550)
setDimensions({ width, height })
}
}
updateDimensions()
window.addEventListener('resize', updateDimensions)
return () => window.removeEventListener('resize', updateDimensions)
}, [])
const padding = 40
const trackWidth = dimensions.width - padding * 2
const trackHeight = dimensions.height - padding * 2
// For a rounded rectangle track, we have straight sections and curved ends
const straightLength = Math.max(trackWidth, trackHeight) - Math.min(trackWidth, trackHeight)
const radius = Math.min(trackWidth, trackHeight) / 2
const isHorizontal = trackWidth > trackHeight
// Calculate position on rounded rectangle track
const getCircularPosition = (progress: number) => {
const progressPerLap = 50
const normalizedProgress = (progress % progressPerLap) / progressPerLap
// Track perimeter consists of: 2 straights + 2 semicircles
const straightPerim = straightLength
const curvePerim = Math.PI * radius
const totalPerim = 2 * straightPerim + 2 * curvePerim
const distanceAlongTrack = normalizedProgress * totalPerim
const centerX = dimensions.width / 2
const centerY = dimensions.height / 2
let x: number, y: number, angle: number
if (isHorizontal) {
// Horizontal track: straight sections on top/bottom, curves on left/right
const topStraightEnd = straightPerim
const rightCurveEnd = topStraightEnd + curvePerim
const bottomStraightEnd = rightCurveEnd + straightPerim
const _leftCurveEnd = bottomStraightEnd + curvePerim
if (distanceAlongTrack < topStraightEnd) {
// Top straight (moving right)
const t = distanceAlongTrack / straightPerim
x = centerX - straightLength / 2 + t * straightLength
y = centerY - radius
angle = 90
} else if (distanceAlongTrack < rightCurveEnd) {
// Right curve
const curveProgress = (distanceAlongTrack - topStraightEnd) / curvePerim
const curveAngle = curveProgress * Math.PI - Math.PI / 2
x = centerX + straightLength / 2 + radius * Math.cos(curveAngle)
y = centerY + radius * Math.sin(curveAngle)
angle = curveProgress * 180 + 90
} else if (distanceAlongTrack < bottomStraightEnd) {
// Bottom straight (moving left)
const t = (distanceAlongTrack - rightCurveEnd) / straightPerim
x = centerX + straightLength / 2 - t * straightLength
y = centerY + radius
angle = 270
} else {
// Left curve
const curveProgress = (distanceAlongTrack - bottomStraightEnd) / curvePerim
const curveAngle = curveProgress * Math.PI + Math.PI / 2
x = centerX - straightLength / 2 + radius * Math.cos(curveAngle)
y = centerY + radius * Math.sin(curveAngle)
angle = curveProgress * 180 + 270
}
} else {
// Vertical track: straight sections on left/right, curves on top/bottom
const leftStraightEnd = straightPerim
const bottomCurveEnd = leftStraightEnd + curvePerim
const rightStraightEnd = bottomCurveEnd + straightPerim
const _topCurveEnd = rightStraightEnd + curvePerim
if (distanceAlongTrack < leftStraightEnd) {
// Left straight (moving down)
const t = distanceAlongTrack / straightPerim
x = centerX - radius
y = centerY - straightLength / 2 + t * straightLength
angle = 180
} else if (distanceAlongTrack < bottomCurveEnd) {
// Bottom curve
const curveProgress = (distanceAlongTrack - leftStraightEnd) / curvePerim
const curveAngle = curveProgress * Math.PI
x = centerX + radius * Math.cos(curveAngle)
y = centerY + straightLength / 2 + radius * Math.sin(curveAngle)
angle = curveProgress * 180 + 180
} else if (distanceAlongTrack < rightStraightEnd) {
// Right straight (moving up)
const t = (distanceAlongTrack - bottomCurveEnd) / straightPerim
x = centerX + radius
y = centerY + straightLength / 2 - t * straightLength
angle = 0
} else {
// Top curve
const curveProgress = (distanceAlongTrack - rightStraightEnd) / curvePerim
const curveAngle = curveProgress * Math.PI + Math.PI
x = centerX + radius * Math.cos(curveAngle)
y = centerY - straightLength / 2 + radius * Math.sin(curveAngle)
angle = curveProgress * 180
}
}
return { x, y, angle }
}
// Check for lap completions and show celebrations
useEffect(() => {
// Check player lap
const playerCurrentLap = Math.floor(playerProgress / 50)
if (playerCurrentLap > playerLap && !celebrationCooldown.has('player')) {
dispatch({ type: 'COMPLETE_LAP', racerId: 'player' })
// Play celebration sound (line 12801)
playSound('lap_celebration', 0.6)
setCelebrationCooldown((prev) => new Set(prev).add('player'))
setTimeout(() => {
setCelebrationCooldown((prev) => {
const next = new Set(prev)
next.delete('player')
return next
})
}, 2000)
}
// Check AI laps
aiRacers.forEach((racer) => {
const aiCurrentLap = Math.floor(racer.position / 50)
const aiPreviousLap = aiLaps.get(racer.id) || 0
if (aiCurrentLap > aiPreviousLap && !celebrationCooldown.has(racer.id)) {
dispatch({ type: 'COMPLETE_LAP', racerId: racer.id })
setCelebrationCooldown((prev) => new Set(prev).add(racer.id))
setTimeout(() => {
setCelebrationCooldown((prev) => {
const next = new Set(prev)
next.delete(racer.id)
return next
})
}, 2000)
}
})
}, [
playerProgress,
playerLap,
aiRacers,
aiLaps,
celebrationCooldown,
dispatch, // Play celebration sound (line 12801)
playSound,
])
const playerPos = getCircularPosition(playerProgress)
// Create rounded rectangle path with wider curves (banking effect)
const createRoundedRectPath = (radiusOffset: number, isOuter: boolean = false) => {
const centerX = dimensions.width / 2
const centerY = dimensions.height / 2
// Make curves wider by increasing radius more on outer edges
const curveWidthBonus = isOuter ? radiusOffset * 0.15 : radiusOffset * -0.1
const r = radius + radiusOffset + curveWidthBonus
if (isHorizontal) {
// Horizontal track - curved ends on left/right
const leftCenterX = centerX - straightLength / 2
const rightCenterX = centerX + straightLength / 2
const curveTopY = centerY - r
const curveBottomY = centerY + r
return `
M ${leftCenterX} ${curveTopY}
L ${rightCenterX} ${curveTopY}
A ${r} ${r} 0 0 1 ${rightCenterX} ${curveBottomY}
L ${leftCenterX} ${curveBottomY}
A ${r} ${r} 0 0 1 ${leftCenterX} ${curveTopY}
Z
`
} else {
// Vertical track - curved ends on top/bottom
const topCenterY = centerY - straightLength / 2
const bottomCenterY = centerY + straightLength / 2
const curveLeftX = centerX - r
const curveRightX = centerX + r
return `
M ${curveLeftX} ${topCenterY}
L ${curveLeftX} ${bottomCenterY}
A ${r} ${r} 0 0 0 ${curveRightX} ${bottomCenterY}
L ${curveRightX} ${topCenterY}
A ${r} ${r} 0 0 0 ${curveLeftX} ${topCenterY}
Z
`
}
}
return (
<div
data-component="circular-track"
style={{
position: 'relative',
width: `${dimensions.width}px`,
height: `${dimensions.height}px`,
margin: '0 auto',
}}
>
{/* SVG Track */}
<svg
data-component="track-svg"
width={dimensions.width}
height={dimensions.height}
style={{
position: 'absolute',
top: 0,
left: 0,
}}
>
{/* Infield grass */}
<path d={createRoundedRectPath(15, false)} fill="#7cb342" stroke="none" />
{/* Track background - reddish clay color */}
<path d={createRoundedRectPath(-10, true)} fill="#d97757" stroke="none" />
{/* Track outer edge - white boundary */}
<path d={createRoundedRectPath(-15, true)} fill="none" stroke="white" strokeWidth="3" />
{/* Track inner edge - white boundary */}
<path d={createRoundedRectPath(15, false)} fill="none" stroke="white" strokeWidth="3" />
{/* Lane markers - dashed white lines */}
{[-5, 0, 5].map((offset) => (
<path
key={offset}
d={createRoundedRectPath(offset, offset < 0)}
fill="none"
stroke="white"
strokeWidth="1.5"
strokeDasharray="8 8"
opacity="0.6"
/>
))}
{/* Start/Finish line - checkered flag pattern */}
{(() => {
const centerX = dimensions.width / 2
const centerY = dimensions.height / 2
const trackThickness = 35 // Track width from inner to outer edge
if (isHorizontal) {
// Horizontal track: vertical finish line crossing the top straight
const x = centerX
const yStart = centerY - radius - 18 // Outer edge
const squareSize = trackThickness / 6
const lineWidth = 12
return (
<g>
{/* Checkered pattern - vertical line */}
{[0, 1, 2, 3, 4, 5].map((i) => (
<rect
key={i}
x={x - lineWidth / 2}
y={yStart + squareSize * i}
width={lineWidth}
height={squareSize}
fill={i % 2 === 0 ? 'black' : 'white'}
/>
))}
</g>
)
} else {
// Vertical track: horizontal finish line crossing the left straight
const xStart = centerX - radius - 18 // Outer edge
const y = centerY
const squareSize = trackThickness / 6
const lineWidth = 12
return (
<g>
{/* Checkered pattern - horizontal line */}
{[0, 1, 2, 3, 4, 5].map((i) => (
<rect
key={i}
x={xStart + squareSize * i}
y={y - lineWidth / 2}
width={squareSize}
height={lineWidth}
fill={i % 2 === 0 ? 'black' : 'white'}
/>
))}
</g>
)
}
})()}
{/* Distance markers (quarter points) */}
{[0.25, 0.5, 0.75].map((fraction) => {
const pos = getCircularPosition(fraction * 50)
const markerLength = 12
const perpAngle = (pos.angle + 90) * (Math.PI / 180)
const x1 = pos.x - markerLength * Math.cos(perpAngle)
const y1 = pos.y - markerLength * Math.sin(perpAngle)
const x2 = pos.x + markerLength * Math.cos(perpAngle)
const y2 = pos.y + markerLength * Math.sin(perpAngle)
return (
<line
key={fraction}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="white"
strokeWidth="3"
strokeLinecap="round"
/>
)
})}
</svg>
{/* Player racer */}
<div
style={{
position: 'absolute',
left: `${playerPos.x}px`,
top: `${playerPos.y}px`,
transform: `translate(-50%, -50%) rotate(${playerPos.angle}deg)`,
fontSize: '32px',
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
zIndex: 10,
transition: 'left 0.3s ease-out, top 0.3s ease-out',
}}
>
{playerEmoji}
</div>
{/* AI racers */}
{aiRacers.map((racer, _index) => {
const aiPos = getCircularPosition(racer.position)
const activeBubble = state.activeSpeechBubbles.get(racer.id)
return (
<div
key={racer.id}
style={{
position: 'absolute',
left: `${aiPos.x}px`,
top: `${aiPos.y}px`,
transform: `translate(-50%, -50%) rotate(${aiPos.angle}deg)`,
fontSize: '28px',
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
zIndex: 5,
transition: 'left 0.2s linear, top 0.2s linear',
}}
>
{racer.icon}
{activeBubble && (
<div
style={{
transform: `rotate(${-aiPos.angle}deg)`, // Counter-rotate bubble
}}
>
<SpeechBubble
message={activeBubble}
onHide={() => dispatch({ type: 'CLEAR_AI_COMMENT', racerId: racer.id })}
/>
</div>
)}
</div>
)
})}
{/* Lap counter */}
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: 'rgba(255, 255, 255, 0.95)',
borderRadius: '50%',
width: '120px',
height: '120px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
border: '3px solid #3b82f6',
}}
>
<div
style={{
fontSize: '14px',
color: '#6b7280',
marginBottom: '4px',
fontWeight: 'bold',
}}
>
Lap
</div>
<div
style={{
fontSize: '36px',
fontWeight: 'bold',
color: '#3b82f6',
}}
>
{playerLap + 1}
</div>
<div
style={{
fontSize: '12px',
color: '#9ca3af',
marginTop: '4px',
}}
>
{Math.floor(((playerProgress % 50) / 50) * 100)}%
</div>
</div>
{/* Lap celebration */}
{celebrationCooldown.has('player') && (
<div
style={{
position: 'absolute',
top: '20px',
left: '50%',
transform: 'translateX(-50%)',
background: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
color: 'white',
padding: '12px 24px',
borderRadius: '12px',
fontSize: '18px',
fontWeight: 'bold',
boxShadow: '0 4px 20px rgba(251, 191, 36, 0.4)',
animation: 'bounce 0.5s ease',
zIndex: 100,
}}
>
🎉 Lap {playerLap + 1} Complete! 🎉
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,223 @@
'use client'
import { memo } from 'react'
import type { ComplementQuestion, Passenger, Station } from '../../lib/gameTypes'
import { AbacusTarget } from '../AbacusTarget'
import { PassengerCard } from '../PassengerCard'
import { PressureGauge } from '../PressureGauge'
interface RouteTheme {
emoji: string
name: string
}
interface GameHUDProps {
routeTheme: RouteTheme
currentRoute: number
periodName: string
timeRemaining: number
pressure: number
nonDeliveredPassengers: Passenger[]
stations: Station[]
currentQuestion: ComplementQuestion | null
currentInput: string
}
export const GameHUD = memo(
({
routeTheme,
currentRoute,
periodName,
timeRemaining,
pressure,
nonDeliveredPassengers,
stations,
currentQuestion,
currentInput,
}: GameHUDProps) => {
return (
<>
{/* Route and time of day indicator */}
<div
data-component="route-info"
style={{
position: 'absolute',
top: '10px',
left: '10px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
zIndex: 10,
}}
>
{/* Current Route */}
<div
style={{
background: 'rgba(0, 0, 0, 0.3)',
color: 'white',
padding: '8px 14px',
borderRadius: '8px',
fontSize: '16px',
fontWeight: 'bold',
backdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span style={{ fontSize: '20px' }}>{routeTheme.emoji}</span>
<div>
<div style={{ fontSize: '14px', opacity: 0.8 }}>Route {currentRoute}</div>
<div style={{ fontSize: '12px', opacity: 0.9 }}>{routeTheme.name}</div>
</div>
</div>
{/* Time of Day */}
<div
style={{
background: 'rgba(0, 0, 0, 0.3)',
color: 'white',
padding: '6px 12px',
borderRadius: '8px',
fontSize: '14px',
fontWeight: 'bold',
backdropFilter: 'blur(4px)',
}}
>
{periodName}
</div>
</div>
{/* Time remaining */}
<div
data-component="time-remaining"
style={{
position: 'absolute',
top: '10px',
right: '10px',
background: 'rgba(0, 0, 0, 0.3)',
color: 'white',
padding: '6px 12px',
borderRadius: '8px',
fontSize: '18px',
fontWeight: 'bold',
backdropFilter: 'blur(4px)',
zIndex: 10,
}}
>
{timeRemaining}s
</div>
{/* Pressure gauge */}
<div
data-component="pressure-gauge-container"
style={{
position: 'fixed',
bottom: '20px',
left: '20px',
zIndex: 1000,
width: '120px',
}}
>
<PressureGauge pressure={pressure} />
</div>
{/* Passenger cards - show all non-delivered passengers */}
{nonDeliveredPassengers.length > 0 && (
<div
data-component="passenger-list"
style={{
position: 'fixed',
bottom: '20px',
right: '20px',
display: 'flex',
flexDirection: 'column-reverse',
gap: '8px',
zIndex: 1000,
maxHeight: 'calc(100vh - 40px)',
overflowY: 'auto',
}}
>
{nonDeliveredPassengers.map((passenger) => (
<PassengerCard
key={passenger.id}
passenger={passenger}
originStation={stations.find((s) => s.id === passenger.originStationId)}
destinationStation={stations.find((s) => s.id === passenger.destinationStationId)}
/>
))}
</div>
)}
{/* Question Display - centered at bottom, equation-focused */}
{currentQuestion && (
<div
data-component="sprint-question-display"
style={{
position: 'fixed',
bottom: '20px',
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(255, 255, 255, 0.98)',
borderRadius: '24px',
padding: '28px 50px',
boxShadow: '0 16px 40px rgba(0, 0, 0, 0.5), 0 0 0 5px rgba(59, 130, 246, 0.4)',
backdropFilter: 'blur(12px)',
border: '4px solid rgba(255, 255, 255, 0.95)',
zIndex: 1000,
}}
>
{/* Complement equation as main focus */}
<div
data-element="sprint-question-equation"
style={{
fontSize: '96px',
fontWeight: 'bold',
color: '#1f2937',
lineHeight: '1.1',
display: 'flex',
alignItems: 'center',
gap: '20px',
justifyContent: 'center',
}}
>
<span
style={{
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
color: 'white',
padding: '12px 32px',
borderRadius: '16px',
minWidth: '140px',
display: 'inline-block',
textShadow: '0 3px 10px rgba(0, 0, 0, 0.3)',
}}
>
{currentInput || '?'}
</span>
<span style={{ color: '#6b7280' }}>+</span>
{currentQuestion.showAsAbacus ? (
<div
style={{
transform: 'scale(2.4) translateY(8%)',
transformOrigin: 'center center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AbacusTarget number={currentQuestion.number} />
</div>
) : (
<span>{currentQuestion.number}</span>
)}
<span style={{ color: '#6b7280' }}>=</span>
<span style={{ color: '#10b981' }}>{currentQuestion.targetSum}</span>
</div>
</div>
)}
</>
)
}
)
GameHUD.displayName = 'GameHUD'

View File

@@ -0,0 +1,172 @@
'use client'
import { useGameMode } from '@/contexts/GameModeContext'
import { useUserProfile } from '@/contexts/UserProfileContext'
import { useComplementRace } from '../../context/ComplementRaceContext'
import type { AIRacer } from '../../lib/gameTypes'
import { SpeechBubble } from '../AISystem/SpeechBubble'
interface LinearTrackProps {
playerProgress: number
aiRacers: AIRacer[]
raceGoal: number
showFinishLine?: boolean
}
export function LinearTrack({
playerProgress,
aiRacers,
raceGoal,
showFinishLine = true,
}: LinearTrackProps) {
const { state, dispatch } = useComplementRace()
const { players } = useGameMode()
const { profile: _profile } = useUserProfile()
// Get the first active player's emoji
const activePlayers = Array.from(players.values()).filter((p) => p.id)
const firstActivePlayer = activePlayers[0]
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
// Position calculation: leftPercent = Math.min(98, (progress / raceGoal) * 96 + 2)
// 2% minimum (start), 98% maximum (near finish), 96% range for race
const getPosition = (progress: number) => {
return Math.min(98, (progress / raceGoal) * 96 + 2)
}
const playerPosition = getPosition(playerProgress)
return (
<div
data-component="linear-track"
style={{
position: 'relative',
width: '100%',
height: '200px',
background:
'linear-gradient(to bottom, #87ceeb 0%, #e0f2fe 50%, #90ee90 50%, #d4f1d4 100%)',
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
marginTop: '20px',
}}
>
{/* Track lines */}
<div
style={{
position: 'absolute',
top: '50%',
left: 0,
right: 0,
height: '2px',
background: 'rgba(0, 0, 0, 0.1)',
transform: 'translateY(-50%)',
}}
/>
<div
style={{
position: 'absolute',
top: '40%',
left: 0,
right: 0,
height: '1px',
background: 'rgba(0, 0, 0, 0.05)',
transform: 'translateY(-50%)',
}}
/>
<div
style={{
position: 'absolute',
top: '60%',
left: 0,
right: 0,
height: '1px',
background: 'rgba(0, 0, 0, 0.05)',
transform: 'translateY(-50%)',
}}
/>
{/* Finish line */}
{showFinishLine && (
<div
style={{
position: 'absolute',
right: '2%',
top: 0,
bottom: 0,
width: '4px',
background:
'repeating-linear-gradient(0deg, black 0px, black 10px, white 10px, white 20px)',
boxShadow: '0 0 10px rgba(0, 0, 0, 0.3)',
}}
/>
)}
{/* Player racer */}
<div
style={{
position: 'absolute',
left: `${playerPosition}%`,
top: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '32px',
transition: 'left 0.3s ease-out',
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
zIndex: 10,
}}
>
{playerEmoji}
</div>
{/* AI racers */}
{aiRacers.map((racer, index) => {
const aiPosition = getPosition(racer.position)
const activeBubble = state.activeSpeechBubbles.get(racer.id)
return (
<div
key={racer.id}
style={{
position: 'absolute',
left: `${aiPosition}%`,
top: `${35 + index * 15}%`,
transform: 'translate(-50%, -50%)',
fontSize: '28px',
transition: 'left 0.2s linear',
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
zIndex: 5,
}}
>
{racer.icon}
{activeBubble && (
<SpeechBubble
message={activeBubble}
onHide={() => dispatch({ type: 'CLEAR_AI_COMMENT', racerId: racer.id })}
/>
)}
</div>
)
})}
{/* Progress indicator */}
<div
style={{
position: 'absolute',
bottom: '10px',
left: '10px',
background: 'rgba(255, 255, 255, 0.9)',
padding: '6px 12px',
borderRadius: '8px',
fontSize: '14px',
fontWeight: 'bold',
color: '#1f2937',
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.1)',
}}
>
{playerProgress} / {raceGoal}
</div>
</div>
)
}

View File

@@ -0,0 +1,204 @@
'use client'
import { memo } from 'react'
import type { Passenger, Station } from '../../lib/gameTypes'
import type { Landmark } from '../../lib/landmarks'
interface RailroadTrackPathProps {
tiesAndRails: {
ties: Array<{ x1: number; y1: number; x2: number; y2: number }>
leftRailPath: string
rightRailPath: string
} | null
referencePath: string
pathRef: React.RefObject<SVGPathElement>
landmarkPositions: Array<{ x: number; y: number }>
landmarks: Landmark[]
stationPositions: Array<{ x: number; y: number }>
stations: Station[]
passengers: Passenger[]
boardingAnimations: Map<string, unknown>
disembarkingAnimations: Map<string, unknown>
}
export const RailroadTrackPath = memo(
({
tiesAndRails,
referencePath,
pathRef,
landmarkPositions,
landmarks,
stationPositions,
stations,
passengers,
boardingAnimations,
disembarkingAnimations,
}: RailroadTrackPathProps) => {
return (
<>
{/* Railroad ties */}
{tiesAndRails?.ties.map((tie, index) => (
<line
key={`tie-${index}`}
x1={tie.x1}
y1={tie.y1}
x2={tie.x2}
y2={tie.y2}
stroke="#654321"
strokeWidth="5"
strokeLinecap="round"
opacity="0.8"
/>
))}
{/* Left rail */}
{tiesAndRails?.leftRailPath && (
<path
d={tiesAndRails.leftRailPath}
fill="none"
stroke="#C0C0C0"
strokeWidth="5"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{/* Right rail */}
{tiesAndRails?.rightRailPath && (
<path
d={tiesAndRails.rightRailPath}
fill="none"
stroke="#C0C0C0"
strokeWidth="5"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{/* Reference path (invisible, used for positioning) */}
<path ref={pathRef} d={referencePath} fill="none" stroke="transparent" strokeWidth="2" />
{/* Landmarks - background scenery */}
{landmarkPositions.map((pos, index) => (
<text
key={`landmark-${index}`}
x={pos.x}
y={pos.y}
textAnchor="middle"
style={{
fontSize: `${(landmarks[index]?.size || 24) * 2.0}px`,
pointerEvents: 'none',
opacity: 0.7,
filter: 'drop-shadow(0 2px 3px rgba(0, 0, 0, 0.2))',
}}
>
{landmarks[index]?.emoji}
</text>
))}
{/* Station markers */}
{stationPositions.map((pos, index) => {
const station = stations[index]
// Find passengers waiting at this station (exclude currently boarding)
const waitingPassengers = passengers.filter(
(p) =>
p.originStationId === station?.id &&
!p.isBoarded &&
!p.isDelivered &&
!boardingAnimations.has(p.id)
)
// Find passengers delivered at this station (exclude currently disembarking)
const deliveredPassengers = passengers.filter(
(p) =>
p.destinationStationId === station?.id &&
p.isDelivered &&
!disembarkingAnimations.has(p.id)
)
return (
<g key={`station-${index}`}>
{/* Station platform */}
<circle
cx={pos.x}
cy={pos.y}
r="18"
fill="#8B4513"
stroke="#654321"
strokeWidth="4"
/>
{/* Station icon */}
<text
x={pos.x}
y={pos.y - 40}
textAnchor="middle"
fontSize="48"
style={{ pointerEvents: 'none' }}
>
{station?.icon}
</text>
{/* Station name */}
<text
x={pos.x}
y={pos.y + 50}
textAnchor="middle"
fontSize="20"
fill="#1f2937"
stroke="#f59e0b"
strokeWidth="0.5"
style={{
fontWeight: 900,
pointerEvents: 'none',
fontFamily: '"Comic Sans MS", "Chalkboard SE", "Bradley Hand", cursive',
textShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
letterSpacing: '0.5px',
paintOrder: 'stroke fill',
}}
>
{station?.name}
</text>
{/* Waiting passengers at this station */}
{waitingPassengers.map((passenger, pIndex) => (
<text
key={`waiting-${passenger.id}`}
x={pos.x + (pIndex - waitingPassengers.length / 2 + 0.5) * 28}
y={pos.y - 30}
textAnchor="middle"
style={{
fontSize: '55px',
pointerEvents: 'none',
filter: passenger.isUrgent
? 'drop-shadow(0 0 8px rgba(245, 158, 11, 0.8))'
: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
}}
>
{passenger.avatar}
</text>
))}
{/* Delivered passengers at this station (celebrating) */}
{deliveredPassengers.map((passenger, pIndex) => (
<text
key={`delivered-${passenger.id}`}
x={pos.x + (pIndex - deliveredPassengers.length / 2 + 0.5) * 28}
y={pos.y - 30}
textAnchor="middle"
style={{
fontSize: '55px',
pointerEvents: 'none',
filter: 'drop-shadow(0 0 12px rgba(16, 185, 129, 0.8))',
animation: 'celebrateDelivery 2s ease-out forwards',
}}
>
{passenger.avatar}
</text>
))}
</g>
)
})}
</>
)
}
)
RailroadTrackPath.displayName = 'RailroadTrackPath'

View File

@@ -0,0 +1,317 @@
'use client'
import { animated, useSpring } from '@react-spring/web'
import { memo, useMemo, useRef, useState } from 'react'
import { useGameMode } from '@/contexts/GameModeContext'
import { useUserProfile } from '@/contexts/UserProfileContext'
import { useComplementRace } from '../../context/ComplementRaceContext'
import {
type BoardingAnimation,
type DisembarkingAnimation,
usePassengerAnimations,
} from '../../hooks/usePassengerAnimations'
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'
import { RailroadTrackPath } from './RailroadTrackPath'
import { TrainAndCars } from './TrainAndCars'
import { TrainTerrainBackground } from './TrainTerrainBackground'
const BoardingPassengerAnimation = memo(({ animation }: { animation: BoardingAnimation }) => {
const spring = useSpring({
from: { x: animation.fromX, y: animation.fromY, opacity: 1 },
to: { x: animation.toX, y: animation.toY, opacity: 1 },
config: { tension: 120, friction: 14 },
})
return (
<animated.text
x={spring.x}
y={spring.y}
textAnchor="middle"
opacity={spring.opacity}
style={{
fontSize: '55px',
pointerEvents: 'none',
filter: animation.passenger.isUrgent
? 'drop-shadow(0 0 8px rgba(245, 158, 11, 0.8))'
: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
}}
>
{animation.passenger.avatar}
</animated.text>
)
})
BoardingPassengerAnimation.displayName = 'BoardingPassengerAnimation'
const DisembarkingPassengerAnimation = memo(
({ animation }: { animation: DisembarkingAnimation }) => {
const spring = useSpring({
from: { x: animation.fromX, y: animation.fromY, opacity: 1 },
to: { x: animation.toX, y: animation.toY, opacity: 1 },
config: { tension: 120, friction: 14 },
})
return (
<animated.text
x={spring.x}
y={spring.y}
textAnchor="middle"
opacity={spring.opacity}
style={{
fontSize: '55px',
pointerEvents: 'none',
filter: 'drop-shadow(0 0 12px rgba(16, 185, 129, 0.8))',
}}
>
{animation.passenger.avatar}
</animated.text>
)
}
)
DisembarkingPassengerAnimation.displayName = 'DisembarkingPassengerAnimation'
interface SteamTrainJourneyProps {
momentum: number
trainPosition: number
pressure: number
elapsedTime: number
currentQuestion: { number: number; targetSum: number; correctAnswer: number } | null
currentInput: string
}
export function SteamTrainJourney({
momentum,
trainPosition,
pressure,
elapsedTime,
currentQuestion,
currentInput,
}: SteamTrainJourneyProps) {
const { state } = useComplementRace()
const { getSkyGradient, getTimeOfDayPeriod } = useSteamJourney()
const _skyGradient = getSkyGradient()
const period = getTimeOfDayPeriod()
const { players } = useGameMode()
const { profile: _profile } = useUserProfile()
// Get the first active player's emoji
const activePlayers = Array.from(players.values()).filter((p) => p.id)
const firstActivePlayer = activePlayers[0]
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
const svgRef = useRef<SVGSVGElement>(null)
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])
const carSpacing = 7 // Distance between cars (in % of track)
// Train transforms (extracted to hook)
const { trainTransform, trainCars, locomotiveOpacity } = useTrainTransforms({
trainPosition,
trackGenerator,
pathRef,
maxCars,
carSpacing,
})
// Track management (extracted to hook)
const {
trackData,
tiesAndRails,
stationPositions,
landmarks,
landmarkPositions,
displayPassengers,
} = useTrackManagement({
currentRoute: state.currentRoute,
trainPosition,
trackGenerator,
pathRef,
stations: state.stations,
passengers: state.passengers,
maxCars,
carSpacing,
})
// Passenger animations (extracted to hook)
const { boardingAnimations, disembarkingAnimations } = usePassengerAnimations({
passengers: state.passengers,
stations: state.stations,
stationPositions,
trainPosition,
trackGenerator,
pathRef,
})
// Time remaining (60 seconds total)
const timeRemaining = Math.max(0, 60 - Math.floor(elapsedTime / 1000))
// Period names for display
const periodNames = ['Dawn', 'Morning', 'Midday', 'Afternoon', 'Dusk', 'Night']
// Get current route theme
const routeTheme = getRouteTheme(state.currentRoute)
// Memoize filtered passenger lists to avoid recalculating on every render
const boardedPassengers = useMemo(
() => displayPassengers.filter((p) => p.isBoarded && !p.isDelivered),
[displayPassengers]
)
const nonDeliveredPassengers = useMemo(
() => displayPassengers.filter((p) => !p.isDelivered),
[displayPassengers]
)
// Memoize ground texture circles to avoid recreating on every render
const groundTextureCircles = useMemo(
() =>
Array.from({ length: 30 }).map((_, i) => ({
key: `ground-texture-${i}`,
cx: -30 + i * 28 + (i % 3) * 10,
cy: 140 + (i % 5) * 60,
r: 2 + (i % 3),
})),
[]
)
if (!trackData) return null
return (
<div
data-component="steam-train-journey"
style={{
position: 'relative',
width: '100%',
height: '100%',
background: 'transparent',
overflow: 'visible',
display: 'flex',
alignItems: 'center',
justifyContent: 'stretch',
}}
>
{/* Game HUD - overlays and UI elements */}
<GameHUD
routeTheme={routeTheme}
currentRoute={state.currentRoute}
periodName={periodNames[period]}
timeRemaining={timeRemaining}
pressure={pressure}
nonDeliveredPassengers={nonDeliveredPassengers}
stations={state.stations}
currentQuestion={currentQuestion}
currentInput={currentInput}
/>
{/* Railroad track SVG */}
<svg
data-component="railroad-track"
ref={svgRef}
viewBox="-50 -50 900 700"
style={{
width: '100%',
height: 'auto',
aspectRatio: '800 / 600',
overflow: 'visible',
}}
>
{/* Terrain background - ground, mountains, and tunnels */}
<TrainTerrainBackground
ballastPath={trackData.ballastPath}
groundTextureCircles={groundTextureCircles}
/>
{/* Railroad track, landmarks, and stations */}
<RailroadTrackPath
tiesAndRails={tiesAndRails}
referencePath={trackData.referencePath}
pathRef={pathRef}
landmarkPositions={landmarkPositions}
landmarks={landmarks}
stationPositions={stationPositions}
stations={state.stations}
passengers={displayPassengers}
boardingAnimations={boardingAnimations}
disembarkingAnimations={disembarkingAnimations}
/>
{/* Train, cars, and passenger animations */}
<TrainAndCars
boardingAnimations={boardingAnimations}
disembarkingAnimations={disembarkingAnimations}
BoardingPassengerAnimation={BoardingPassengerAnimation}
DisembarkingPassengerAnimation={DisembarkingPassengerAnimation}
trainCars={trainCars}
boardedPassengers={boardedPassengers}
trainTransform={trainTransform}
locomotiveOpacity={locomotiveOpacity}
playerEmoji={playerEmoji}
momentum={momentum}
/>
</svg>
{/* CSS animations */}
<style>{`
@keyframes steamPuffSVG {
0% {
opacity: 0.8;
transform: scale(0.5) translate(0, 0);
}
50% {
opacity: 0.4;
transform: scale(1.5) translate(15px, -30px);
}
100% {
opacity: 0;
transform: scale(2) translate(25px, -60px);
}
}
@keyframes coalFallingSVG {
0% {
opacity: 1;
transform: translate(0, 0) scale(1);
}
50% {
opacity: 0.7;
transform: translate(5px, 15px) scale(0.8);
}
100% {
opacity: 0;
transform: translate(8px, 30px) scale(0.5);
}
}
@keyframes celebrateDelivery {
0% {
opacity: 1;
transform: scale(1) translateY(0);
}
20% {
transform: scale(1.3) translateY(-10px);
}
40% {
transform: scale(1.2) translateY(-5px);
}
100% {
opacity: 0;
transform: scale(0.8) translateY(-20px);
}
}
`}</style>
</div>
)
}

View File

@@ -0,0 +1,196 @@
'use client'
import { memo } from 'react'
import type { BoardingAnimation, DisembarkingAnimation } from '../../hooks/usePassengerAnimations'
import type { Passenger } from '../../lib/gameTypes'
interface TrainCarTransform {
x: number
y: number
rotation: number
position: number
opacity: number
}
interface TrainTransform {
x: number
y: number
rotation: number
}
interface TrainAndCarsProps {
boardingAnimations: Map<string, BoardingAnimation>
disembarkingAnimations: Map<string, DisembarkingAnimation>
BoardingPassengerAnimation: React.ComponentType<{ animation: BoardingAnimation }>
DisembarkingPassengerAnimation: React.ComponentType<{ animation: DisembarkingAnimation }>
trainCars: TrainCarTransform[]
boardedPassengers: Passenger[]
trainTransform: TrainTransform
locomotiveOpacity: number
playerEmoji: string
momentum: number
}
export const TrainAndCars = memo(
({
boardingAnimations,
disembarkingAnimations,
BoardingPassengerAnimation,
DisembarkingPassengerAnimation,
trainCars,
boardedPassengers,
trainTransform,
locomotiveOpacity,
playerEmoji,
momentum,
}: TrainAndCarsProps) => {
return (
<>
{/* Boarding animations - passengers moving from station to train car */}
{Array.from(boardingAnimations.values()).map((animation) => (
<BoardingPassengerAnimation
key={`boarding-${animation.passenger.id}`}
animation={animation}
/>
))}
{/* Disembarking animations - passengers moving from train car to station */}
{Array.from(disembarkingAnimations.values()).map((animation) => (
<DisembarkingPassengerAnimation
key={`disembarking-${animation.passenger.id}`}
animation={animation}
/>
))}
{/* Train cars - render in reverse order so locomotive appears on top */}
{trainCars.map((carTransform, carIndex) => {
// Assign passenger to this car (if one exists for this car index)
const passenger = boardedPassengers[carIndex]
return (
<g
key={`train-car-${carIndex}`}
data-component="train-car"
transform={`translate(${carTransform.x}, ${carTransform.y}) rotate(${carTransform.rotation}) scale(-1, 1)`}
opacity={carTransform.opacity}
style={{
transition: 'opacity 0.5s ease-in',
}}
>
{/* Train car */}
<text
data-element="train-car-body"
x={0}
y={0}
textAnchor="middle"
style={{
fontSize: '65px',
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
pointerEvents: 'none',
}}
>
🚃
</text>
{/* Passenger inside this car (hide if currently boarding) */}
{passenger && !boardingAnimations.has(passenger.id) && (
<text
data-element="car-passenger"
x={0}
y={0}
textAnchor="middle"
style={{
fontSize: '42px',
filter: passenger.isUrgent
? 'drop-shadow(0 0 6px rgba(245, 158, 11, 0.8))'
: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
pointerEvents: 'none',
}}
>
{passenger.avatar}
</text>
)}
</g>
)
})}
{/* Locomotive - rendered last so it appears on top */}
<g
data-component="locomotive-group"
transform={`translate(${trainTransform.x}, ${trainTransform.y}) rotate(${trainTransform.rotation}) scale(-1, 1)`}
opacity={locomotiveOpacity}
style={{
transition: 'opacity 0.5s ease-in',
}}
>
{/* Train locomotive */}
<text
data-element="train-locomotive"
x={0}
y={0}
textAnchor="middle"
style={{
fontSize: '100px',
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
pointerEvents: 'none',
}}
>
🚂
</text>
{/* Player engineer - layered over the train */}
<text
data-element="player-engineer"
x={45}
y={0}
textAnchor="middle"
style={{
fontSize: '70px',
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
pointerEvents: 'none',
}}
>
{playerEmoji}
</text>
{/* Steam puffs - positioned at smokestack, layered over train */}
{momentum > 10 &&
[0, 0.6, 1.2].map((delay, i) => (
<circle
key={`steam-${i}`}
cx={-35}
cy={-35}
r="10"
fill="rgba(255, 255, 255, 0.6)"
style={{
filter: 'blur(4px)',
animation: `steamPuffSVG 2s ease-out infinite`,
animationDelay: `${delay}s`,
pointerEvents: 'none',
}}
/>
))}
{/* Coal particles - animated when shoveling */}
{momentum > 60 &&
[0, 0.3, 0.6].map((delay, i) => (
<circle
key={`coal-${i}`}
cx={25}
cy={0}
r="3"
fill="#2c2c2c"
style={{
animation: 'coalFallingSVG 1.2s ease-out infinite',
animationDelay: `${delay}s`,
pointerEvents: 'none',
}}
/>
))}
</g>
</>
)
}
)
TrainAndCars.displayName = 'TrainAndCars'

View File

@@ -0,0 +1,144 @@
'use client'
import { memo } from 'react'
interface TrainTerrainBackgroundProps {
ballastPath: string
groundTextureCircles: Array<{
key: string
cx: number
cy: number
r: number
}>
}
export const TrainTerrainBackground = memo(
({ ballastPath, groundTextureCircles }: TrainTerrainBackgroundProps) => {
return (
<>
{/* Gradient definitions for mountain shading and ground */}
<defs>
<linearGradient id="mountainGradientLeft" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style={{ stopColor: '#a0a0a0', stopOpacity: 0.8 }} />
<stop offset="50%" style={{ stopColor: '#7a7a7a', stopOpacity: 0.6 }} />
<stop offset="100%" style={{ stopColor: '#5a5a5a', stopOpacity: 0.4 }} />
</linearGradient>
<linearGradient id="mountainGradientRight" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style={{ stopColor: '#5a5a5a', stopOpacity: 0.4 }} />
<stop offset="50%" style={{ stopColor: '#7a7a7a', stopOpacity: 0.6 }} />
<stop offset="100%" style={{ stopColor: '#a0a0a0', stopOpacity: 0.8 }} />
</linearGradient>
<linearGradient id="groundGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style={{ stopColor: '#6a8759', stopOpacity: 0.3 }} />
<stop offset="100%" style={{ stopColor: '#8B7355', stopOpacity: 0 }} />
</linearGradient>
</defs>
{/* Ground layer - extends full width and height to cover entire track area */}
<rect x="-50" y="120" width="900" height="530" fill="#8B7355" />
{/* Ground surface gradient for depth */}
<rect x="-50" y="120" width="900" height="60" fill="url(#groundGradient)" />
{/* Ground texture - scattered rocks/pebbles */}
{groundTextureCircles.map((circle) => (
<circle
key={circle.key}
cx={circle.cx}
cy={circle.cy}
r={circle.r}
fill="#654321"
opacity={0.3}
/>
))}
{/* Railroad ballast (gravel bed) */}
<path d={ballastPath} fill="none" stroke="#8B7355" strokeWidth="40" strokeLinecap="round" />
{/* Left mountain and tunnel */}
<g data-element="left-tunnel">
{/* Mountain base - extends from left edge */}
<rect x="-50" y="200" width="120" height="450" fill="#6b7280" />
{/* Mountain peak - triangular slope */}
<path d="M -50 200 L 70 200 L 20 -50 L -50 100 Z" fill="#8b8b8b" />
{/* Mountain ridge shading */}
<path d="M -50 200 L 70 200 L 20 -50 Z" fill="url(#mountainGradientLeft)" />
{/* Tunnel depth/interior (dark entrance) */}
<ellipse cx="20" cy="300" rx="50" ry="55" fill="#0a0a0a" />
{/* Tunnel arch opening */}
<path
d="M 20 355 L -50 355 L -50 245 Q -50 235, 20 235 Q 70 235, 70 245 L 70 355 Z"
fill="#1a1a1a"
stroke="#4a4a4a"
strokeWidth="3"
/>
{/* Tunnel arch rim (stone bricks) */}
<path
d="M -50 245 Q -50 235, 20 235 Q 70 235, 70 245"
fill="none"
stroke="#8b7355"
strokeWidth="8"
strokeLinecap="round"
/>
{/* Stone brick texture around arch */}
<path
d="M -50 245 Q -50 235, 20 235 Q 70 235, 70 245"
fill="none"
stroke="#654321"
strokeWidth="2"
strokeDasharray="15,10"
/>
</g>
{/* Right mountain and tunnel */}
<g data-element="right-tunnel">
{/* Mountain base - extends to right edge */}
<rect x="680" y="200" width="170" height="450" fill="#6b7280" />
{/* Mountain peak - triangular slope */}
<path d="M 730 200 L 850 200 L 850 100 L 780 -50 Z" fill="#8b8b8b" />
{/* Mountain ridge shading */}
<path d="M 730 200 L 850 150 L 780 -50 Z" fill="url(#mountainGradientRight)" />
{/* Tunnel depth/interior (dark entrance) */}
<ellipse cx="780" cy="300" rx="50" ry="55" fill="#0a0a0a" />
{/* Tunnel arch opening */}
<path
d="M 780 355 L 730 355 L 730 245 Q 730 235, 780 235 Q 850 235, 850 245 L 850 355 Z"
fill="#1a1a1a"
stroke="#4a4a4a"
strokeWidth="3"
/>
{/* Tunnel arch rim (stone bricks) */}
<path
d="M 730 245 Q 730 235, 780 235 Q 850 235, 850 245"
fill="none"
stroke="#8b7355"
strokeWidth="8"
strokeLinecap="round"
/>
{/* Stone brick texture around arch */}
<path
d="M 730 245 Q 730 235, 780 235 Q 850 235, 850 245"
fill="none"
stroke="#654321"
strokeWidth="2"
strokeDasharray="15,10"
/>
</g>
</>
)
}
)
TrainTerrainBackground.displayName = 'TrainTerrainBackground'

View File

@@ -0,0 +1,165 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, test, vi } from 'vitest'
import type { Passenger, Station } from '../../../lib/gameTypes'
import { GameHUD } from '../GameHUD'
// Mock child components
vi.mock('../../PassengerCard', () => ({
PassengerCard: ({ passenger }: { passenger: Passenger }) => (
<div data-testid="passenger-card">{passenger.avatar}</div>
),
}))
vi.mock('../../PressureGauge', () => ({
PressureGauge: ({ pressure }: { pressure: number }) => (
<div data-testid="pressure-gauge">{pressure}</div>
),
}))
describe('GameHUD', () => {
const mockRouteTheme = {
emoji: '🚂',
name: 'Mountain Pass',
}
const mockStations: Station[] = [
{ id: 'station-1', name: 'Station 1', position: 20, icon: '🏭' },
{ id: 'station-2', name: 'Station 2', position: 60, icon: '🏛️' },
]
const mockPassenger: Passenger = {
id: 'passenger-1',
avatar: '👨',
originStationId: 'station-1',
destinationStationId: 'station-2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
}
const defaultProps = {
routeTheme: mockRouteTheme,
currentRoute: 1,
periodName: '🌅 Dawn',
timeRemaining: 45,
pressure: 75,
nonDeliveredPassengers: [],
stations: mockStations,
currentQuestion: {
number: 3,
targetSum: 10,
correctAnswer: 7,
},
currentInput: '7',
}
test('renders route information', () => {
render(<GameHUD {...defaultProps} />)
expect(screen.getByText('Route 1')).toBeInTheDocument()
expect(screen.getByText('Mountain Pass')).toBeInTheDocument()
expect(screen.getByText('🚂')).toBeInTheDocument()
})
test('renders time of day period', () => {
render(<GameHUD {...defaultProps} />)
expect(screen.getByText('🌅 Dawn')).toBeInTheDocument()
})
test('renders time remaining', () => {
render(<GameHUD {...defaultProps} />)
expect(screen.getByText(/45s/)).toBeInTheDocument()
})
test('renders pressure gauge', () => {
render(<GameHUD {...defaultProps} />)
expect(screen.getByTestId('pressure-gauge')).toBeInTheDocument()
expect(screen.getByText('75')).toBeInTheDocument()
})
test('renders passenger list when passengers exist', () => {
render(<GameHUD {...defaultProps} nonDeliveredPassengers={[mockPassenger]} />)
expect(screen.getByTestId('passenger-card')).toBeInTheDocument()
expect(screen.getByText('👨')).toBeInTheDocument()
})
test('does not render passenger list when empty', () => {
render(<GameHUD {...defaultProps} nonDeliveredPassengers={[]} />)
expect(screen.queryByTestId('passenger-card')).not.toBeInTheDocument()
})
test('renders current question when provided', () => {
render(<GameHUD {...defaultProps} />)
expect(screen.getByText('7')).toBeInTheDocument() // currentInput
expect(screen.getByText('3')).toBeInTheDocument() // question.number
expect(screen.getByText('10')).toBeInTheDocument() // targetSum
expect(screen.getByText('+')).toBeInTheDocument()
expect(screen.getByText('=')).toBeInTheDocument()
})
test('shows question mark when no input', () => {
render(<GameHUD {...defaultProps} currentInput="" />)
expect(screen.getByText('?')).toBeInTheDocument()
})
test('does not render question display when currentQuestion is null', () => {
render(<GameHUD {...defaultProps} currentQuestion={null} />)
expect(screen.queryByText('+')).not.toBeInTheDocument()
expect(screen.queryByText('=')).not.toBeInTheDocument()
})
test('renders multiple passengers', () => {
const passengers = [
mockPassenger,
{ ...mockPassenger, id: 'passenger-2', avatar: '👩' },
{ ...mockPassenger, id: 'passenger-3', avatar: '👧' },
]
render(<GameHUD {...defaultProps} nonDeliveredPassengers={passengers} />)
expect(screen.getAllByTestId('passenger-card')).toHaveLength(3)
expect(screen.getByText('👨')).toBeInTheDocument()
expect(screen.getByText('👩')).toBeInTheDocument()
expect(screen.getByText('👧')).toBeInTheDocument()
})
test('updates when route changes', () => {
const { rerender } = render(<GameHUD {...defaultProps} />)
expect(screen.getByText('Route 1')).toBeInTheDocument()
rerender(<GameHUD {...defaultProps} currentRoute={2} />)
expect(screen.getByText('Route 2')).toBeInTheDocument()
})
test('updates when time remaining changes', () => {
const { rerender } = render(<GameHUD {...defaultProps} />)
expect(screen.getByText(/45s/)).toBeInTheDocument()
rerender(<GameHUD {...defaultProps} timeRemaining={30} />)
expect(screen.getByText(/30s/)).toBeInTheDocument()
})
test('memoization: same props do not cause re-render', () => {
const { rerender, container } = render(<GameHUD {...defaultProps} />)
const initialHTML = container.innerHTML
// Rerender with same props
rerender(<GameHUD {...defaultProps} />)
// Should be memoized (same HTML)
expect(container.innerHTML).toBe(initialHTML)
})
})

View File

@@ -0,0 +1,191 @@
import { render } from '@testing-library/react'
import { describe, expect, test } from 'vitest'
import { TrainTerrainBackground } from '../TrainTerrainBackground'
describe('TrainTerrainBackground', () => {
const mockGroundCircles = [
{ key: 'ground-1', cx: 10, cy: 150, r: 2 },
{ key: 'ground-2', cx: 40, cy: 180, r: 3 },
]
test('renders without crashing', () => {
const { container } = render(
<svg>
<TrainTerrainBackground
ballastPath="M 0 300 L 800 300"
groundTextureCircles={mockGroundCircles}
/>
</svg>
)
expect(container).toBeTruthy()
})
test('renders gradient definitions', () => {
const { container } = render(
<svg>
<TrainTerrainBackground
ballastPath="M 0 300 L 800 300"
groundTextureCircles={mockGroundCircles}
/>
</svg>
)
const defs = container.querySelector('defs')
expect(defs).toBeTruthy()
// Check for gradient IDs
expect(container.querySelector('#mountainGradientLeft')).toBeTruthy()
expect(container.querySelector('#mountainGradientRight')).toBeTruthy()
expect(container.querySelector('#groundGradient')).toBeTruthy()
})
test('renders ground layer rects', () => {
const { container } = render(
<svg>
<TrainTerrainBackground
ballastPath="M 0 300 L 800 300"
groundTextureCircles={mockGroundCircles}
/>
</svg>
)
const rects = container.querySelectorAll('rect')
expect(rects.length).toBeGreaterThan(0)
// Check for ground base layer
const groundRect = Array.from(rects).find(
(rect) => rect.getAttribute('fill') === '#8B7355' && rect.getAttribute('width') === '900'
)
expect(groundRect).toBeTruthy()
})
test('renders ground texture circles', () => {
const { container } = render(
<svg>
<TrainTerrainBackground
ballastPath="M 0 300 L 800 300"
groundTextureCircles={mockGroundCircles}
/>
</svg>
)
const circles = container.querySelectorAll('circle')
expect(circles.length).toBeGreaterThanOrEqual(2)
// Verify circle attributes
const firstCircle = circles[0]
expect(firstCircle.getAttribute('cx')).toBe('10')
expect(firstCircle.getAttribute('cy')).toBe('150')
expect(firstCircle.getAttribute('r')).toBe('2')
})
test('renders ballast path with correct attributes', () => {
const { container } = render(
<svg>
<TrainTerrainBackground
ballastPath="M 0 300 L 800 300"
groundTextureCircles={mockGroundCircles}
/>
</svg>
)
const ballastPath = Array.from(container.querySelectorAll('path')).find(
(path) =>
path.getAttribute('d') === 'M 0 300 L 800 300' && path.getAttribute('stroke') === '#8B7355'
)
expect(ballastPath).toBeTruthy()
expect(ballastPath?.getAttribute('stroke-width')).toBe('40')
})
test('renders left tunnel structure', () => {
const { container } = render(
<svg>
<TrainTerrainBackground
ballastPath="M 0 300 L 800 300"
groundTextureCircles={mockGroundCircles}
/>
</svg>
)
const leftTunnel = container.querySelector('[data-element="left-tunnel"]')
expect(leftTunnel).toBeTruthy()
// Check for tunnel elements
const ellipses = leftTunnel?.querySelectorAll('ellipse')
expect(ellipses?.length).toBeGreaterThan(0)
})
test('renders right tunnel structure', () => {
const { container } = render(
<svg>
<TrainTerrainBackground
ballastPath="M 0 300 L 800 300"
groundTextureCircles={mockGroundCircles}
/>
</svg>
)
const rightTunnel = container.querySelector('[data-element="right-tunnel"]')
expect(rightTunnel).toBeTruthy()
// Check for tunnel elements
const ellipses = rightTunnel?.querySelectorAll('ellipse')
expect(ellipses?.length).toBeGreaterThan(0)
})
test('renders mountains with gradient fills', () => {
const { container } = render(
<svg>
<TrainTerrainBackground
ballastPath="M 0 300 L 800 300"
groundTextureCircles={mockGroundCircles}
/>
</svg>
)
// Check for paths with gradient fills
const gradientPaths = Array.from(container.querySelectorAll('path')).filter((path) =>
path.getAttribute('fill')?.includes('url(#mountainGradient')
)
expect(gradientPaths.length).toBeGreaterThanOrEqual(2)
})
test('handles empty groundTextureCircles array', () => {
const { container } = render(
<svg>
<TrainTerrainBackground ballastPath="M 0 300 L 800 300" groundTextureCircles={[]} />
</svg>
)
// Should still render other elements
expect(container.querySelector('defs')).toBeTruthy()
expect(container.querySelector('[data-element="left-tunnel"]')).toBeTruthy()
})
test('memoization: does not re-render with same props', () => {
const { rerender, container } = render(
<svg>
<TrainTerrainBackground
ballastPath="M 0 300 L 800 300"
groundTextureCircles={mockGroundCircles}
/>
</svg>
)
const initialHTML = container.innerHTML
// Rerender with same props
rerender(
<svg>
<TrainTerrainBackground
ballastPath="M 0 300 L 800 300"
groundTextureCircles={mockGroundCircles}
/>
</svg>
)
// HTML should be identical (component memoized)
expect(container.innerHTML).toBe(initialHTML)
})
})

View File

@@ -0,0 +1,171 @@
'use client'
import { getRouteTheme } from '../lib/routeThemes'
interface RouteCelebrationProps {
completedRouteNumber: number
nextRouteNumber: number
onContinue: () => void
}
export function RouteCelebration({
completedRouteNumber,
nextRouteNumber,
onContinue,
}: RouteCelebrationProps) {
const completedTheme = getRouteTheme(completedRouteNumber)
const nextTheme = getRouteTheme(nextRouteNumber)
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
animation: 'fadeIn 0.3s ease-out',
}}
>
<div
style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
borderRadius: '24px',
padding: '40px',
maxWidth: '500px',
textAlign: 'center',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
animation: 'scaleIn 0.5s ease-out',
color: 'white',
}}
>
{/* Celebration header */}
<div
style={{
fontSize: '64px',
marginBottom: '20px',
animation: 'bounce 1s ease-in-out infinite',
}}
>
🎉
</div>
<h2
style={{
fontSize: '32px',
fontWeight: 'bold',
marginBottom: '16px',
textShadow: '0 2px 10px rgba(0, 0, 0, 0.3)',
}}
>
Route Complete!
</h2>
{/* Completed route info */}
<div
style={{
background: 'rgba(255, 255, 255, 0.2)',
borderRadius: '12px',
padding: '16px',
marginBottom: '24px',
}}
>
<div style={{ fontSize: '40px', marginBottom: '8px' }}>{completedTheme.emoji}</div>
<div style={{ fontSize: '20px', fontWeight: '600' }}>{completedTheme.name}</div>
<div style={{ fontSize: '16px', opacity: 0.9, marginTop: '4px' }}>
Route {completedRouteNumber}
</div>
</div>
{/* Next route preview */}
<div
style={{
fontSize: '14px',
opacity: 0.9,
marginBottom: '8px',
}}
>
Next destination:
</div>
<div
style={{
background: 'rgba(255, 255, 255, 0.15)',
borderRadius: '12px',
padding: '12px',
marginBottom: '24px',
border: '2px dashed rgba(255, 255, 255, 0.3)',
}}
>
<div style={{ fontSize: '32px', marginBottom: '4px' }}>{nextTheme.emoji}</div>
<div style={{ fontSize: '18px', fontWeight: '600' }}>{nextTheme.name}</div>
<div style={{ fontSize: '14px', opacity: 0.8, marginTop: '4px' }}>
Route {nextRouteNumber}
</div>
</div>
{/* Continue button */}
<button
onClick={onContinue}
style={{
background: 'white',
color: '#667eea',
border: 'none',
borderRadius: '12px',
padding: '16px 32px',
fontSize: '18px',
fontWeight: 'bold',
cursor: 'pointer',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 6px 16px rgba(0, 0, 0, 0.3)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.2)'
}}
>
Continue Journey 🚂
</button>
</div>
<style>{`
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scaleIn {
from {
transform: scale(0.8);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
`}</style>
</div>
)
}

View File

@@ -0,0 +1,455 @@
'use client'
import type React from 'react'
import { createContext, type ReactNode, useContext, useReducer } from 'react'
import type { AIRacer, DifficultyTracker, GameAction, GameState, Station } from '../lib/gameTypes'
const initialDifficultyTracker: DifficultyTracker = {
pairPerformance: new Map(),
baseTimeLimit: 3000,
currentTimeLimit: 3000,
difficultyLevel: 1,
consecutiveCorrect: 0,
consecutiveIncorrect: 0,
learningMode: true,
adaptationRate: 0.1,
}
const initialAIRacers: AIRacer[] = [
{
id: 'ai-racer-1',
position: 0,
speed: 0.32, // Balanced speed for good challenge
name: 'Swift AI',
personality: 'competitive',
icon: '🏃‍♂️',
lastComment: 0,
commentCooldown: 0,
previousPosition: 0,
},
{
id: 'ai-racer-2',
position: 0,
speed: 0.2, // Balanced speed for good challenge
name: 'Math Bot',
personality: 'analytical',
icon: '🏃',
lastComment: 0,
commentCooldown: 0,
previousPosition: 0,
},
]
const initialStations: Station[] = [
{ id: 'station-0', name: 'Depot', position: 0, icon: '🏭' },
{ id: 'station-1', name: 'Riverside', position: 20, icon: '🌊' },
{ id: 'station-2', name: 'Hillside', position: 40, icon: '⛰️' },
{ id: 'station-3', name: 'Canyon View', position: 60, icon: '🏜️' },
{ id: 'station-4', name: 'Meadows', position: 80, icon: '🌾' },
{ id: 'station-5', name: 'Grand Central', position: 100, icon: '🏛️' },
]
const initialState: GameState = {
// Game configuration
mode: 'friends5',
style: 'practice',
timeoutSetting: 'normal',
complementDisplay: 'abacus', // Default to showing abacus
// Current question
currentQuestion: null,
previousQuestion: null,
// Game progress
score: 0,
streak: 0,
bestStreak: 0,
totalQuestions: 0,
correctAnswers: 0,
// Game status
isGameActive: false,
isPaused: false,
gamePhase: 'controls',
// Timing
gameStartTime: null,
questionStartTime: Date.now(),
// Race mechanics
raceGoal: 20,
timeLimit: null,
speedMultiplier: 1.0,
aiRacers: initialAIRacers,
// Adaptive difficulty
difficultyTracker: initialDifficultyTracker,
// Survival mode specific
playerLap: 0,
aiLaps: new Map(),
survivalMultiplier: 1.0,
// Sprint mode specific
momentum: 0,
trainPosition: 0,
pressure: 0,
elapsedTime: 0,
lastCorrectAnswerTime: Date.now(),
currentRoute: 1,
stations: initialStations,
passengers: [],
deliveredPassengers: 0,
cumulativeDistance: 0,
showRouteCelebration: false,
// Input
currentInput: '',
// UI state
showScoreModal: false,
activeSpeechBubbles: new Map(),
adaptiveFeedback: null,
}
function gameReducer(state: GameState, action: GameAction): GameState {
switch (action.type) {
case 'SET_MODE':
return { ...state, mode: action.mode }
case 'SET_STYLE':
return { ...state, style: action.style }
case 'SET_TIMEOUT':
return { ...state, timeoutSetting: action.timeout }
case 'SET_COMPLEMENT_DISPLAY':
return { ...state, complementDisplay: action.display }
case 'SHOW_CONTROLS':
return { ...state, gamePhase: 'controls' }
case 'START_COUNTDOWN':
return { ...state, gamePhase: 'countdown' }
case 'BEGIN_GAME': {
// Generate first question when game starts
const generateFirstQuestion = () => {
let targetSum: number
if (state.mode === 'friends5') {
targetSum = 5
} else if (state.mode === 'friends10') {
targetSum = 10
} else {
targetSum = Math.random() > 0.5 ? 5 : 10
}
const newNumber =
targetSum === 5 ? Math.floor(Math.random() * 5) : Math.floor(Math.random() * 10)
// Decide once whether to show as abacus
const showAsAbacus =
state.complementDisplay === 'abacus' ||
(state.complementDisplay === 'random' && Math.random() < 0.5)
return {
number: newNumber,
targetSum,
correctAnswer: targetSum - newNumber,
showAsAbacus,
}
}
return {
...state,
gamePhase: 'playing',
isGameActive: true,
gameStartTime: Date.now(),
questionStartTime: Date.now(),
currentQuestion: generateFirstQuestion(),
}
}
case 'NEXT_QUESTION': {
// Generate new question based on mode
const generateQuestion = () => {
let targetSum: number
if (state.mode === 'friends5') {
targetSum = 5
} else if (state.mode === 'friends10') {
targetSum = 10
} else {
targetSum = Math.random() > 0.5 ? 5 : 10
}
let newNumber: number
let attempts = 0
do {
if (targetSum === 5) {
newNumber = Math.floor(Math.random() * 5)
} else {
newNumber = Math.floor(Math.random() * 10)
}
attempts++
} while (
state.currentQuestion &&
state.currentQuestion.number === newNumber &&
state.currentQuestion.targetSum === targetSum &&
attempts < 10
)
// Decide once whether to show as abacus
const showAsAbacus =
state.complementDisplay === 'abacus' ||
(state.complementDisplay === 'random' && Math.random() < 0.5)
return {
number: newNumber,
targetSum,
correctAnswer: targetSum - newNumber,
showAsAbacus,
}
}
return {
...state,
previousQuestion: state.currentQuestion,
currentQuestion: generateQuestion(),
questionStartTime: Date.now(),
currentInput: '',
}
}
case 'UPDATE_INPUT':
return { ...state, currentInput: action.input }
case 'SUBMIT_ANSWER': {
if (!state.currentQuestion) return state
const isCorrect = action.answer === state.currentQuestion.correctAnswer
const responseTime = Date.now() - state.questionStartTime
if (isCorrect) {
// Calculate speed bonus: max(0, 300 - (avgTime * 10))
const speedBonus = Math.max(0, 300 - responseTime / 100)
// Update score: correctAnswers * 100 + streak * 50 + speedBonus
const newStreak = state.streak + 1
const newCorrectAnswers = state.correctAnswers + 1
const newScore = state.score + 100 + newStreak * 50 + speedBonus
return {
...state,
correctAnswers: newCorrectAnswers,
streak: newStreak,
bestStreak: Math.max(state.bestStreak, newStreak),
score: Math.round(newScore),
totalQuestions: state.totalQuestions + 1,
}
} else {
// Incorrect answer - reset streak but keep score
return {
...state,
streak: 0,
totalQuestions: state.totalQuestions + 1,
}
}
}
case 'UPDATE_AI_POSITIONS':
return {
...state,
aiRacers: state.aiRacers.map((racer) => {
const update = action.positions.find((p) => p.id === racer.id)
return update
? { ...racer, previousPosition: racer.position, position: update.position }
: racer
}),
}
case 'UPDATE_MOMENTUM':
return { ...state, momentum: action.momentum }
case 'UPDATE_TRAIN_POSITION':
return { ...state, trainPosition: action.position }
case 'UPDATE_STEAM_JOURNEY':
return {
...state,
momentum: action.momentum,
trainPosition: action.trainPosition,
pressure: action.pressure,
elapsedTime: action.elapsedTime,
}
case 'COMPLETE_LAP':
if (action.racerId === 'player') {
return { ...state, playerLap: state.playerLap + 1 }
} else {
const newAILaps = new Map(state.aiLaps)
newAILaps.set(action.racerId, (newAILaps.get(action.racerId) || 0) + 1)
return { ...state, aiLaps: newAILaps }
}
case 'PAUSE_RACE':
return { ...state, isPaused: true }
case 'RESUME_RACE':
return { ...state, isPaused: false }
case 'END_RACE':
return { ...state, isGameActive: false }
case 'SHOW_RESULTS':
return { ...state, gamePhase: 'results', showScoreModal: true }
case 'RESET_GAME':
return {
...initialState,
// Preserve configuration settings
mode: state.mode,
style: state.style,
timeoutSetting: state.timeoutSetting,
complementDisplay: state.complementDisplay,
gamePhase: 'controls',
}
case 'TRIGGER_AI_COMMENTARY': {
const newBubbles = new Map(state.activeSpeechBubbles)
newBubbles.set(action.racerId, action.message)
return {
...state,
activeSpeechBubbles: newBubbles,
// Update racer's lastComment time and cooldown
aiRacers: state.aiRacers.map((racer) =>
racer.id === action.racerId
? {
...racer,
lastComment: Date.now(),
commentCooldown: Math.random() * 4000 + 2000, // 2-6 seconds
}
: racer
),
}
}
case 'CLEAR_AI_COMMENT': {
const clearedBubbles = new Map(state.activeSpeechBubbles)
clearedBubbles.delete(action.racerId)
return {
...state,
activeSpeechBubbles: clearedBubbles,
}
}
case 'UPDATE_DIFFICULTY_TRACKER':
return {
...state,
difficultyTracker: action.tracker,
}
case 'UPDATE_AI_SPEEDS':
return {
...state,
aiRacers: action.racers,
}
case 'SHOW_ADAPTIVE_FEEDBACK':
return {
...state,
adaptiveFeedback: action.feedback,
}
case 'CLEAR_ADAPTIVE_FEEDBACK':
return {
...state,
adaptiveFeedback: null,
}
case 'GENERATE_PASSENGERS':
return {
...state,
passengers: action.passengers,
}
case 'BOARD_PASSENGER':
return {
...state,
passengers: state.passengers.map((p) =>
p.id === action.passengerId ? { ...p, isBoarded: true } : p
),
}
case 'DELIVER_PASSENGER':
return {
...state,
passengers: state.passengers.map((p) =>
p.id === action.passengerId ? { ...p, isDelivered: true } : p
),
deliveredPassengers: state.deliveredPassengers + 1,
score: state.score + action.points,
}
case 'START_NEW_ROUTE':
return {
...state,
currentRoute: action.routeNumber,
stations: action.stations,
trainPosition: -5, // Start off-screen to the left for smooth fade-in
deliveredPassengers: 0,
showRouteCelebration: false,
momentum: 50, // Give some starting momentum for the new route
pressure: 50,
}
case 'COMPLETE_ROUTE':
return {
...state,
cumulativeDistance: state.cumulativeDistance + 100,
showRouteCelebration: true,
}
case 'HIDE_ROUTE_CELEBRATION':
return {
...state,
showRouteCelebration: false,
}
default:
return state
}
}
interface ComplementRaceContextType {
state: GameState
dispatch: React.Dispatch<GameAction>
}
const ComplementRaceContext = createContext<ComplementRaceContextType | undefined>(undefined)
interface ComplementRaceProviderProps {
children: ReactNode
initialStyle?: 'practice' | 'sprint' | 'survival'
}
export function ComplementRaceProvider({ children, initialStyle }: ComplementRaceProviderProps) {
const [state, dispatch] = useReducer(gameReducer, {
...initialState,
style: initialStyle || initialState.style,
})
return (
<ComplementRaceContext.Provider value={{ state, dispatch }}>
{children}
</ComplementRaceContext.Provider>
)
}
export function useComplementRace() {
const context = useContext(ComplementRaceContext)
if (context === undefined) {
throw new Error('useComplementRace must be used within ComplementRaceProvider')
}
return context
}

View File

@@ -0,0 +1,279 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import type { Passenger, Station } from '../../lib/gameTypes'
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
import { usePassengerAnimations } from '../usePassengerAnimations'
describe('usePassengerAnimations', () => {
let mockPathRef: React.RefObject<SVGPathElement>
let mockTrackGenerator: RailroadTrackGenerator
let mockStation1: Station
let mockStation2: Station
let mockPassenger1: Passenger
let mockPassenger2: Passenger
beforeEach(() => {
// Create mock path element
const mockPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
mockPathRef = { current: mockPath }
// Mock track generator
mockTrackGenerator = {
getTrainTransform: vi.fn((_path: SVGPathElement, position: number) => ({
x: position * 10,
y: 300,
rotation: 0,
})),
} as unknown as RailroadTrackGenerator
// Create mock stations
mockStation1 = {
id: 'station-1',
name: 'Station 1',
position: 20,
icon: '🏭',
}
mockStation2 = {
id: 'station-2',
name: 'Station 2',
position: 60,
icon: '🏛️',
}
// Create mock passengers
mockPassenger1 = {
id: 'passenger-1',
avatar: '👨',
originStationId: 'station-1',
destinationStationId: 'station-2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
}
mockPassenger2 = {
id: 'passenger-2',
avatar: '👩',
originStationId: 'station-1',
destinationStationId: 'station-2',
isBoarded: false,
isDelivered: false,
isUrgent: true,
}
vi.clearAllMocks()
})
test('initializes with empty animation maps', () => {
const { result } = renderHook(() =>
usePassengerAnimations({
passengers: [],
stations: [mockStation1, mockStation2],
stationPositions: [
{ x: 100, y: 300 },
{ x: 500, y: 300 },
],
trainPosition: 0,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
})
)
expect(result.current.boardingAnimations.size).toBe(0)
expect(result.current.disembarkingAnimations.size).toBe(0)
})
test('creates boarding animation when passenger boards', () => {
const { result, rerender } = renderHook(
({ passengers }) =>
usePassengerAnimations({
passengers,
stations: [mockStation1, mockStation2],
stationPositions: [
{ x: 100, y: 300 },
{ x: 500, y: 300 },
],
trainPosition: 20,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
}),
{
initialProps: {
passengers: [mockPassenger1],
},
}
)
// Initially no boarding animations
expect(result.current.boardingAnimations.size).toBe(0)
// Passenger boards
const boardedPassenger = { ...mockPassenger1, isBoarded: true }
rerender({ passengers: [boardedPassenger] })
// Should create boarding animation
expect(result.current.boardingAnimations.size).toBe(1)
expect(result.current.boardingAnimations.has('passenger-1')).toBe(true)
const animation = result.current.boardingAnimations.get('passenger-1')
expect(animation).toBeDefined()
expect(animation?.passenger).toEqual(boardedPassenger)
expect(animation?.fromX).toBe(100) // Station position
expect(animation?.fromY).toBe(270) // Station position - 30
expect(mockTrackGenerator.getTrainTransform).toHaveBeenCalled()
})
test('creates disembarking animation when passenger is delivered', () => {
const boardedPassenger = { ...mockPassenger1, isBoarded: true }
const { result, rerender } = renderHook(
({ passengers }) =>
usePassengerAnimations({
passengers,
stations: [mockStation1, mockStation2],
stationPositions: [
{ x: 100, y: 300 },
{ x: 500, y: 300 },
],
trainPosition: 60,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
}),
{
initialProps: {
passengers: [boardedPassenger],
},
}
)
// Initially no disembarking animations
expect(result.current.disembarkingAnimations.size).toBe(0)
// Passenger is delivered
const deliveredPassenger = { ...boardedPassenger, isDelivered: true }
rerender({ passengers: [deliveredPassenger] })
// Should create disembarking animation
expect(result.current.disembarkingAnimations.size).toBe(1)
expect(result.current.disembarkingAnimations.has('passenger-1')).toBe(true)
const animation = result.current.disembarkingAnimations.get('passenger-1')
expect(animation).toBeDefined()
expect(animation?.passenger).toEqual(deliveredPassenger)
expect(animation?.toX).toBe(500) // Destination station position
expect(animation?.toY).toBe(270) // Station position - 30
})
test('handles multiple passengers boarding simultaneously', () => {
const { result, rerender } = renderHook(
({ passengers }) =>
usePassengerAnimations({
passengers,
stations: [mockStation1, mockStation2],
stationPositions: [
{ x: 100, y: 300 },
{ x: 500, y: 300 },
],
trainPosition: 20,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
}),
{
initialProps: {
passengers: [mockPassenger1, mockPassenger2],
},
}
)
// Both passengers board
const boardedPassengers = [
{ ...mockPassenger1, isBoarded: true },
{ ...mockPassenger2, isBoarded: true },
]
rerender({ passengers: boardedPassengers })
// Should create boarding animations for both
expect(result.current.boardingAnimations.size).toBe(2)
expect(result.current.boardingAnimations.has('passenger-1')).toBe(true)
expect(result.current.boardingAnimations.has('passenger-2')).toBe(true)
})
test('does not create animation if passenger already boarded in previous state', () => {
const boardedPassenger = { ...mockPassenger1, isBoarded: true }
const { result } = renderHook(() =>
usePassengerAnimations({
passengers: [boardedPassenger],
stations: [mockStation1, mockStation2],
stationPositions: [
{ x: 100, y: 300 },
{ x: 500, y: 300 },
],
trainPosition: 20,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
})
)
// No animation since passenger was already boarded
expect(result.current.boardingAnimations.size).toBe(0)
})
test('returns empty animations when pathRef is null', () => {
const nullPathRef: React.RefObject<SVGPathElement> = { current: null }
const { result, rerender } = renderHook(
({ passengers }) =>
usePassengerAnimations({
passengers,
stations: [mockStation1, mockStation2],
stationPositions: [
{ x: 100, y: 300 },
{ x: 500, y: 300 },
],
trainPosition: 20,
trackGenerator: mockTrackGenerator,
pathRef: nullPathRef,
}),
{
initialProps: {
passengers: [mockPassenger1],
},
}
)
// Passenger boards
const boardedPassenger = { ...mockPassenger1, isBoarded: true }
rerender({ passengers: [boardedPassenger] })
// Should not create animation without path
expect(result.current.boardingAnimations.size).toBe(0)
})
test('returns empty animations when stationPositions is empty', () => {
const { result, rerender } = renderHook(
({ passengers }) =>
usePassengerAnimations({
passengers,
stations: [mockStation1, mockStation2],
stationPositions: [],
trainPosition: 20,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
}),
{
initialProps: {
passengers: [mockPassenger1],
},
}
)
// Passenger boards
const boardedPassenger = { ...mockPassenger1, isBoarded: true }
rerender({ passengers: [boardedPassenger] })
// Should not create animation without station positions
expect(result.current.boardingAnimations.size).toBe(0)
})
})

View File

@@ -0,0 +1,353 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
// Mock sound effects
vi.mock('../useSoundEffects', () => ({
useSoundEffects: () => ({
playSound: vi.fn(),
}),
}))
/**
* Boarding Logic Tests
*
* These tests simulate the game loop's boarding logic to find edge cases
* where passengers get left behind at stations.
*/
interface Passenger {
id: string
name: string
avatar: string
originStationId: string
destinationStationId: string
isBoarded: boolean
isDelivered: boolean
isUrgent: boolean
}
interface Station {
id: string
name: string
icon: string
position: number
}
describe('useSteamJourney - Boarding Logic', () => {
const CAR_SPACING = 7
let stations: Station[]
let passengers: Passenger[]
beforeEach(() => {
stations = [
{ id: 's1', name: 'Station 1', icon: '🏠', position: 20 },
{ id: 's2', name: 'Station 2', icon: '🏢', position: 50 },
{ id: 's3', name: 'Station 3', icon: '🏪', position: 80 },
]
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
/**
* Simulate the boarding logic from useSteamJourney (with fix)
*/
function simulateBoardingAtPosition(
trainPosition: number,
passengers: Passenger[],
stations: Station[],
maxCars: number
): Passenger[] {
const updatedPassengers = [...passengers]
const currentBoardedPassengers = updatedPassengers.filter((p) => p.isBoarded && !p.isDelivered)
// Track which cars are assigned in THIS frame to prevent double-boarding
const carsAssignedThisFrame = new Set<number>()
// Simulate the boarding logic
updatedPassengers.forEach((passenger, passengerIndex) => {
if (passenger.isBoarded || passenger.isDelivered) return
const station = stations.find((s) => s.id === passenger.originStationId)
if (!station) return
// Check if any empty car is at this station
for (let carIndex = 0; carIndex < maxCars; carIndex++) {
// Skip if this car already has a passenger OR was assigned this frame
if (currentBoardedPassengers[carIndex] || carsAssignedThisFrame.has(carIndex)) continue
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
const distance = Math.abs(carPosition - station.position)
// If car is at station (within 3% tolerance), board this passenger
if (distance < 3) {
updatedPassengers[passengerIndex] = { ...passenger, isBoarded: true }
// Mark this car as assigned in this frame
carsAssignedThisFrame.add(carIndex)
return // Board this passenger and move on
}
}
})
return updatedPassengers
}
test('single passenger at station boards when car arrives', () => {
passengers = [
{
id: 'p1',
name: 'Alice',
avatar: '👩',
originStationId: 's1',
destinationStationId: 's2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
]
// Train at position 27%, first car at position 20% (station 1)
const result = simulateBoardingAtPosition(27, passengers, stations, 1)
expect(result[0].isBoarded).toBe(true)
})
test('EDGE CASE: multiple passengers at same station with enough cars', () => {
passengers = [
{
id: 'p1',
name: 'Alice',
avatar: '👩',
originStationId: 's1',
destinationStationId: 's2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
{
id: 'p2',
name: 'Bob',
avatar: '👨',
originStationId: 's1',
destinationStationId: 's2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
{
id: 'p3',
name: 'Charlie',
avatar: '👴',
originStationId: 's1',
destinationStationId: 's2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
]
// Train at position 34%, cars at: 27%, 20%, 13%
// Car 1 (27%): 7% away from station (too far)
// Car 2 (20%): 0% away from station (at station!)
// Car 3 (13%): 7% away from station (too far)
let result = simulateBoardingAtPosition(34, passengers, stations, 3)
// First iteration: car 2 is at station, should board first passenger
expect(result[0].isBoarded).toBe(true)
// But what about the other passengers? They should board on subsequent frames
// Let's simulate the train advancing slightly
result = simulateBoardingAtPosition(35, result, stations, 3)
// Now car 1 is at 28% (still too far), car 2 at 21% (still close), car 3 at 14% (too far)
// Passenger 2 should still not board yet
// Advance more - when does car 1 reach the station?
result = simulateBoardingAtPosition(27, result, stations, 3)
// Car 1 at 20% (at station!)
expect(result[1].isBoarded).toBe(true)
// What about passenger 3? Need car 3 to reach station
// Car 3 position = trainPosition - (3 * 7) = trainPosition - 21
// For car 3 to be at 20%, need trainPosition = 41
result = simulateBoardingAtPosition(41, result, stations, 3)
// Car 3 at 20% (at station!)
expect(result[2].isBoarded).toBe(true)
})
test('EDGE CASE: passengers left behind when train moves too fast', () => {
passengers = [
{
id: 'p1',
name: 'Alice',
avatar: '👩',
originStationId: 's1',
destinationStationId: 's2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
{
id: 'p2',
name: 'Bob',
avatar: '👨',
originStationId: 's1',
destinationStationId: 's2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
]
// Simulate train speeding through station
// Only 2 cars, but 2 passengers at same station
// Frame 1: Train at 27%, car 1 at 20%, car 2 at 13%
let result = simulateBoardingAtPosition(27, passengers, stations, 2)
expect(result[0].isBoarded).toBe(true)
expect(result[1].isBoarded).toBe(false)
// Frame 2: Train jumps to 35% (high momentum)
// Car 1 at 28%, car 2 at 21%
result = simulateBoardingAtPosition(35, result, stations, 2)
// Car 2 is at 21%, within 1% of station at 20%
expect(result[1].isBoarded).toBe(true)
// Frame 3: Train at 45% - both cars past station
result = simulateBoardingAtPosition(45, result, stations, 2)
// Car 1 at 38%, car 2 at 31% - both way past 20%
// All passengers should have boarded
expect(result.every((p) => p.isBoarded)).toBe(true)
})
test('EDGE CASE: passenger left behind when boarding window is missed', () => {
passengers = [
{
id: 'p1',
name: 'Alice',
avatar: '👩',
originStationId: 's1',
destinationStationId: 's2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
{
id: 'p2',
name: 'Bob',
avatar: '👨',
originStationId: 's1',
destinationStationId: 's2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
]
// Only 1 car, 2 passengers
// Frame 1: Train at 27%, car at 20%
let result = simulateBoardingAtPosition(27, passengers, stations, 1)
expect(result[0].isBoarded).toBe(true)
expect(result[1].isBoarded).toBe(false) // Second passenger waiting
// Frame 2: Train jumps way past (very high momentum)
result = simulateBoardingAtPosition(50, result, stations, 1)
// Car at 43% - way past station at 20%
// Second passenger SHOULD BE LEFT BEHIND!
expect(result[1].isBoarded).toBe(false)
})
test('EDGE CASE: only one passenger boards per car per frame', () => {
passengers = [
{
id: 'p1',
name: 'Alice',
avatar: '👩',
originStationId: 's1',
destinationStationId: 's2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
{
id: 'p2',
name: 'Bob',
avatar: '👨',
originStationId: 's1',
destinationStationId: 's2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
]
// Only 1 car, both passengers at same station
// With the fix, only first passenger should board in this frame
const result = simulateBoardingAtPosition(27, passengers, stations, 1)
// First passenger boards
expect(result[0].isBoarded).toBe(true)
// Second passenger does NOT board (car already assigned this frame)
expect(result[1].isBoarded).toBe(false)
})
test('all passengers board before train completely passes station', () => {
passengers = [
{
id: 'p1',
name: 'Alice',
avatar: '👩',
originStationId: 's1',
destinationStationId: 's2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
{
id: 'p2',
name: 'Bob',
avatar: '👨',
originStationId: 's1',
destinationStationId: 's2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
{
id: 'p3',
name: 'Charlie',
avatar: '👴',
originStationId: 's1',
destinationStationId: 's2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
]
// 3 passengers, 3 cars
// Simulate train moving through station frame by frame
let result = passengers
// Train approaching station
for (let pos = 13; pos <= 40; pos += 1) {
result = simulateBoardingAtPosition(pos, result, stations, 3)
}
// All passengers should have boarded by the time last car passes
const allBoarded = result.every((p) => p.isBoarded)
const leftBehind = result.filter((p) => !p.isBoarded)
expect(allBoarded).toBe(true)
if (!allBoarded) {
console.log(
'Passengers left behind:',
leftBehind.map((p) => p.name)
)
}
})
})

View File

@@ -0,0 +1,292 @@
/**
* Unit tests for passenger boarding/delivery logic in useSteamJourney
*
* These tests ensure that:
* 1. Passengers always board when an empty car reaches their origin station
* 2. Passengers are never left behind
* 3. Multiple passengers can board at the same station on different cars
* 4. Passengers are delivered to the correct destination
*/
import { act, renderHook } from '@testing-library/react'
import type { ReactNode } from 'react'
import { ComplementRaceProvider, useComplementRace } from '../../context/ComplementRaceContext'
import type { Passenger, Station } from '../../lib/gameTypes'
import { useSteamJourney } from '../useSteamJourney'
// Mock sound effects
jest.mock('../useSoundEffects', () => ({
useSoundEffects: () => ({
playSound: jest.fn(),
}),
}))
// Wrapper component
const wrapper = ({ children }: { children: ReactNode }) => (
<ComplementRaceProvider initialStyle="sprint">{children}</ComplementRaceProvider>
)
// Helper to create test passengers
const createPassenger = (
id: string,
originStationId: string,
destinationStationId: string,
isBoarded = false,
isDelivered = false
): Passenger => ({
id,
name: `Passenger ${id}`,
avatar: '👤',
originStationId,
destinationStationId,
isUrgent: false,
isBoarded,
isDelivered,
})
// Test stations
const _testStations: Station[] = [
{ id: 'station-0', name: 'Start', position: 0, icon: '🏁' },
{ id: 'station-1', name: 'Middle', position: 50, icon: '🏢' },
{ id: 'station-2', name: 'End', position: 100, icon: '🏁' },
]
describe('useSteamJourney - Passenger Boarding', () => {
beforeEach(() => {
jest.useFakeTimers()
})
afterEach(() => {
jest.runOnlyPendingTimers()
jest.useRealTimers()
})
test('passenger boards when train reaches their origin station', () => {
const { result } = renderHook(
() => {
const journey = useSteamJourney()
const race = useComplementRace()
return { journey, race }
},
{ wrapper }
)
// Setup: Add passenger waiting at station-1 (position 50)
const passenger = createPassenger('p1', 'station-1', 'station-2')
act(() => {
result.current.race.dispatch({ type: 'BEGIN_GAME' })
result.current.race.dispatch({
type: 'GENERATE_PASSENGERS',
passengers: [passenger],
})
// Set train position just before station-1
result.current.race.dispatch({
type: 'UPDATE_STEAM_JOURNEY',
momentum: 50,
trainPosition: 40, // First car will be at ~33 (40 - 7)
pressure: 75,
elapsedTime: 1000,
})
})
// Verify passenger is waiting
expect(result.current.race.state.passengers[0].isBoarded).toBe(false)
// Move train to station-1 position
act(() => {
result.current.race.dispatch({
type: 'UPDATE_STEAM_JOURNEY',
momentum: 50,
trainPosition: 57, // First car at position 50 (57 - 7)
pressure: 75,
elapsedTime: 2000,
})
})
// Advance timers to trigger the interval
act(() => {
jest.advanceTimersByTime(100)
})
// Verify passenger boarded
const boardedPassenger = result.current.race.state.passengers.find((p) => p.id === 'p1')
expect(boardedPassenger?.isBoarded).toBe(true)
})
test('multiple passengers can board at the same station on different cars', () => {
const { result } = renderHook(
() => {
const journey = useSteamJourney()
const race = useComplementRace()
return { journey, race }
},
{ wrapper }
)
// Setup: Three passengers waiting at station-1
const passengers = [
createPassenger('p1', 'station-1', 'station-2'),
createPassenger('p2', 'station-1', 'station-2'),
createPassenger('p3', 'station-1', 'station-2'),
]
act(() => {
result.current.race.dispatch({ type: 'BEGIN_GAME' })
result.current.race.dispatch({
type: 'GENERATE_PASSENGERS',
passengers,
})
// Set train with 3 empty cars approaching station-1 (position 50)
// Cars at: 50 (57-7), 43 (57-14), 36 (57-21)
result.current.race.dispatch({
type: 'UPDATE_STEAM_JOURNEY',
momentum: 60,
trainPosition: 57,
pressure: 90,
elapsedTime: 1000,
})
})
// Advance timers
act(() => {
jest.advanceTimersByTime(100)
})
// All three passengers should board (one per car)
const boardedCount = result.current.race.state.passengers.filter((p) => p.isBoarded).length
expect(boardedCount).toBe(3)
})
test('passenger is not left behind when train passes quickly', () => {
const { result } = renderHook(
() => {
const journey = useSteamJourney()
const race = useComplementRace()
return { journey, race }
},
{ wrapper }
)
const passenger = createPassenger('p1', 'station-1', 'station-2')
act(() => {
result.current.race.dispatch({ type: 'BEGIN_GAME' })
result.current.race.dispatch({
type: 'GENERATE_PASSENGERS',
passengers: [passenger],
})
})
// Simulate train passing through station quickly
const positions = [40, 45, 50, 52, 54, 56, 58, 60, 65, 70]
for (const pos of positions) {
act(() => {
result.current.race.dispatch({
type: 'UPDATE_STEAM_JOURNEY',
momentum: 80,
trainPosition: pos,
pressure: 120,
elapsedTime: 1000 + pos * 50,
})
jest.advanceTimersByTime(50)
})
// Check if passenger boarded
const boardedPassenger = result.current.race.state.passengers.find((p) => p.id === 'p1')
if (boardedPassenger?.isBoarded) {
// Success! Passenger boarded during the pass
return
}
}
// If we get here, passenger was left behind
const boardedPassenger = result.current.race.state.passengers.find((p) => p.id === 'p1')
expect(boardedPassenger?.isBoarded).toBe(true)
})
test('passenger boards on correct car based on availability', () => {
const { result } = renderHook(
() => {
const journey = useSteamJourney()
const race = useComplementRace()
return { journey, race }
},
{ wrapper }
)
// Setup: One passenger already on car 0, another waiting
const passengers = [
createPassenger('p1', 'station-0', 'station-2', true, false), // Already boarded on car 0
createPassenger('p2', 'station-1', 'station-2'), // Waiting at station-1
]
act(() => {
result.current.race.dispatch({ type: 'BEGIN_GAME' })
result.current.race.dispatch({
type: 'GENERATE_PASSENGERS',
passengers,
})
// Train at station-1, car 0 occupied, car 1 empty
result.current.race.dispatch({
type: 'UPDATE_STEAM_JOURNEY',
momentum: 50,
trainPosition: 57, // Car 0 at 50, Car 1 at 43
pressure: 75,
elapsedTime: 2000,
})
})
act(() => {
jest.advanceTimersByTime(100)
})
// p2 should board (on car 1 since car 0 is occupied)
const p2 = result.current.race.state.passengers.find((p) => p.id === 'p2')
expect(p2?.isBoarded).toBe(true)
// p1 should still be boarded
const p1 = result.current.race.state.passengers.find((p) => p.id === 'p1')
expect(p1?.isBoarded).toBe(true)
expect(p1?.isDelivered).toBe(false)
})
test('passenger is delivered when their car reaches destination', () => {
const { result } = renderHook(
() => {
const journey = useSteamJourney()
const race = useComplementRace()
return { journey, race }
},
{ wrapper }
)
// Setup: Passenger already boarded, heading to station-2 (position 100)
const passenger = createPassenger('p1', 'station-0', 'station-2', true, false)
act(() => {
result.current.race.dispatch({ type: 'BEGIN_GAME' })
result.current.race.dispatch({
type: 'GENERATE_PASSENGERS',
passengers: [passenger],
})
// Move train so car 0 reaches station-2
result.current.race.dispatch({
type: 'UPDATE_STEAM_JOURNEY',
momentum: 50,
trainPosition: 107, // Car 0 at position 100 (107 - 7)
pressure: 75,
elapsedTime: 5000,
})
})
act(() => {
jest.advanceTimersByTime(100)
})
// Passenger should be delivered
const deliveredPassenger = result.current.race.state.passengers.find((p) => p.id === 'p1')
expect(deliveredPassenger?.isDelivered).toBe(true)
})
})

View File

@@ -0,0 +1,500 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import type { Passenger, Station } from '../../lib/gameTypes'
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
import { useTrackManagement } from '../useTrackManagement'
describe('useTrackManagement - Passenger Display', () => {
let mockPathRef: React.RefObject<SVGPathElement>
let mockTrackGenerator: RailroadTrackGenerator
let mockStations: Station[]
let mockPassengers: Passenger[]
beforeEach(() => {
// Create mock path element
const mockPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
mockPath.getTotalLength = vi.fn(() => 1000)
mockPath.getPointAtLength = vi.fn((distance: number) => ({
x: distance,
y: 300,
}))
mockPathRef = { current: mockPath }
// Mock track generator
mockTrackGenerator = {
generateTrack: vi.fn(() => ({
ballastPath: 'M 0 0',
referencePath: 'M 0 0',
ties: [],
leftRailPath: 'M 0 0',
rightRailPath: 'M 0 0',
})),
generateTiesAndRails: vi.fn(() => ({
ties: [],
leftRailPath: 'M 0 0',
rightRailPath: 'M 0 0',
})),
} as unknown as RailroadTrackGenerator
// Mock stations
mockStations = [
{ id: 'station1', name: 'Station 1', icon: '🏠', position: 20 },
{ id: 'station2', name: 'Station 2', icon: '🏢', position: 50 },
{ id: 'station3', name: 'Station 3', icon: '🏪', position: 80 },
]
// Mock passengers - initial set
mockPassengers = [
{
id: 'p1',
name: 'Alice',
avatar: '👩',
originStationId: 'station1',
destinationStationId: 'station2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
{
id: 'p2',
name: 'Bob',
avatar: '👨',
originStationId: 'station2',
destinationStationId: 'station3',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
]
vi.clearAllMocks()
})
test('initial passengers are displayed', () => {
const { result } = renderHook(() =>
useTrackManagement({
currentRoute: 1,
trainPosition: 10,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
maxCars: 3,
carSpacing: 7,
})
)
expect(result.current.displayPassengers).toHaveLength(2)
expect(result.current.displayPassengers[0].id).toBe('p1')
expect(result.current.displayPassengers[1].id).toBe('p2')
})
test('passengers update when boarded (same route gameplay)', () => {
const { result, rerender } = renderHook(
({ passengers, position }) =>
useTrackManagement({
currentRoute: 1,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers,
maxCars: 3,
carSpacing: 7,
}),
{ initialProps: { passengers: mockPassengers, position: 25 } }
)
// Initially 2 passengers
expect(result.current.displayPassengers).toHaveLength(2)
expect(result.current.displayPassengers[0].isBoarded).toBe(false)
// Board first passenger
const boardedPassengers = mockPassengers.map((p) =>
p.id === 'p1' ? { ...p, isBoarded: true } : p
)
rerender({ passengers: boardedPassengers, position: 25 })
// Should show updated passengers
expect(result.current.displayPassengers).toHaveLength(2)
expect(result.current.displayPassengers[0].isBoarded).toBe(true)
})
test('passengers do NOT update during route transition (train moving)', () => {
const { result, rerender } = renderHook(
({ route, passengers, position }) =>
useTrackManagement({
currentRoute: route,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers,
maxCars: 3,
carSpacing: 7,
}),
{ initialProps: { route: 1, passengers: mockPassengers, position: 50 } }
)
// Initially route 1 passengers
expect(result.current.displayPassengers).toHaveLength(2)
expect(result.current.displayPassengers[0].id).toBe('p1')
// Generate new passengers for route 2
const newPassengers: Passenger[] = [
{
id: 'p3',
name: 'Charlie',
avatar: '👴',
originStationId: 'station1',
destinationStationId: 'station3',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
]
// Change route but train still moving
rerender({ route: 2, passengers: newPassengers, position: 60 })
// Should STILL show old passengers (route 1)
expect(result.current.displayPassengers).toHaveLength(2)
expect(result.current.displayPassengers[0].id).toBe('p1')
expect(result.current.displayPassengers[0].name).toBe('Alice')
})
test('passengers update when train resets to start (negative position)', () => {
const { result, rerender } = renderHook(
({ route, passengers, position }) =>
useTrackManagement({
currentRoute: route,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers,
maxCars: 3,
carSpacing: 7,
}),
{ initialProps: { route: 1, passengers: mockPassengers, position: 50 } }
)
// Initially route 1 passengers
expect(result.current.displayPassengers).toHaveLength(2)
expect(result.current.displayPassengers[0].id).toBe('p1')
// Generate new passengers for route 2
const newPassengers: Passenger[] = [
{
id: 'p3',
name: 'Charlie',
avatar: '👴',
originStationId: 'station1',
destinationStationId: 'station3',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
]
// Change route and train resets
rerender({ route: 2, passengers: newPassengers, position: -5 })
// Should now show NEW passengers (route 2)
expect(result.current.displayPassengers).toHaveLength(1)
expect(result.current.displayPassengers[0].id).toBe('p3')
expect(result.current.displayPassengers[0].name).toBe('Charlie')
})
test('passengers do NOT flash when transitioning through 100%', () => {
const { result, rerender } = renderHook(
({ route, passengers, position }) =>
useTrackManagement({
currentRoute: route,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers,
maxCars: 3,
carSpacing: 7,
}),
{ initialProps: { route: 1, passengers: mockPassengers, position: 95 } }
)
// At 95% - show route 1 passengers
expect(result.current.displayPassengers).toHaveLength(2)
expect(result.current.displayPassengers[0].id).toBe('p1')
// Generate new passengers for route 2
const newPassengers: Passenger[] = [
{
id: 'p3',
name: 'Charlie',
avatar: '👴',
originStationId: 'station1',
destinationStationId: 'station3',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
]
// Train exits (105%) but route hasn't changed yet
rerender({ route: 1, passengers: mockPassengers, position: 105 })
// Should STILL show route 1 passengers
expect(result.current.displayPassengers).toHaveLength(2)
expect(result.current.displayPassengers[0].id).toBe('p1')
// Now route changes to 2, but train still at 105%
rerender({ route: 2, passengers: newPassengers, position: 105 })
// Should STILL show route 1 passengers (old ones)
expect(result.current.displayPassengers).toHaveLength(2)
expect(result.current.displayPassengers[0].id).toBe('p1')
// Train resets to start
rerender({ route: 2, passengers: newPassengers, position: -5 })
// NOW should show route 2 passengers
expect(result.current.displayPassengers).toHaveLength(1)
expect(result.current.displayPassengers[0].id).toBe('p3')
})
test('passengers do NOT update when array reference changes but same route', () => {
const { result, rerender } = renderHook(
({ passengers, position }) =>
useTrackManagement({
currentRoute: 1,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers,
maxCars: 3,
carSpacing: 7,
}),
{ initialProps: { passengers: mockPassengers, position: 50 } }
)
// Initially route 1 passengers
expect(result.current.displayPassengers).toHaveLength(2)
expect(result.current.displayPassengers[0].id).toBe('p1')
// Create new array with same content (different reference)
const samePassengersNewRef = mockPassengers.map((p) => ({ ...p }))
// Update with new reference but same content
rerender({ passengers: samePassengersNewRef, position: 50 })
// Display should update because it's the same route (gameplay update)
expect(result.current.displayPassengers).toHaveLength(2)
expect(result.current.displayPassengers[0].id).toBe('p1')
})
test('delivered passengers update immediately (same route)', () => {
const { result, rerender } = renderHook(
({ passengers, position }) =>
useTrackManagement({
currentRoute: 1,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers,
maxCars: 3,
carSpacing: 7,
}),
{ initialProps: { passengers: mockPassengers, position: 25 } }
)
// Initially 2 passengers, neither delivered
expect(result.current.displayPassengers).toHaveLength(2)
expect(result.current.displayPassengers[0].isDelivered).toBe(false)
// Deliver first passenger
const deliveredPassengers = mockPassengers.map((p) =>
p.id === 'p1' ? { ...p, isBoarded: true, isDelivered: true } : 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)
})
test('multiple rapid passenger updates during same route', () => {
const { result, rerender } = renderHook(
({ passengers, position }) =>
useTrackManagement({
currentRoute: 1,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers,
maxCars: 3,
carSpacing: 7,
}),
{ initialProps: { passengers: mockPassengers, position: 25 } }
)
// Initially 2 passengers
expect(result.current.displayPassengers).toHaveLength(2)
// Board p1
let updated = mockPassengers.map((p) => (p.id === 'p1' ? { ...p, isBoarded: true } : p))
rerender({ passengers: updated, position: 26 })
expect(result.current.displayPassengers[0].isBoarded).toBe(true)
// Board p2
updated = updated.map((p) => (p.id === 'p2' ? { ...p, isBoarded: true } : p))
rerender({ passengers: updated, position: 52 })
expect(result.current.displayPassengers[1].isBoarded).toBe(true)
// Deliver p1
updated = updated.map((p) => (p.id === 'p1' ? { ...p, isDelivered: true } : p))
rerender({ passengers: updated, position: 53 })
expect(result.current.displayPassengers[0].isDelivered).toBe(true)
// 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)
})
test('EDGE CASE: new passengers at position 0 with old route', () => {
const { result, rerender } = renderHook(
({ route, passengers, position }) =>
useTrackManagement({
currentRoute: route,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers,
maxCars: 3,
carSpacing: 7,
}),
{ initialProps: { route: 1, passengers: mockPassengers, position: 95 } }
)
// At 95% - route 1 passengers
expect(result.current.displayPassengers[0].id).toBe('p1')
// Train exits tunnel
rerender({ route: 1, passengers: mockPassengers, position: 110 })
expect(result.current.displayPassengers[0].id).toBe('p1')
// New passengers generated but route hasn't changed yet, position resets to 0
const newPassengers: Passenger[] = [
{
id: 'p3',
name: 'Charlie',
avatar: '👴',
originStationId: 'station1',
destinationStationId: 'station3',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
]
// CRITICAL: New passengers, old route, position = 0
// This could trigger the second useEffect if not handled carefully
rerender({ route: 1, passengers: newPassengers, position: 0 })
// Should NOT show new passengers yet (route hasn't changed)
// But position is 0-100, so second effect might fire
expect(result.current.displayPassengers[0].id).toBe('p1')
expect(result.current.displayPassengers[0].name).toBe('Alice')
})
test('EDGE CASE: passengers regenerated at position 5%', () => {
const { result, rerender } = renderHook(
({ route, passengers, position }) =>
useTrackManagement({
currentRoute: route,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers,
maxCars: 3,
carSpacing: 7,
}),
{ initialProps: { route: 1, passengers: mockPassengers, position: 95 } }
)
// At 95% - route 1 passengers
expect(result.current.displayPassengers[0].id).toBe('p1')
// New passengers generated while train is at 5%
const newPassengers: Passenger[] = [
{
id: 'p3',
name: 'Charlie',
avatar: '👴',
originStationId: 'station1',
destinationStationId: 'station3',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
]
// CRITICAL: New passengers array, same route, position within 0-100
rerender({ route: 1, passengers: newPassengers, position: 5 })
// Should NOT show new passengers (different array reference, route hasn't changed properly)
expect(result.current.displayPassengers[0].id).toBe('p1')
})
test('EDGE CASE: rapid route increment with position oscillation', () => {
const { result, rerender } = renderHook(
({ route, passengers, position }) =>
useTrackManagement({
currentRoute: route,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers,
maxCars: 3,
carSpacing: 7,
}),
{ initialProps: { route: 1, passengers: mockPassengers, position: 50 } }
)
expect(result.current.displayPassengers[0].id).toBe('p1')
const route2Passengers: Passenger[] = [
{
id: 'p3',
name: 'Charlie',
avatar: '👴',
originStationId: 'station1',
destinationStationId: 'station3',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
]
// Route changes, position goes positive briefly before negative
rerender({ route: 2, passengers: route2Passengers, position: 2 })
// Should still show old passengers
expect(result.current.displayPassengers[0].id).toBe('p1')
// Position goes negative
rerender({ route: 2, passengers: route2Passengers, position: -3 })
// NOW should show new passengers
expect(result.current.displayPassengers[0].id).toBe('p3')
})
})

View File

@@ -0,0 +1,362 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import type { Passenger, Station } from '../../lib/gameTypes'
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
import { useTrackManagement } from '../useTrackManagement'
// Mock the landmarks module
vi.mock('../../lib/landmarks', () => ({
generateLandmarks: vi.fn((_route: number) => [
{ emoji: '🌲', position: 30, offset: { x: 0, y: -50 }, size: 24 },
{ emoji: '🏔️', position: 70, offset: { x: 0, y: -80 }, size: 32 },
]),
}))
describe('useTrackManagement', () => {
let mockPathRef: React.RefObject<SVGPathElement>
let mockTrackGenerator: RailroadTrackGenerator
let mockStations: Station[]
let mockPassengers: Passenger[]
beforeEach(() => {
// Create mock path element
const mockPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
mockPath.getTotalLength = vi.fn(() => 1000)
mockPath.getPointAtLength = vi.fn((distance: number) => ({
x: distance,
y: 300,
}))
mockPathRef = { current: mockPath }
// Mock track generator
mockTrackGenerator = {
generateTrack: vi.fn((route: number) => ({
referencePath: `M 0 300 L ${route * 100} 300`,
ballastPath: `M 0 300 L ${route * 100} 300`,
})),
generateTiesAndRails: vi.fn(() => ({
ties: [
{ x1: 0, y1: 300, x2: 10, y2: 300 },
{ x1: 20, y1: 300, x2: 30, y2: 300 },
],
leftRailPoints: ['0,295', '100,295'],
rightRailPoints: ['0,305', '100,305'],
})),
} as unknown as RailroadTrackGenerator
mockStations = [
{ id: 'station-1', name: 'Station 1', position: 20, icon: '🏭' },
{ id: 'station-2', name: 'Station 2', position: 60, icon: '🏛️' },
]
mockPassengers = [
{
id: 'passenger-1',
avatar: '👨',
originStationId: 'station-1',
destinationStationId: 'station-2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
]
vi.clearAllMocks()
})
test('initializes with null trackData', () => {
const { result } = renderHook(() =>
useTrackManagement({
currentRoute: 1,
trainPosition: 0,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
})
)
// Track data should be generated
expect(result.current.trackData).toBeDefined()
expect(mockTrackGenerator.generateTrack).toHaveBeenCalledWith(1)
})
test('generates landmarks for current route', () => {
const { result } = renderHook(() =>
useTrackManagement({
currentRoute: 1,
trainPosition: 0,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
})
)
expect(result.current.landmarks).toHaveLength(2)
expect(result.current.landmarks[0].emoji).toBe('🌲')
expect(result.current.landmarks[1].emoji).toBe('🏔️')
})
test('generates ties and rails when path is ready', () => {
const { result } = renderHook(() =>
useTrackManagement({
currentRoute: 1,
trainPosition: 0,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
})
)
expect(result.current.tiesAndRails).toBeDefined()
expect(result.current.tiesAndRails?.ties).toHaveLength(2)
})
test('calculates station positions along path', () => {
const { result } = renderHook(() =>
useTrackManagement({
currentRoute: 1,
trainPosition: 0,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
})
)
expect(result.current.stationPositions).toHaveLength(2)
// Station 1 at 20% of 1000 = 200
expect(result.current.stationPositions[0].x).toBe(200)
// Station 2 at 60% of 1000 = 600
expect(result.current.stationPositions[1].x).toBe(600)
})
test('calculates landmark positions along path', () => {
const { result } = renderHook(() =>
useTrackManagement({
currentRoute: 1,
trainPosition: 0,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
})
)
expect(result.current.landmarkPositions).toHaveLength(2)
// First landmark at 30% + offset
expect(result.current.landmarkPositions[0].x).toBe(300) // 30% of 1000
expect(result.current.landmarkPositions[0].y).toBe(250) // 300 + (-50)
})
test('delays track update when changing routes mid-journey', () => {
const { result, rerender } = renderHook(
({ route, position }) =>
useTrackManagement({
currentRoute: route,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
}),
{
initialProps: { route: 1, position: 0 },
}
)
const initialTrackData = result.current.trackData
// Change route while train is mid-journey (position > 0)
rerender({ route: 2, position: 50 })
// Track should NOT update yet (pending)
expect(result.current.trackData).toBe(initialTrackData)
expect(mockTrackGenerator.generateTrack).toHaveBeenCalledWith(2)
})
test('applies pending track when train resets to beginning', () => {
const { result, rerender } = renderHook(
({ route, position }) =>
useTrackManagement({
currentRoute: route,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
}),
{
initialProps: { route: 1, position: 0 },
}
)
// Change route while train is mid-journey
rerender({ route: 2, position: 50 })
const trackDataBeforeReset = result.current.trackData
// Train resets to beginning (position < 0)
rerender({ route: 2, position: -5 })
// Track should now update
expect(result.current.trackData).not.toBe(trackDataBeforeReset)
})
test('immediately applies new track when train is at start', () => {
const { result, rerender } = renderHook(
({ route, position }) =>
useTrackManagement({
currentRoute: route,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
}),
{
initialProps: { route: 1, position: -5 },
}
)
const initialTrackData = result.current.trackData
// Change route while train is at start (position < 0)
rerender({ route: 2, position: -5 })
// Track should update immediately
expect(result.current.trackData).not.toBe(initialTrackData)
})
test('delays passenger display update until all cars exit', () => {
const newPassengers: Passenger[] = [
{
id: 'passenger-2',
avatar: '👩',
originStationId: 'station-1',
destinationStationId: 'station-2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
]
const { result, rerender } = renderHook(
({ passengers, position }) =>
useTrackManagement({
currentRoute: 1,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers,
maxCars: 5,
carSpacing: 7,
}),
{
initialProps: { passengers: mockPassengers, position: 50 },
}
)
expect(result.current.displayPassengers).toBe(mockPassengers)
// Change passengers while train is mid-journey
// Locomotive at 100%, but last car at 100 - (5*7) = 65%
rerender({ passengers: newPassengers, position: 100 })
// Display passengers should NOT update yet (last car hasn't exited)
expect(result.current.displayPassengers).toBe(mockPassengers)
})
test('does not update passenger display until train resets', () => {
const newPassengers: Passenger[] = [
{
id: 'passenger-2',
avatar: '👩',
originStationId: 'station-1',
destinationStationId: 'station-2',
isBoarded: false,
isDelivered: false,
isUrgent: false,
},
]
const { result, rerender } = renderHook(
({ passengers, position }) =>
useTrackManagement({
currentRoute: 1,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers,
maxCars: 5,
carSpacing: 7,
}),
{
initialProps: { passengers: mockPassengers, position: 50 },
}
)
// Change passengers, locomotive at position where all cars have exited
// Last car exits at position 97%, so locomotive at 132%
rerender({ passengers: newPassengers, position: 132 })
// Display passengers should NOT update yet (waiting for train reset)
expect(result.current.displayPassengers).toBe(mockPassengers)
// Now train resets to beginning
rerender({ passengers: newPassengers, position: -5 })
// Display passengers should update now (train reset)
expect(result.current.displayPassengers).toBe(newPassengers)
})
test('updates passengers immediately during same route', () => {
const updatedPassengers: Passenger[] = [{ ...mockPassengers[0], isBoarded: true }]
const { result, rerender } = renderHook(
({ passengers, position }) =>
useTrackManagement({
currentRoute: 1,
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers,
maxCars: 5,
carSpacing: 7,
}),
{
initialProps: { passengers: mockPassengers, position: 50 },
}
)
// Update passengers (boarding) during same route
rerender({ passengers: updatedPassengers, position: 55 })
// Display passengers should update immediately (same route, gameplay update)
expect(result.current.displayPassengers).toBe(updatedPassengers)
})
test('returns null when no track data', () => {
// Create a hook where trackGenerator returns null
const nullTrackGenerator = {
generateTrack: vi.fn(() => null),
} as unknown as RailroadTrackGenerator
const { result } = renderHook(() =>
useTrackManagement({
currentRoute: 1,
trainPosition: 0,
trackGenerator: nullTrackGenerator,
pathRef: mockPathRef,
stations: mockStations,
passengers: mockPassengers,
})
)
expect(result.current.trackData).toBeNull()
})
})

View File

@@ -0,0 +1,298 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
import { useTrainTransforms } from '../useTrainTransforms'
describe('useTrainTransforms', () => {
let mockPathRef: React.RefObject<SVGPathElement>
let mockTrackGenerator: RailroadTrackGenerator
beforeEach(() => {
// Create mock path element
const mockPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
mockPathRef = { current: mockPath }
// Mock track generator
mockTrackGenerator = {
getTrainTransform: vi.fn((_path: SVGPathElement, position: number) => ({
x: position * 10,
y: 300,
rotation: position / 10,
})),
} as unknown as RailroadTrackGenerator
vi.clearAllMocks()
})
test('returns default transform when pathRef is null', () => {
const nullPathRef: React.RefObject<SVGPathElement> = { current: null }
const { result } = renderHook(() =>
useTrainTransforms({
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: nullPathRef,
maxCars: 5,
carSpacing: 7,
})
)
expect(result.current.trainTransform).toEqual({ x: 50, y: 300, rotation: 0 })
expect(result.current.trainCars).toHaveLength(5)
})
test('calculates train transform at given position', () => {
const { result } = renderHook(() =>
useTrainTransforms({
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7,
})
)
expect(result.current.trainTransform).toEqual({
x: 500, // 50 * 10
y: 300,
rotation: 5, // 50 / 10
})
})
test('updates transform when train position changes', () => {
const { result, rerender } = renderHook(
({ position }) =>
useTrainTransforms({
trainPosition: position,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7,
}),
{ initialProps: { position: 20 } }
)
expect(result.current.trainTransform.x).toBe(200)
rerender({ position: 60 })
expect(result.current.trainTransform.x).toBe(600)
})
test('calculates correct number of train cars', () => {
const { result } = renderHook(() =>
useTrainTransforms({
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7,
})
)
expect(result.current.trainCars).toHaveLength(5)
})
test('respects custom maxCars parameter', () => {
const { result } = renderHook(() =>
useTrainTransforms({
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 3,
carSpacing: 7,
})
)
expect(result.current.trainCars).toHaveLength(3)
})
test('respects custom carSpacing parameter', () => {
const { result } = renderHook(() =>
useTrainTransforms({
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 10,
})
)
// First car should be at position 50 - 10 = 40
expect(result.current.trainCars[0].position).toBe(40)
})
test('positions cars behind locomotive with correct spacing', () => {
const { result } = renderHook(() =>
useTrainTransforms({
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 3,
carSpacing: 10,
})
)
expect(result.current.trainCars[0].position).toBe(40) // 50 - 1*10
expect(result.current.trainCars[1].position).toBe(30) // 50 - 2*10
expect(result.current.trainCars[2].position).toBe(20) // 50 - 3*10
})
test('calculates locomotive opacity correctly during fade in', () => {
// Fade in range: 3-8%
const { result: result1 } = renderHook(() =>
useTrainTransforms({
trainPosition: 3,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7,
})
)
expect(result1.current.locomotiveOpacity).toBe(0)
const { result: result2 } = renderHook(() =>
useTrainTransforms({
trainPosition: 5.5, // Midpoint between 3 and 8
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7,
})
)
expect(result2.current.locomotiveOpacity).toBe(0.5)
const { result: result3 } = renderHook(() =>
useTrainTransforms({
trainPosition: 8,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7,
})
)
expect(result3.current.locomotiveOpacity).toBe(1)
})
test('calculates locomotive opacity correctly during fade out', () => {
// Fade out range: 92-97%
const { result: result1 } = renderHook(() =>
useTrainTransforms({
trainPosition: 92,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7,
})
)
expect(result1.current.locomotiveOpacity).toBe(1)
const { result: result2 } = renderHook(() =>
useTrainTransforms({
trainPosition: 94.5, // Midpoint between 92 and 97
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7,
})
)
expect(result2.current.locomotiveOpacity).toBe(0.5)
const { result: result3 } = renderHook(() =>
useTrainTransforms({
trainPosition: 97,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7,
})
)
expect(result3.current.locomotiveOpacity).toBe(0)
})
test('locomotive is fully visible in middle of track', () => {
const { result } = renderHook(() =>
useTrainTransforms({
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7,
})
)
expect(result.current.locomotiveOpacity).toBe(1)
})
test('calculates car opacity independently for each car', () => {
const { result } = renderHook(() =>
useTrainTransforms({
trainPosition: 10, // Locomotive at 10%, first car at 3% (fading in)
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 2,
carSpacing: 7,
})
)
// First car at position 3 should be starting to fade in
expect(result.current.trainCars[0].position).toBe(3)
expect(result.current.trainCars[0].opacity).toBe(0)
// Second car at position -4 should be invisible (not yet entered)
expect(result.current.trainCars[1].position).toBe(0) // clamped to 0
expect(result.current.trainCars[1].opacity).toBe(0)
})
test('car positions cannot go below zero', () => {
const { result } = renderHook(() =>
useTrainTransforms({
trainPosition: 5,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 3,
carSpacing: 7,
})
)
// First car at 5 - 7 = -2, should be clamped to 0
expect(result.current.trainCars[0].position).toBe(0)
// Second car at 5 - 14 = -9, should be clamped to 0
expect(result.current.trainCars[1].position).toBe(0)
})
test('cars fade out completely past 97%', () => {
const { result } = renderHook(() =>
useTrainTransforms({
trainPosition: 104, // Last car at 104 - 35 = 69% (5 cars * 7 spacing)
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7,
})
)
const lastCar = result.current.trainCars[4]
expect(lastCar.position).toBe(69)
expect(lastCar.opacity).toBe(1) // Still visible, not past 97%
})
test('memoizes car transforms to avoid recalculation on same inputs', () => {
const { result, rerender } = renderHook(() =>
useTrainTransforms({
trainPosition: 50,
trackGenerator: mockTrackGenerator,
pathRef: mockPathRef,
maxCars: 5,
carSpacing: 7,
})
)
const firstCars = result.current.trainCars
// Rerender with same props
rerender()
// Should be the exact same array reference (memoized)
expect(result.current.trainCars).toBe(firstCars)
})
})

View File

@@ -0,0 +1,126 @@
import { useEffect } from 'react'
import { type CommentaryContext, getAICommentary } from '../components/AISystem/aiCommentary'
import { useComplementRace } from '../context/ComplementRaceContext'
import { useSoundEffects } from './useSoundEffects'
export function useAIRacers() {
const { state, dispatch } = useComplementRace()
const { playSound } = useSoundEffects()
useEffect(() => {
if (!state.isGameActive) return
// Update AI positions every 200ms (line 11690)
const aiUpdateInterval = setInterval(() => {
const newPositions = state.aiRacers.map((racer) => {
// Base speed with random variance (0.6-1.4 range via Math.random() * 0.8 + 0.6)
const variance = Math.random() * 0.8 + 0.6
let speed = racer.speed * variance * state.speedMultiplier
// Rubber-banding: AI speeds up 2x when >10 units behind player (line 11697-11699)
const distanceBehind = state.correctAnswers - racer.position
if (distanceBehind > 10) {
speed *= 2
}
// Update position
const newPosition = racer.position + speed
return {
id: racer.id,
position: newPosition,
}
})
dispatch({ type: 'UPDATE_AI_POSITIONS', positions: newPositions })
// Check for AI win in practice mode (line 14151)
if (state.style === 'practice' && state.isGameActive) {
const winningAI = state.aiRacers.find((racer, index) => {
const updatedPosition = newPositions[index]?.position || racer.position
return updatedPosition >= state.raceGoal
})
if (winningAI) {
// Play game over sound (line 14193)
playSound('gameOver')
// End the game
dispatch({ type: 'END_RACE' })
// Show results after a short delay
setTimeout(() => {
dispatch({ type: 'SHOW_RESULTS' })
}, 1500)
return // Exit early to prevent further updates
}
}
// Check for commentary triggers after position updates
state.aiRacers.forEach((racer) => {
const updatedPosition =
newPositions.find((p) => p.id === racer.id)?.position || racer.position
const distanceBehind = state.correctAnswers - updatedPosition
const distanceAhead = updatedPosition - state.correctAnswers
// Detect passing events
const playerJustPassed =
racer.previousPosition > state.correctAnswers && updatedPosition < state.correctAnswers
const aiJustPassed =
racer.previousPosition < state.correctAnswers && updatedPosition > state.correctAnswers
// Determine commentary context
let context: CommentaryContext | null = null
if (playerJustPassed) {
context = 'player_passed'
} else if (aiJustPassed) {
context = 'ai_passed'
} else if (distanceBehind > 20) {
// Player has lapped the AI (more than 20 units behind)
context = 'lapped'
} else if (distanceBehind > 10) {
// AI is desperate to catch up (rubber-banding active)
context = 'desperate_catchup'
} else if (distanceAhead > 5) {
// AI is significantly ahead
context = 'ahead'
} else if (distanceBehind > 3) {
// AI is behind
context = 'behind'
}
// Trigger commentary if context is valid
if (context) {
const message = getAICommentary(racer, context, state.correctAnswers, updatedPosition)
if (message) {
dispatch({
type: 'TRIGGER_AI_COMMENTARY',
racerId: racer.id,
message,
context,
})
// Play special turbo sound when AI goes desperate (line 11941)
if (context === 'desperate_catchup') {
playSound('ai_turbo', 0.12)
}
}
}
})
}, 200)
return () => clearInterval(aiUpdateInterval)
}, [
state.isGameActive,
state.aiRacers,
state.correctAnswers,
state.speedMultiplier,
dispatch, // Play game over sound (line 14193)
playSound,
state.raceGoal,
state.style,
])
return {
aiRacers: state.aiRacers,
}
}

View File

@@ -0,0 +1,345 @@
import { useComplementRace } from '../context/ComplementRaceContext'
import type { PairPerformance } from '../lib/gameTypes'
export function useAdaptiveDifficulty() {
const { state, dispatch } = useComplementRace()
// Track performance after each answer (lines 14495-14553)
const trackPerformance = (isCorrect: boolean, responseTime: number) => {
if (!state.currentQuestion) return
const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}`
// Get or create performance data for this pair
const pairData: PairPerformance = state.difficultyTracker.pairPerformance.get(pairKey) || {
attempts: 0,
correct: 0,
avgTime: 0,
difficulty: 1,
}
// Update performance data
pairData.attempts++
if (isCorrect) {
pairData.correct++
}
// Update average time (rolling average)
const totalTime = pairData.avgTime * (pairData.attempts - 1) + responseTime
pairData.avgTime = totalTime / pairData.attempts
// Calculate pair-specific difficulty (lines 14555-14576)
if (pairData.attempts >= 2) {
const accuracyRate = pairData.correct / pairData.attempts
const avgTime = pairData.avgTime
let difficulty = 1
if (accuracyRate >= 0.9 && avgTime < 1500) {
difficulty = 1 // Very easy
} else if (accuracyRate >= 0.8 && avgTime < 2000) {
difficulty = 2 // Easy
} else if (accuracyRate >= 0.7 || avgTime < 2500) {
difficulty = 3 // Medium
} else if (accuracyRate >= 0.5 || avgTime < 3500) {
difficulty = 4 // Hard
} else {
difficulty = 5 // Very hard
}
pairData.difficulty = difficulty
}
// Update difficulty tracker in state
const newPairPerformance = new Map(state.difficultyTracker.pairPerformance)
newPairPerformance.set(pairKey, pairData)
// Update consecutive counters
const newTracker = {
...state.difficultyTracker,
pairPerformance: newPairPerformance,
consecutiveCorrect: isCorrect ? state.difficultyTracker.consecutiveCorrect + 1 : 0,
consecutiveIncorrect: !isCorrect ? state.difficultyTracker.consecutiveIncorrect + 1 : 0,
}
// Adapt global difficulty (lines 14578-14605)
if (newTracker.consecutiveCorrect >= 3) {
// Reduce time limit (increase difficulty)
newTracker.currentTimeLimit = Math.max(
1000,
newTracker.currentTimeLimit - newTracker.currentTimeLimit * newTracker.adaptationRate
)
} else if (newTracker.consecutiveIncorrect >= 2) {
// Increase time limit (decrease difficulty)
newTracker.currentTimeLimit = Math.min(
5000,
newTracker.currentTimeLimit + newTracker.baseTimeLimit * newTracker.adaptationRate
)
}
// Update overall difficulty level
const avgDifficulty =
Array.from(newTracker.pairPerformance.values()).reduce(
(sum, data) => sum + data.difficulty,
0
) / Math.max(1, newTracker.pairPerformance.size)
newTracker.difficultyLevel = Math.round(avgDifficulty)
// Exit learning mode after sufficient data (lines 14548-14552)
if (
newTracker.pairPerformance.size >= 5 &&
Array.from(newTracker.pairPerformance.values()).some((data) => data.attempts >= 3)
) {
newTracker.learningMode = false
}
// Dispatch update
dispatch({ type: 'UPDATE_DIFFICULTY_TRACKER', tracker: newTracker })
// Adapt AI speeds based on player performance
adaptAISpeeds(newTracker)
}
// Calculate recent success rate (lines 14685-14693)
const calculateRecentSuccessRate = (): number => {
const recentQuestions = Math.min(10, state.totalQuestions)
if (recentQuestions === 0) return 0.5 // Default for first question
// Use global tracking for recent performance
const recentCorrect = Math.max(
0,
state.correctAnswers - Math.max(0, state.totalQuestions - recentQuestions)
)
return recentCorrect / recentQuestions
}
// Calculate average response time (lines 14695-14705)
const calculateAverageResponseTime = (): number => {
const recentPairs = Array.from(state.difficultyTracker.pairPerformance.values())
.filter((data) => data.attempts >= 1)
.slice(-5) // Last 5 different pairs encountered
if (recentPairs.length === 0) return 3000 // Default for learning mode
const totalTime = recentPairs.reduce((sum, data) => sum + data.avgTime, 0)
return totalTime / recentPairs.length
}
// Adapt AI speeds based on performance (lines 14607-14683)
const adaptAISpeeds = (tracker: typeof state.difficultyTracker) => {
// Don't adapt during learning mode
if (tracker.learningMode) return
const playerSuccessRate = calculateRecentSuccessRate()
const avgResponseTime = calculateAverageResponseTime()
// Base speed multipliers for each race mode
let baseSpeedMultiplier: number
switch (state.style) {
case 'practice':
baseSpeedMultiplier = 0.7
break
case 'sprint':
baseSpeedMultiplier = 0.9
break
case 'survival':
baseSpeedMultiplier = state.speedMultiplier * state.survivalMultiplier
break
default:
baseSpeedMultiplier = 0.7
}
// Calculate adaptive multiplier based on player performance
let adaptiveMultiplier = 1.0
// Success rate factor (0.5x to 1.6x based on success rate)
if (playerSuccessRate > 0.85) {
adaptiveMultiplier *= 1.6 // Player doing great - speed up AI significantly
} else if (playerSuccessRate > 0.75) {
adaptiveMultiplier *= 1.3 // Player doing well - speed up AI moderately
} else if (playerSuccessRate > 0.6) {
adaptiveMultiplier *= 1.0 // Player doing okay - keep AI at base speed
} else if (playerSuccessRate > 0.45) {
adaptiveMultiplier *= 0.75 // Player struggling - slow down AI
} else {
adaptiveMultiplier *= 0.5 // Player really struggling - significantly slow AI
}
// Response time factor - faster players get faster AI
if (avgResponseTime < 1500) {
adaptiveMultiplier *= 1.2 // Very fast player
} else if (avgResponseTime < 2500) {
adaptiveMultiplier *= 1.1 // Fast player
} else if (avgResponseTime > 4000) {
adaptiveMultiplier *= 0.9 // Slow player
}
// Streak bonus - players on hot streaks get more challenge
if (state.streak >= 8) {
adaptiveMultiplier *= 1.3
} else if (state.streak >= 5) {
adaptiveMultiplier *= 1.15
}
// Apply bounds to prevent extreme values
adaptiveMultiplier = Math.max(0.3, Math.min(2.0, adaptiveMultiplier))
// Update AI speeds with adaptive multiplier
const finalSpeedMultiplier = baseSpeedMultiplier * adaptiveMultiplier
// Update AI racer speeds
const updatedRacers = state.aiRacers.map((racer, index) => {
if (index === 0) {
// Swift AI (more aggressive)
return { ...racer, speed: 0.32 * finalSpeedMultiplier }
} else {
// Math Bot (more consistent)
return { ...racer, speed: 0.2 * finalSpeedMultiplier }
}
})
dispatch({ type: 'UPDATE_AI_SPEEDS', racers: updatedRacers })
// Debug logging for AI adaptation (every 5 questions)
if (state.totalQuestions % 5 === 0) {
console.log('🤖 AI Speed Adaptation:', {
playerSuccessRate: `${Math.round(playerSuccessRate * 100)}%`,
avgResponseTime: `${Math.round(avgResponseTime)}ms`,
streak: state.streak,
adaptiveMultiplier: Math.round(adaptiveMultiplier * 100) / 100,
swiftAISpeed: updatedRacers[0] ? Math.round(updatedRacers[0].speed * 1000) / 1000 : 0,
mathBotSpeed: updatedRacers[1] ? Math.round(updatedRacers[1].speed * 1000) / 1000 : 0,
})
}
}
// Get adaptive time limit for current question (lines 14740-14763)
const getAdaptiveTimeLimit = (): number => {
if (!state.currentQuestion) return 3000
let adaptiveTime: number
if (state.difficultyTracker.learningMode) {
adaptiveTime = Math.max(2000, state.difficultyTracker.currentTimeLimit)
} else {
const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}`
const pairData = state.difficultyTracker.pairPerformance.get(pairKey)
if (pairData && pairData.attempts >= 2) {
// Use pair-specific difficulty
const baseTime = state.difficultyTracker.baseTimeLimit
const difficultyMultiplier = (6 - pairData.difficulty) / 5 // Invert: difficulty 1 = more time
adaptiveTime = Math.max(1000, baseTime * difficultyMultiplier)
} else {
// Default for new pairs
adaptiveTime = state.difficultyTracker.currentTimeLimit
}
}
// Apply user timeout setting override (lines 14765-14785)
return applyTimeoutSetting(adaptiveTime)
}
// Apply timeout setting multiplier (lines 14765-14785)
const applyTimeoutSetting = (baseTime: number): number => {
switch (state.timeoutSetting) {
case 'preschool':
return Math.max(baseTime * 4, 20000) // At least 20 seconds
case 'kindergarten':
return Math.max(baseTime * 3, 15000) // At least 15 seconds
case 'relaxed':
return Math.max(baseTime * 2.4, 12000) // At least 12 seconds
case 'slow':
return Math.max(baseTime * 1.6, 8000) // At least 8 seconds
case 'normal':
return Math.max(baseTime, 5000) // At least 5 seconds
case 'fast':
return Math.max(baseTime * 0.6, 3000) // At least 3 seconds
case 'expert':
return Math.max(baseTime * 0.4, 2000) // At least 2 seconds
default:
return baseTime
}
}
// Get adaptive feedback message (lines 11655-11721)
const getAdaptiveFeedbackMessage = (
pairKey: string,
_isCorrect: boolean,
_responseTime: number
): { message: string; type: 'learning' | 'struggling' | 'mastered' | 'adapted' } | null => {
const pairData = state.difficultyTracker.pairPerformance.get(pairKey)
const [num1, num2, _sum] = pairKey.split('_').map(Number)
// Learning mode messages
if (state.difficultyTracker.learningMode) {
const encouragements = [
"🧠 I'm learning your style! Keep going!",
'📊 Building your skill profile...',
'🎯 Every answer helps me understand you better!',
'🚀 Analyzing your complement superpowers!',
]
return {
message: encouragements[Math.floor(Math.random() * encouragements.length)],
type: 'learning',
}
}
// After learning - provide specific feedback
if (pairData && pairData.attempts >= 3) {
const accuracy = pairData.correct / pairData.attempts
const avgTime = pairData.avgTime
// Struggling pairs (< 60% accuracy)
if (accuracy < 0.6) {
const strugglingMessages = [
`💪 ${num1}+${num2} needs practice - I'm giving you extra time!`,
`🎯 Working on ${num1}+${num2} - you've got this!`,
`⏰ Taking it slower with ${num1}+${num2} - no rush!`,
`🧩 ${num1}+${num2} is getting special attention from me!`,
]
return {
message: strugglingMessages[Math.floor(Math.random() * strugglingMessages.length)],
type: 'struggling',
}
}
// Mastered pairs (> 85% accuracy and fast)
if (accuracy > 0.85 && avgTime < 2000) {
const masteredMessages = [
`${num1}+${num2} = MASTERED! Lightning mode activated!`,
`🔥 You've conquered ${num1}+${num2} - speeding it up!`,
`🏆 ${num1}+${num2} expert detected! Challenge mode ON!`,
`${num1}+${num2} is your superpower! Going faster!`,
]
return {
message: masteredMessages[Math.floor(Math.random() * masteredMessages.length)],
type: 'mastered',
}
}
}
// Show adaptation when difficulty changes
if (state.difficultyTracker.consecutiveCorrect >= 3) {
return {
message: "🚀 You're on fire! Increasing the challenge!",
type: 'adapted',
}
} else if (state.difficultyTracker.consecutiveIncorrect >= 2) {
return {
message: "🤗 Let's slow down a bit - I'm here to help!",
type: 'adapted',
}
}
return null
}
return {
trackPerformance,
getAdaptiveTimeLimit,
calculateRecentSuccessRate,
calculateAverageResponseTime,
getAdaptiveFeedbackMessage,
}
}

View File

@@ -0,0 +1,69 @@
import { useCallback, useEffect } from 'react'
import { useComplementRace } from '../context/ComplementRaceContext'
export function useGameLoop() {
const { state, dispatch } = useComplementRace()
// Generate first question when game begins
useEffect(() => {
if (state.gamePhase === 'playing' && !state.currentQuestion) {
dispatch({ type: 'NEXT_QUESTION' })
}
}, [state.gamePhase, state.currentQuestion, dispatch])
const nextQuestion = useCallback(() => {
if (!state.isGameActive) return
dispatch({ type: 'NEXT_QUESTION' })
}, [state.isGameActive, dispatch])
const submitAnswer = useCallback(
(answer: number) => {
if (!state.currentQuestion) return
const isCorrect = answer === state.currentQuestion.correctAnswer
if (isCorrect) {
// Update score, streak, progress
// TODO: Will implement full scoring in next step
dispatch({ type: 'SUBMIT_ANSWER', answer })
// Move to next question
dispatch({ type: 'NEXT_QUESTION' })
} else {
// Reset streak
// TODO: Will implement incorrect answer handling
dispatch({ type: 'SUBMIT_ANSWER', answer })
}
},
[state.currentQuestion, dispatch]
)
const startCountdown = useCallback(() => {
// Trigger countdown phase
dispatch({ type: 'START_COUNTDOWN' })
// Start 3-2-1-GO countdown (lines 11163-11211)
let count = 3
const countdownInterval = setInterval(() => {
if (count > 0) {
// TODO: Play countdown sound
count--
} else {
// GO!
// TODO: Play start sound
clearInterval(countdownInterval)
// Start the actual game after GO animation (1 second delay)
setTimeout(() => {
dispatch({ type: 'BEGIN_GAME' })
}, 1000)
}
}, 1000)
}, [dispatch])
return {
nextQuestion,
submitAnswer,
startCountdown,
}
}

View File

@@ -0,0 +1,166 @@
import { useEffect, useRef, useState } from 'react'
import type { Passenger, Station } from '../lib/gameTypes'
import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator'
export interface BoardingAnimation {
passenger: Passenger
fromX: number
fromY: number
toX: number
toY: number
carIndex: number
startTime: number
}
export interface DisembarkingAnimation {
passenger: Passenger
fromX: number
fromY: number
toX: number
toY: number
startTime: number
}
interface UsePassengerAnimationsParams {
passengers: Passenger[]
stations: Station[]
stationPositions: Array<{ x: number; y: number }>
trainPosition: number
trackGenerator: RailroadTrackGenerator
pathRef: React.RefObject<SVGPathElement>
}
export function usePassengerAnimations({
passengers,
stations,
stationPositions,
trainPosition,
trackGenerator,
pathRef,
}: UsePassengerAnimationsParams) {
const [boardingAnimations, setBoardingAnimations] = useState<Map<string, BoardingAnimation>>(
new Map()
)
const [disembarkingAnimations, setDisembarkingAnimations] = useState<
Map<string, DisembarkingAnimation>
>(new Map())
const previousPassengersRef = useRef<Passenger[]>(passengers)
// Detect passengers boarding/disembarking and start animations
useEffect(() => {
if (!pathRef.current || stationPositions.length === 0) return
const previousPassengers = previousPassengersRef.current
const currentPassengers = passengers
// Find newly boarded passengers
const newlyBoarded = currentPassengers.filter((curr) => {
const prev = previousPassengers.find((p) => p.id === curr.id)
return curr.isBoarded && prev && !prev.isBoarded
})
// Find newly delivered passengers
const newlyDelivered = currentPassengers.filter((curr) => {
const prev = previousPassengers.find((p) => p.id === curr.id)
return curr.isDelivered && prev && !prev.isDelivered
})
// Start animation for each newly boarded passenger
newlyBoarded.forEach((passenger) => {
// Find origin station
const originStation = stations.find((s) => s.id === passenger.originStationId)
if (!originStation) return
const stationIndex = stations.indexOf(originStation)
const stationPos = stationPositions[stationIndex]
if (!stationPos) return
// Find which car this passenger will be in
const boardedPassengers = currentPassengers.filter((p) => p.isBoarded && !p.isDelivered)
const carIndex = boardedPassengers.indexOf(passenger)
// Calculate train car position
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * 7) // 7% spacing
const carTransform = trackGenerator.getTrainTransform(pathRef.current!, carPosition)
// Create boarding animation
const animation: BoardingAnimation = {
passenger,
fromX: stationPos.x,
fromY: stationPos.y - 30,
toX: carTransform.x,
toY: carTransform.y,
carIndex,
startTime: Date.now(),
}
setBoardingAnimations((prev) => {
const next = new Map(prev)
next.set(passenger.id, animation)
return next
})
// Remove animation after 800ms
setTimeout(() => {
setBoardingAnimations((prev) => {
const next = new Map(prev)
next.delete(passenger.id)
return next
})
}, 800)
})
// Start animation for each newly delivered passenger
newlyDelivered.forEach((passenger) => {
// Find destination station
const destinationStation = stations.find((s) => s.id === passenger.destinationStationId)
if (!destinationStation) return
const stationIndex = stations.indexOf(destinationStation)
const stationPos = stationPositions[stationIndex]
if (!stationPos) return
// Find which car this passenger was in (before delivery)
const prevBoardedPassengers = previousPassengers.filter((p) => p.isBoarded && !p.isDelivered)
const carIndex = prevBoardedPassengers.findIndex((p) => p.id === passenger.id)
if (carIndex === -1) return
// Calculate train car position at time of disembarking
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * 7) // 7% spacing
const carTransform = trackGenerator.getTrainTransform(pathRef.current!, carPosition)
// Create disembarking animation (from car to station)
const animation: DisembarkingAnimation = {
passenger,
fromX: carTransform.x,
fromY: carTransform.y,
toX: stationPos.x,
toY: stationPos.y - 30,
startTime: Date.now(),
}
setDisembarkingAnimations((prev) => {
const next = new Map(prev)
next.set(passenger.id, animation)
return next
})
// Remove animation after 800ms
setTimeout(() => {
setDisembarkingAnimations((prev) => {
const next = new Map(prev)
next.delete(passenger.id)
return next
})
}, 800)
})
// Update ref
previousPassengersRef.current = currentPassengers
}, [passengers, stations, stationPositions, trainPosition, trackGenerator, pathRef])
return {
boardingAnimations,
disembarkingAnimations,
}
}

View File

@@ -0,0 +1,468 @@
import { useCallback, useRef } from 'react'
/**
* Web Audio API sound effects system
* Generates retro 90s-style arcade sounds programmatically
*
* Based on original implementation from web_generator.py lines 14218-14490
*/
interface Note {
freq: number
time: number
duration: number
}
export function useSoundEffects() {
const audioContextsRef = useRef<AudioContext[]>([])
/**
* Helper function to play multi-note 90s arcade sounds
*/
const play90sSound = useCallback(
(
audioContext: AudioContext,
notes: Note[],
volume: number = 0.15,
waveType: OscillatorType = 'sine'
) => {
notes.forEach((note) => {
const oscillator = audioContext.createOscillator()
const gainNode = audioContext.createGain()
const filterNode = audioContext.createBiquadFilter()
// Create that classic 90s arcade sound chain
oscillator.connect(filterNode)
filterNode.connect(gainNode)
gainNode.connect(audioContext.destination)
// Set wave type for that retro flavor
oscillator.type = waveType
// Add some 90s-style filtering
filterNode.type = 'lowpass'
filterNode.frequency.setValueAtTime(2000, audioContext.currentTime + note.time)
filterNode.Q.setValueAtTime(1, audioContext.currentTime + note.time)
// Set frequency and add vibrato for that classic arcade wobble
oscillator.frequency.setValueAtTime(note.freq, audioContext.currentTime + note.time)
if (waveType === 'sawtooth' || waveType === 'square') {
// Add slight vibrato for extra 90s flavor
oscillator.frequency.exponentialRampToValueAtTime(
note.freq * 1.02,
audioContext.currentTime + note.time + note.duration * 0.5
)
oscillator.frequency.exponentialRampToValueAtTime(
note.freq,
audioContext.currentTime + note.time + note.duration
)
}
// Classic arcade envelope - quick attack, moderate decay
gainNode.gain.setValueAtTime(0, audioContext.currentTime + note.time)
gainNode.gain.exponentialRampToValueAtTime(
volume,
audioContext.currentTime + note.time + 0.01
)
gainNode.gain.exponentialRampToValueAtTime(
volume * 0.7,
audioContext.currentTime + note.time + note.duration * 0.7
)
gainNode.gain.exponentialRampToValueAtTime(
0.001,
audioContext.currentTime + note.time + note.duration
)
oscillator.start(audioContext.currentTime + note.time)
oscillator.stop(audioContext.currentTime + note.time + note.duration)
})
},
[]
)
/**
* Play a sound effect
* @param type - Sound type (correct, incorrect, countdown, etc.)
* @param volume - Volume level (0-1), default 0.15
*/
const playSound = useCallback(
(
type:
| 'correct'
| 'incorrect'
| 'timeout'
| 'countdown'
| 'race_start'
| 'celebration'
| 'lap_celebration'
| 'gameOver'
| 'ai_turbo'
| 'milestone'
| 'streak'
| 'combo'
| 'whoosh'
| 'train_chuff'
| 'train_whistle'
| 'coal_spill'
| 'steam_hiss',
volume: number = 0.15
) => {
try {
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
// Track audio contexts for cleanup
audioContextsRef.current.push(audioContext)
switch (type) {
case 'correct':
// Classic 90s "power-up" sound - ascending beeps
play90sSound(
audioContext,
[
{ freq: 523, time: 0, duration: 0.08 }, // C5
{ freq: 659, time: 0.08, duration: 0.08 }, // E5
{ freq: 784, time: 0.16, duration: 0.12 }, // G5
],
volume,
'sawtooth'
)
break
case 'incorrect':
// Classic arcade "error" sound - descending buzz
play90sSound(
audioContext,
[
{ freq: 400, time: 0, duration: 0.15 },
{ freq: 300, time: 0.05, duration: 0.15 },
{ freq: 200, time: 0.1, duration: 0.2 },
],
volume * 0.8,
'square'
)
break
case 'timeout':
// Classic "time's up" alarm
play90sSound(
audioContext,
[
{ freq: 800, time: 0, duration: 0.1 },
{ freq: 600, time: 0.1, duration: 0.1 },
{ freq: 800, time: 0.2, duration: 0.1 },
{ freq: 600, time: 0.3, duration: 0.15 },
],
volume,
'square'
)
break
case 'countdown':
// Classic arcade countdown beep
play90sSound(
audioContext,
[{ freq: 800, time: 0, duration: 0.15 }],
volume * 0.6,
'sine'
)
break
case 'race_start':
// Epic race start fanfare
play90sSound(
audioContext,
[
{ freq: 523, time: 0, duration: 0.1 }, // C5
{ freq: 659, time: 0.1, duration: 0.1 }, // E5
{ freq: 784, time: 0.2, duration: 0.1 }, // G5
{ freq: 1046, time: 0.3, duration: 0.3 }, // C6 - triumphant!
],
volume * 1.2,
'sawtooth'
)
break
case 'celebration':
// Classic victory fanfare - like completing a level
play90sSound(
audioContext,
[
{ freq: 523, time: 0, duration: 0.12 }, // C5
{ freq: 659, time: 0.12, duration: 0.12 }, // E5
{ freq: 784, time: 0.24, duration: 0.12 }, // G5
{ freq: 1046, time: 0.36, duration: 0.24 }, // C6
{ freq: 1318, time: 0.6, duration: 0.3 }, // E6 - epic finish!
],
volume * 1.5,
'sawtooth'
)
break
case 'lap_celebration':
// Radical "bonus achieved" sound
play90sSound(
audioContext,
[
{ freq: 1046, time: 0, duration: 0.08 }, // C6
{ freq: 1318, time: 0.08, duration: 0.08 }, // E6
{ freq: 1568, time: 0.16, duration: 0.08 }, // G6
{ freq: 2093, time: 0.24, duration: 0.15 }, // C7 - totally rad!
],
volume * 1.3,
'sawtooth'
)
break
case 'gameOver':
// Classic "game over" descending tones
play90sSound(
audioContext,
[
{ freq: 400, time: 0, duration: 0.2 },
{ freq: 350, time: 0.2, duration: 0.2 },
{ freq: 300, time: 0.4, duration: 0.2 },
{ freq: 250, time: 0.6, duration: 0.3 },
{ freq: 200, time: 0.9, duration: 0.4 },
],
volume,
'triangle'
)
break
case 'ai_turbo':
// Sound when AI goes into turbo mode
play90sSound(
audioContext,
[
{ freq: 200, time: 0, duration: 0.05 },
{ freq: 400, time: 0.05, duration: 0.05 },
{ freq: 600, time: 0.1, duration: 0.05 },
{ freq: 800, time: 0.15, duration: 0.1 },
],
volume * 0.7,
'sawtooth'
)
break
case 'milestone':
// Rad milestone sound - like collecting a power-up
play90sSound(
audioContext,
[
{ freq: 659, time: 0, duration: 0.1 }, // E5
{ freq: 784, time: 0.1, duration: 0.1 }, // G5
{ freq: 880, time: 0.2, duration: 0.1 }, // A5
{ freq: 1046, time: 0.3, duration: 0.15 }, // C6 - awesome!
],
volume * 1.1,
'sawtooth'
)
break
case 'streak':
// Epic streak sound - getting hot!
play90sSound(
audioContext,
[
{ freq: 880, time: 0, duration: 0.06 }, // A5
{ freq: 1046, time: 0.06, duration: 0.06 }, // C6
{ freq: 1318, time: 0.12, duration: 0.08 }, // E6
{ freq: 1760, time: 0.2, duration: 0.1 }, // A6 - on fire!
],
volume * 1.2,
'sawtooth'
)
break
case 'combo':
// Gnarly combo sound - for rapid correct answers
play90sSound(
audioContext,
[
{ freq: 1046, time: 0, duration: 0.04 }, // C6
{ freq: 1175, time: 0.04, duration: 0.04 }, // D6
{ freq: 1318, time: 0.08, duration: 0.04 }, // E6
{ freq: 1480, time: 0.12, duration: 0.06 }, // F#6
],
volume * 0.9,
'square'
)
break
case 'whoosh': {
// Cool whoosh sound for fast responses
const whooshOsc = audioContext.createOscillator()
const whooshGain = audioContext.createGain()
const whooshFilter = audioContext.createBiquadFilter()
whooshOsc.connect(whooshFilter)
whooshFilter.connect(whooshGain)
whooshGain.connect(audioContext.destination)
whooshOsc.type = 'sawtooth'
whooshFilter.type = 'highpass'
whooshFilter.frequency.setValueAtTime(1000, audioContext.currentTime)
whooshFilter.frequency.exponentialRampToValueAtTime(100, audioContext.currentTime + 0.3)
whooshOsc.frequency.setValueAtTime(400, audioContext.currentTime)
whooshOsc.frequency.exponentialRampToValueAtTime(800, audioContext.currentTime + 0.15)
whooshOsc.frequency.exponentialRampToValueAtTime(200, audioContext.currentTime + 0.3)
whooshGain.gain.setValueAtTime(0, audioContext.currentTime)
whooshGain.gain.exponentialRampToValueAtTime(
volume * 0.6,
audioContext.currentTime + 0.02
)
whooshGain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.3)
whooshOsc.start(audioContext.currentTime)
whooshOsc.stop(audioContext.currentTime + 0.3)
break
}
case 'train_chuff': {
// Realistic steam train chuffing sound
const chuffOsc = audioContext.createOscillator()
const chuffGain = audioContext.createGain()
const chuffFilter = audioContext.createBiquadFilter()
chuffOsc.connect(chuffFilter)
chuffFilter.connect(chuffGain)
chuffGain.connect(audioContext.destination)
chuffOsc.type = 'sawtooth'
chuffFilter.type = 'bandpass'
chuffFilter.frequency.setValueAtTime(150, audioContext.currentTime)
chuffFilter.Q.setValueAtTime(5, audioContext.currentTime)
chuffOsc.frequency.setValueAtTime(80, audioContext.currentTime)
chuffOsc.frequency.exponentialRampToValueAtTime(120, audioContext.currentTime + 0.05)
chuffOsc.frequency.exponentialRampToValueAtTime(60, audioContext.currentTime + 0.2)
chuffGain.gain.setValueAtTime(0, audioContext.currentTime)
chuffGain.gain.exponentialRampToValueAtTime(
volume * 0.8,
audioContext.currentTime + 0.01
)
chuffGain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.2)
chuffOsc.start(audioContext.currentTime)
chuffOsc.stop(audioContext.currentTime + 0.2)
break
}
case 'train_whistle':
// Classic steam train whistle
play90sSound(
audioContext,
[
{ freq: 523, time: 0, duration: 0.3 }, // C5 - long whistle
{ freq: 659, time: 0.1, duration: 0.4 }, // E5 - harmony
{ freq: 523, time: 0.3, duration: 0.2 }, // C5 - fade out
],
volume * 1.2,
'sine'
)
break
case 'coal_spill': {
// Coal chunks spilling sound effect
const coalOsc = audioContext.createOscillator()
const coalGain = audioContext.createGain()
const coalFilter = audioContext.createBiquadFilter()
coalOsc.connect(coalFilter)
coalFilter.connect(coalGain)
coalGain.connect(audioContext.destination)
coalOsc.type = 'square'
coalFilter.type = 'lowpass'
coalFilter.frequency.setValueAtTime(300, audioContext.currentTime)
// Simulate coal chunks falling with random frequency bursts
coalOsc.frequency.setValueAtTime(200 + Math.random() * 100, audioContext.currentTime)
coalOsc.frequency.exponentialRampToValueAtTime(
100 + Math.random() * 50,
audioContext.currentTime + 0.1
)
coalOsc.frequency.exponentialRampToValueAtTime(
80 + Math.random() * 40,
audioContext.currentTime + 0.3
)
coalGain.gain.setValueAtTime(0, audioContext.currentTime)
coalGain.gain.exponentialRampToValueAtTime(
volume * 0.6,
audioContext.currentTime + 0.01
)
coalGain.gain.exponentialRampToValueAtTime(
volume * 0.3,
audioContext.currentTime + 0.15
)
coalGain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.4)
coalOsc.start(audioContext.currentTime)
coalOsc.stop(audioContext.currentTime + 0.4)
break
}
case 'steam_hiss': {
// Steam hissing sound for locomotive
const steamOsc = audioContext.createOscillator()
const steamGain = audioContext.createGain()
const steamFilter = audioContext.createBiquadFilter()
steamOsc.connect(steamFilter)
steamFilter.connect(steamGain)
steamGain.connect(audioContext.destination)
steamOsc.type = 'triangle'
steamFilter.type = 'highpass'
steamFilter.frequency.setValueAtTime(2000, audioContext.currentTime)
steamOsc.frequency.setValueAtTime(4000 + Math.random() * 1000, audioContext.currentTime)
steamGain.gain.setValueAtTime(0, audioContext.currentTime)
steamGain.gain.exponentialRampToValueAtTime(
volume * 0.4,
audioContext.currentTime + 0.02
)
steamGain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.6)
steamOsc.start(audioContext.currentTime)
steamOsc.stop(audioContext.currentTime + 0.6)
break
}
}
} catch (_e) {
console.log('🎵 Web Audio not supported - missing out on rad 90s sounds!')
}
},
[play90sSound]
)
/**
* Stop all currently playing sounds
*/
const stopAllSounds = useCallback(() => {
try {
if (audioContextsRef.current.length > 0) {
audioContextsRef.current.forEach((context) => {
try {
context.close()
} catch (_e) {
// Ignore errors
}
})
audioContextsRef.current = []
}
} catch (e) {
console.log('🔇 Sound cleanup error:', e)
}
}, [])
return {
playSound,
stopAllSounds,
}
}

View File

@@ -0,0 +1,457 @@
import { useEffect, useRef } from 'react'
import { useComplementRace } from '../context/ComplementRaceContext'
import { calculateMaxConcurrentPassengers, generatePassengers } from '../lib/passengerGenerator'
import { useSoundEffects } from './useSoundEffects'
/**
* Steam Sprint momentum system (Infinite Mode)
*
* Momentum mechanics:
* - Each correct answer adds momentum (builds up steam pressure)
* - Momentum decays over time based on skill level
* - Train automatically advances to next route upon completion
* - Game continues indefinitely until player quits
* - Time-of-day cycle repeats every 60 seconds
*
* Skill level decay rates (momentum lost per second):
* - Preschool: 2.0/s (very slow decay)
* - Kindergarten: 3.5/s
* - Relaxed: 5.0/s
* - Slow: 7.0/s
* - Normal: 9.0/s
* - Fast: 11.0/s
* - Expert: 13.0/s (rapid decay)
*/
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 // Momentum added for each correct answer
const SPEED_MULTIPLIER = 0.15 // Convert momentum to speed (% per second at momentum=100)
const UPDATE_INTERVAL = 50 // Update every 50ms (~20 fps)
const GAME_DURATION = 60000 // 60 seconds in milliseconds
export function useSteamJourney() {
const { state, dispatch } = useComplementRace()
const { playSound } = useSoundEffects()
const gameStartTimeRef = useRef<number>(0)
const lastUpdateRef = useRef<number>(0)
const routeExitThresholdRef = useRef<number>(107) // Default for 1 car: 100 + 7
// Initialize game start time and generate initial passengers
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])
// Momentum decay and position update loop
useEffect(() => {
if (!state.isGameActive || state.style !== 'sprint') return
const interval = setInterval(() => {
const now = Date.now()
const elapsed = now - gameStartTimeRef.current
const deltaTime = now - lastUpdateRef.current
lastUpdateRef.current = now
// Steam Sprint is infinite - no time limit
// Get decay rate based on timeout setting (skill level)
const decayRate = MOMENTUM_DECAY_RATES[state.timeoutSetting] || 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,
})
// 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)
// 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 = true
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})`)
console.log(
` Status: ${p.isDelivered ? 'DELIVERED' : p.isBoarded ? 'BOARDED' : 'WAITING'}`
)
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)}`)
}
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'}`)
})
}
// FIRST: Identify which passengers will be delivered in this frame
const passengersToDeliver = new Set<string>()
currentBoardedPassengers.forEach((passenger, carIndex) => {
if (!passenger || passenger.isDelivered) 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)
const distance = Math.abs(carPosition - station.position)
// If this car is at the destination station (within 5% tolerance), mark for delivery
if (distance < 5) {
passengersToDeliver.add(passenger.id)
}
})
// Build a map of which cars are occupied (excluding passengers being delivered this frame)
const occupiedCars = new Map<number, (typeof currentBoardedPassengers)[0]>()
currentBoardedPassengers.forEach((passenger, arrayIndex) => {
// 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 (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
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)
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
dispatch({
type: 'DELIVER_PASSENGER',
passengerId: passenger.id,
points,
})
} else if (DEBUG_PASSENGER_BOARDING) {
console.log(
`${passenger.name} in Car ${carIndex} heading to ${station.emoji} ${station.name}`
)
console.log(
` Car position: ${carPosition.toFixed(2)}, Station: ${station.position}, Distance: ${distance.toFixed(2)}`
)
}
})
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
if (
trainPosition >= ENTIRE_TRAIN_EXIT_THRESHOLD &&
state.trainPosition < ENTIRE_TRAIN_EXIT_THRESHOLD
) {
// Play celebration whistle
playSound('train_whistle', 0.6)
setTimeout(() => {
playSound('celebration', 0.4)
}, 800)
// Auto-advance to next route
const nextRoute = state.currentRoute + 1
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
}
}, 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])
// Add momentum on correct answer
useEffect(() => {
// Only for sprint mode
if (state.style !== 'sprint') return
// This effect triggers when correctAnswers increases
// We use a ref to track previous value to detect changes
}, [state.style])
// Function to boost momentum (called when answer is correct)
const boostMomentum = () => {
if (state.style !== 'sprint') return
const newMomentum = Math.min(100, state.momentum + MOMENTUM_GAIN_PER_CORRECT)
dispatch({
type: 'UPDATE_STEAM_JOURNEY',
momentum: newMomentum,
trainPosition: state.trainPosition, // Keep current position
pressure: state.pressure,
elapsedTime: state.elapsedTime,
})
}
// Calculate time of day period (0-5 for 6 periods, cycles infinitely)
const getTimeOfDayPeriod = (): number => {
if (state.elapsedTime === 0) return 0
const periodDuration = GAME_DURATION / 6
return Math.floor(state.elapsedTime / periodDuration) % 6
}
// Get sky gradient colors based on time of day
const getSkyGradient = (): { top: string; bottom: string } => {
const period = getTimeOfDayPeriod()
// 6 periods over 60 seconds: dawn → morning → midday → afternoon → dusk → night
const gradients = [
{ top: '#1e3a8a', bottom: '#f59e0b' }, // Dawn - deep blue to orange
{ top: '#3b82f6', bottom: '#fbbf24' }, // Morning - blue to yellow
{ top: '#60a5fa', bottom: '#93c5fd' }, // Midday - bright blue
{ top: '#3b82f6', bottom: '#f59e0b' }, // Afternoon - blue to orange
{ top: '#7c3aed', bottom: '#f97316' }, // Dusk - purple to orange
{ top: '#1e1b4b', bottom: '#312e81' }, // Night - dark purple
]
return gradients[period] || gradients[0]
}
return {
boostMomentum,
getTimeOfDayPeriod,
getSkyGradient,
}
}

View File

@@ -0,0 +1,142 @@
import { useEffect, useRef, useState } from 'react'
import type { Passenger, Station } from '../lib/gameTypes'
import { generateLandmarks, type Landmark } from '../lib/landmarks'
import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator'
interface UseTrackManagementParams {
currentRoute: number
trainPosition: number
trackGenerator: RailroadTrackGenerator
pathRef: React.RefObject<SVGPathElement>
stations: Station[]
passengers: Passenger[]
maxCars: number
carSpacing: number
}
export function useTrackManagement({
currentRoute,
trainPosition,
trackGenerator,
pathRef,
stations,
passengers,
maxCars: _maxCars,
carSpacing: _carSpacing,
}: UseTrackManagementParams) {
const [trackData, setTrackData] = useState<ReturnType<
typeof trackGenerator.generateTrack
> | null>(null)
const [tiesAndRails, setTiesAndRails] = useState<{
ties: Array<{ x1: number; y1: number; x2: number; y2: number }>
leftRailPath: string
rightRailPath: string
} | null>(null)
const [stationPositions, setStationPositions] = useState<Array<{ x: number; y: number }>>([])
const [landmarks, setLandmarks] = useState<Landmark[]>([])
const [landmarkPositions, setLandmarkPositions] = useState<Array<{ x: number; y: number }>>([])
const [displayPassengers, setDisplayPassengers] = useState<Passenger[]>(passengers)
// Track previous route data to maintain visuals during transition
const previousRouteRef = useRef(currentRoute)
const [pendingTrackData, setPendingTrackData] = useState<ReturnType<
typeof trackGenerator.generateTrack
> | null>(null)
const displayRouteRef = useRef(currentRoute) // Track which route's passengers are being displayed
// Generate landmarks when route changes
useEffect(() => {
const newLandmarks = generateLandmarks(currentRoute)
setLandmarks(newLandmarks)
}, [currentRoute])
// Generate track on mount and when route changes
useEffect(() => {
const track = trackGenerator.generateTrack(currentRoute)
// If we're in the middle of a route (position > 0), store as pending
// Only apply new track when position resets to beginning (< 0)
if (trainPosition > 0 && previousRouteRef.current !== currentRoute) {
setPendingTrackData(track)
} else {
setTrackData(track)
previousRouteRef.current = currentRoute
setPendingTrackData(null)
}
}, [trackGenerator, currentRoute, trainPosition])
// Apply pending track when train resets to beginning
useEffect(() => {
if (pendingTrackData && trainPosition < 0) {
setTrackData(pendingTrackData)
previousRouteRef.current = currentRoute
setPendingTrackData(null)
}
}, [pendingTrackData, trainPosition, currentRoute])
// 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
const sameRoute = currentRoute === displayRouteRef.current
const inMiddleOfTrack = trainPosition >= 10 && trainPosition < 90 // Avoid start/end transition zones
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)
setDisplayPassengers(passengers)
}
// Otherwise, keep displaying old passengers until train resets
}, [passengers, trainPosition, currentRoute])
// Generate ties and rails when path is ready
useEffect(() => {
if (pathRef.current && trackData) {
const result = trackGenerator.generateTiesAndRails(pathRef.current)
setTiesAndRails(result)
}
}, [trackData, trackGenerator, pathRef])
// Calculate station positions when path is ready
useEffect(() => {
if (pathRef.current) {
const positions = stations.map((station) => {
const pathLength = pathRef.current!.getTotalLength()
const distance = (station.position / 100) * pathLength
const point = pathRef.current!.getPointAtLength(distance)
return { x: point.x, y: point.y }
})
setStationPositions(positions)
}
}, [stations, pathRef])
// Calculate landmark positions when path is ready
useEffect(() => {
if (pathRef.current && landmarks.length > 0) {
const positions = landmarks.map((landmark) => {
const pathLength = pathRef.current!.getTotalLength()
const distance = (landmark.position / 100) * pathLength
const point = pathRef.current!.getPointAtLength(distance)
return {
x: point.x + landmark.offset.x,
y: point.y + landmark.offset.y,
}
})
setLandmarkPositions(positions)
}
}, [landmarks, pathRef])
return {
trackData,
tiesAndRails,
stationPositions,
landmarks,
landmarkPositions,
displayPassengers,
}
}

View File

@@ -0,0 +1,117 @@
import { useEffect, useMemo, useState } from 'react'
import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator'
interface TrainTransform {
x: number
y: number
rotation: number
}
interface TrainCarTransform extends TrainTransform {
position: number
opacity: number
}
interface UseTrainTransformsParams {
trainPosition: number
trackGenerator: RailroadTrackGenerator
pathRef: React.RefObject<SVGPathElement>
maxCars: number
carSpacing: number
}
export function useTrainTransforms({
trainPosition,
trackGenerator,
pathRef,
maxCars,
carSpacing,
}: UseTrainTransformsParams) {
const [trainTransform, setTrainTransform] = useState<TrainTransform>({
x: 50,
y: 300,
rotation: 0,
})
// Update train position and rotation
useEffect(() => {
if (pathRef.current) {
const transform = trackGenerator.getTrainTransform(pathRef.current, trainPosition)
setTrainTransform(transform)
}
}, [trainPosition, trackGenerator, pathRef])
// Calculate train car transforms (each car follows behind the locomotive)
const trainCars = useMemo((): TrainCarTransform[] => {
if (!pathRef.current) {
return Array.from({ length: maxCars }, () => ({
x: 0,
y: 0,
rotation: 0,
position: 0,
opacity: 0,
}))
}
return Array.from({ length: maxCars }).map((_, carIndex) => {
// Calculate position for this car (behind the locomotive)
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * carSpacing)
// Calculate opacity: fade in at left tunnel (3-8%), fade out at right tunnel (92-97%)
const fadeInStart = 3
const fadeInEnd = 8
const fadeOutStart = 92
const fadeOutEnd = 97
let opacity = 1 // Default to fully visible
// Fade in from left tunnel
if (carPosition <= fadeInStart) {
opacity = 0
} else if (carPosition < fadeInEnd) {
opacity = (carPosition - fadeInStart) / (fadeInEnd - fadeInStart)
}
// Fade out into right tunnel
else if (carPosition >= fadeOutEnd) {
opacity = 0
} else if (carPosition > fadeOutStart) {
opacity = 1 - (carPosition - fadeOutStart) / (fadeOutEnd - fadeOutStart)
}
return {
...trackGenerator.getTrainTransform(pathRef.current!, carPosition),
position: carPosition,
opacity,
}
})
}, [trainPosition, trackGenerator, pathRef, maxCars, carSpacing])
// Calculate locomotive opacity (fade in/out through tunnels)
const locomotiveOpacity = useMemo(() => {
const fadeInStart = 3
const fadeInEnd = 8
const fadeOutStart = 92
const fadeOutEnd = 97
// Fade in from left tunnel
if (trainPosition <= fadeInStart) {
return 0
} else if (trainPosition < fadeInEnd) {
return (trainPosition - fadeInStart) / (fadeInEnd - fadeInStart)
}
// Fade out into right tunnel
else if (trainPosition >= fadeOutEnd) {
return 0
} else if (trainPosition > fadeOutStart) {
return 1 - (trainPosition - fadeOutStart) / (fadeOutEnd - fadeOutStart)
}
return 1 // Default to fully visible
}, [trainPosition])
return {
trainTransform,
trainCars,
locomotiveOpacity,
}
}

Some files were not shown because too many files have changed in this diff Show More