Compare commits
210 Commits
abacus-rea
...
v2.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9262a2c83 | ||
|
|
4792dde1be | ||
|
|
f91248b0bb | ||
|
|
eedce28572 | ||
|
|
d84bf9c845 | ||
|
|
4f8aaf04aa | ||
|
|
a43c8654e1 | ||
|
|
f554592272 | ||
|
|
b073b9e1ec | ||
|
|
2df8cdc88e | ||
|
|
e73afdb913 | ||
|
|
2a77d755b7 | ||
|
|
f4ab0ff9ba | ||
|
|
4e721f765a | ||
|
|
8bd6d6d8b7 | ||
|
|
43077a80e2 | ||
|
|
1f527581b8 | ||
|
|
fa4547543d | ||
|
|
81f50323ad | ||
|
|
480960c2c8 | ||
|
|
d3b2e0b2e1 | ||
|
|
6168c292d5 | ||
|
|
5c65ac5caa | ||
|
|
51bf448c9f | ||
|
|
4ab1aef9b8 | ||
|
|
7ba746b6bd | ||
|
|
6a51c1e9bd | ||
|
|
af8d993628 | ||
|
|
a9175a050c | ||
|
|
9165014997 | ||
|
|
4758ad2f26 | ||
|
|
fbd8cd4a6b | ||
|
|
1f45c17e0a | ||
|
|
2248c34215 | ||
|
|
ea1b1a2f69 | ||
|
|
d00abd25e7 | ||
|
|
d5a8a2a14c | ||
|
|
22541df99f | ||
|
|
ccd0d6d94c | ||
|
|
619be9859c | ||
|
|
7c0e6b142b | ||
|
|
70d6f43d6d | ||
|
|
4153929a2a | ||
|
|
c7ad3c0695 | ||
|
|
3b3cad4b76 | ||
|
|
ff16303a7c | ||
|
|
9d4cba05be | ||
|
|
ae1318e8bf | ||
|
|
abc2ea50d0 | ||
|
|
158f52773d | ||
|
|
7829d8a0fb | ||
|
|
a216a3d343 | ||
|
|
aa1ad315ef | ||
|
|
92ef1360a4 | ||
|
|
b62cf26fb6 | ||
|
|
fe01a1fe18 | ||
|
|
6f940e24d6 | ||
|
|
10d8aaf814 | ||
|
|
5d5afd4e68 | ||
|
|
dd0df8c274 | ||
|
|
a3878a8537 | ||
|
|
1234e6ce60 | ||
|
|
5e7b273b33 | ||
|
|
43be7ac83a | ||
|
|
571664e725 | ||
|
|
416dc897e2 | ||
|
|
2e041ddc44 | ||
|
|
ae4e8fcb5a | ||
|
|
81d17f2397 | ||
|
|
e85d0415f2 | ||
|
|
2b94cad11b | ||
|
|
79f44b25d6 | ||
|
|
42b73cf8ee | ||
|
|
2960eef2b5 | ||
|
|
e6ebecb09b | ||
|
|
93f4cb0b11 | ||
|
|
c5ebc635af | ||
|
|
491b299e28 | ||
|
|
bb9959f7fb | ||
|
|
88ac0b9bcb | ||
|
|
2ed7b2cbf8 | ||
|
|
63b0b552a8 | ||
|
|
b7233f9e4a | ||
|
|
db56ce89ee | ||
|
|
4f79c08d73 | ||
|
|
46d4af2bda | ||
|
|
6c90a68c49 | ||
|
|
9ea15535d1 | ||
|
|
65dafc9215 | ||
|
|
e06a750454 | ||
|
|
5d2083903e | ||
|
|
a9e0d19734 | ||
|
|
fe9ea67f56 | ||
|
|
78d5234a79 | ||
|
|
5ae22e4645 | ||
|
|
d9acc0efea | ||
|
|
05bb035db5 | ||
|
|
a1f2b9736a | ||
|
|
a2512d5738 | ||
|
|
32abde107c | ||
|
|
fad8636763 | ||
|
|
f2e71657dc | ||
|
|
e704a28524 | ||
|
|
96782b0e7a | ||
|
|
01766944f0 | ||
|
|
93cb070ca5 | ||
|
|
1613912740 | ||
|
|
651bc21583 | ||
|
|
8ad3144d2d | ||
|
|
076c97abac | ||
|
|
227cfabf11 | ||
|
|
1b11031598 | ||
|
|
4ac8758957 | ||
|
|
db52e14dfe | ||
|
|
8c8b8e08b4 | ||
|
|
ee48417abf | ||
|
|
99cdfa8a0b | ||
|
|
7488bb3803 | ||
|
|
1a5fa2873b | ||
|
|
f7419bc6a0 | ||
|
|
eadd7da6db | ||
|
|
05a3ddb086 | ||
|
|
c928e90785 | ||
|
|
ebc6894746 | ||
|
|
0bcd7a30d4 | ||
|
|
5c5954be74 | ||
|
|
863a2e1319 | ||
|
|
4b6888af05 | ||
|
|
32c3a35eab | ||
|
|
3ea88d7a5a | ||
|
|
1a8093416e | ||
|
|
12c54b27b7 | ||
|
|
73a59745a5 | ||
|
|
3920bbad33 | ||
|
|
80e33e25b3 | ||
|
|
600bc35bc3 | ||
|
|
920aaa6398 | ||
|
|
1ff9695f69 | ||
|
|
a08f0535bf | ||
|
|
f3bc2f6d92 | ||
|
|
8c3a855239 | ||
|
|
90ba86640c | ||
|
|
90ad789ff1 | ||
|
|
159990489f | ||
|
|
4bbdabc3b5 | ||
|
|
23a9016245 | ||
|
|
582bce411f | ||
|
|
d8b4e425bf | ||
|
|
2c88b6b5f3 | ||
|
|
b2a21b79ad | ||
|
|
616a50e234 | ||
|
|
72f8dee183 | ||
|
|
d0a3bc7dc1 | ||
|
|
9a3fa93e53 | ||
|
|
982fa45c08 | ||
|
|
b58bcd92ee | ||
|
|
5c8c18cbb8 | ||
|
|
edfdd81227 | ||
|
|
6049a7f6b7 | ||
|
|
b85968bcb6 | ||
|
|
4f2a661494 | ||
|
|
31898328a3 | ||
|
|
98cfa5645b | ||
|
|
e3f552d8f5 | ||
|
|
66f52234e1 | ||
|
|
57a72e34a5 | ||
|
|
28495767a9 | ||
|
|
d67315f771 | ||
|
|
fa1cf96789 | ||
|
|
8792393956 | ||
|
|
386c88a3c0 | ||
|
|
a854fe3dc9 | ||
|
|
402724c80e | ||
|
|
c77e880be3 | ||
|
|
6c0bf7b0f7 | ||
|
|
ae4b71b986 | ||
|
|
bf046c999b | ||
|
|
183706dade | ||
|
|
54ff20c755 | ||
|
|
7a3e34b4fa | ||
|
|
a935e5aed8 | ||
|
|
dd9f688a32 | ||
|
|
0b9bfed12d | ||
|
|
af7725622e | ||
|
|
18af9730ff | ||
|
|
beff64652c | ||
|
|
c4d6691715 | ||
|
|
dcefa74902 | ||
|
|
8973241297 | ||
|
|
7f8c90acea | ||
|
|
111c0ced71 | ||
|
|
876ace50ec | ||
|
|
d58053fad3 | ||
|
|
899fc6975f | ||
|
|
c95be1df6d | ||
|
|
a387b030fa | ||
|
|
90b9ffa0d8 | ||
|
|
3fa314aaa5 | ||
|
|
0c46f3a7ba | ||
|
|
1fe12c4837 | ||
|
|
b7e7c4beff | ||
|
|
423ba55350 | ||
|
|
885fc725dc | ||
|
|
0311b0fe03 | ||
|
|
cea5fadbe4 | ||
|
|
9191b12493 | ||
|
|
3dcff2ff88 | ||
|
|
301e65dfa6 | ||
|
|
00bfcbcdee | ||
|
|
0a4bf1765c |
@@ -151,9 +151,39 @@
|
||||
"Bash(git pull:*)",
|
||||
"WebFetch(domain:antialias.github.io)",
|
||||
"Bash(open http://localhost:3006/games/matching)",
|
||||
"Bash(gh api:*)"
|
||||
"Bash(gh api:*)",
|
||||
"Bash(npx playwright test:*)",
|
||||
"Bash(open http://localhost:3001/games/matching)",
|
||||
"Bash(open http://localhost:3001/games/memory-quiz)",
|
||||
"Bash(open http://localhost:3002)",
|
||||
"Bash(open \"data:text/html,<script>console.log(''Current localStorage:'', localStorage.getItem(''soroban-abacus-display-config'')); localStorage.setItem(''soroban-abacus-display-config'', JSON.stringify({colorScheme: ''place-value'', beadShape: ''diamond'', hideInactiveBeads: false, coloredNumerals: false, scaleFactor: 1.0, soundEnabled: true, soundVolume: 0.8})); console.log(''After test save:'', localStorage.getItem(''soroban-abacus-display-config''));</script><h1>Check browser console</h1>\")",
|
||||
"Bash(npx playwright:*)",
|
||||
"Bash(open \"data:text/html,<script>\nconsole.log(''Current localStorage:'', localStorage.getItem(''soroban-abacus-display-config'')); \nlocalStorage.setItem(''soroban-abacus-display-config'', JSON.stringify({\n colorScheme: ''place-value'', \n beadShape: ''diamond'', \n hideInactiveBeads: false, \n coloredNumerals: false, \n scaleFactor: 1.0, \n soundEnabled: true, \n soundVolume: 0.8\n})); \nconsole.log(''After test save:'', localStorage.getItem(''soroban-abacus-display-config''));\n</script><h1>Check browser console</h1>\")",
|
||||
"Bash(xargs sed:*)",
|
||||
"Bash(open http://localhost:3003/games/matching)",
|
||||
"Bash(open http://localhost:3003/arcade/matching)",
|
||||
"Bash(open http://localhost:3000)",
|
||||
"Bash(open http://localhost:3003/games/memory-quiz)",
|
||||
"Bash(open http://localhost:3001)",
|
||||
"Bash(open http://localhost:3001/arcade)",
|
||||
"Bash(open http://localhost:6006)",
|
||||
"Bash(open http://localhost:3002/games/matching)",
|
||||
"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(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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Ignore development files
|
||||
# Ignore all node_modules to prevent Docker overlay conflicts
|
||||
node_modules
|
||||
**/node_modules
|
||||
.next
|
||||
.git
|
||||
.github
|
||||
|
||||
2
.github/workflows/deploy-storybook.yml
vendored
2
.github/workflows/deploy-storybook.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -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:
|
||||
|
||||
11
.github/workflows/publish-abacus-react.yml
vendored
11
.github/workflows/publish-abacus-react.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8.0.0
|
||||
version: 8.15.6
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
@@ -52,7 +52,10 @@ jobs:
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: |
|
||||
echo "Installing dependencies..."
|
||||
pnpm install
|
||||
echo "Dependencies installed successfully"
|
||||
|
||||
- name: Build abacus-react package
|
||||
run: pnpm --filter @soroban/abacus-react build
|
||||
@@ -69,7 +72,7 @@ jobs:
|
||||
- name: Configure npm for GitHub Packages
|
||||
working-directory: packages/abacus-react
|
||||
run: |
|
||||
echo "//npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }}" > .npmrc
|
||||
echo "//npm.pkg.github.com/:_authToken=${{ secrets.NPM_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}" > .npmrc
|
||||
echo "@soroban:registry=https://npm.pkg.github.com" >> .npmrc
|
||||
echo "registry=https://npm.pkg.github.com" >> .npmrc
|
||||
|
||||
@@ -144,7 +147,7 @@ jobs:
|
||||
|
||||
# Set authentication and registry for GitHub Packages
|
||||
echo "Publishing with explicit authentication..."
|
||||
NPM_CONFIG_USERCONFIG=.npmrc NODE_AUTH_TOKEN="${{ secrets.GITHUB_TOKEN }}" npm publish --registry=https://npm.pkg.github.com
|
||||
NPM_CONFIG_USERCONFIG=.npmrc NODE_AUTH_TOKEN="${{ secrets.NPM_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}" npm publish --registry=https://npm.pkg.github.com
|
||||
else
|
||||
echo "No new abacus-react version tag found, skipping publish"
|
||||
fi
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/verify-examples.yml
vendored
2
.github/workflows/verify-examples.yml
vendored
@@ -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
11
.gitignore
vendored
@@ -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/
|
||||
|
||||
308
CHANGELOG.md
308
CHANGELOG.md
@@ -1,3 +1,311 @@
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
# 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
|
||||
|
||||
# Copy package files for dependency resolution
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json .npmrc ./
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY packages/core/client/node/package.json ./packages/core/client/node/
|
||||
COPY packages/core/client/typescript/package.json ./packages/core/client/typescript/
|
||||
COPY packages/abacus-react/package.json ./packages/abacus-react/
|
||||
COPY packages/templates/package.json ./packages/templates/
|
||||
|
||||
# Install dependencies
|
||||
# Install dependencies (will use .npmrc with hoisted mode)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Builder stage
|
||||
|
||||
14
Makefile
14
Makefile
@@ -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)
|
||||
|
||||
14
README.md
14
README.md
@@ -773,4 +773,16 @@ make verify-examples
|
||||
|
||||
MIT License - see LICENSE file for details.
|
||||
|
||||
This project uses DejaVu Sans font (included), which is released under a free license.
|
||||
This project uses DejaVu Sans font (included), which is released under a free license.
|
||||
---
|
||||
|
||||
## 🚀 Active Development Projects
|
||||
|
||||
### Speed Complement Race Port (In Progress)
|
||||
**Status**: Planning Complete, Ready to Implement
|
||||
**Plan Document**: [`apps/web/COMPLEMENT_RACE_PORT_PLAN.md`](./apps/web/COMPLEMENT_RACE_PORT_PLAN.md)
|
||||
**Source**: `packages/core/src/web_generator.py` (lines 10956-15113)
|
||||
**Target**: `apps/web/src/app/games/complement-race/`
|
||||
|
||||
A comprehensive port of the sophisticated Speed Complement Race game from standalone HTML to Next.js. Features 3 game modes, 2 AI personalities with 82 unique commentary messages, adaptive difficulty, and multiple visualization systems.
|
||||
|
||||
|
||||
1170
apps/web/COMPLEMENT_RACE_PORT_PLAN.md
Normal file
1170
apps/web/COMPLEMENT_RACE_PORT_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
350
apps/web/__tests__/api-abacus-settings.e2e.test.ts
Normal file
350
apps/web/__tests__/api-abacus-settings.e2e.test.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { db, schema } from '../src/db'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
/**
|
||||
* 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))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
506
apps/web/__tests__/api-players.e2e.test.ts
Normal file
506
apps/web/__tests__/api-players.e2e.test.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { db, schema } from '../src/db'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
/**
|
||||
* 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))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
192
apps/web/__tests__/api-user-stats.e2e.test.ts
Normal file
192
apps/web/__tests__/api-user-stats.e2e.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { db, schema } from '../src/db'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
/**
|
||||
* 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
124
apps/web/__tests__/middleware.e2e.test.ts
Normal file
124
apps/web/__tests__/middleware.e2e.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { middleware } from '../src/middleware'
|
||||
import { verifyGuestToken, GUEST_COOKIE_NAME } from '../src/lib/guest-token'
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
109
apps/web/docs/arcade-rooms-implementation-tasks.md
Normal file
109
apps/web/docs/arcade-rooms-implementation-tasks.md
Normal 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...')"
|
||||
}
|
||||
```
|
||||
895
apps/web/docs/arcade-rooms-technical-plan.md
Normal file
895
apps/web/docs/arcade-rooms-technical-plan.md
Normal 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! 🚀**
|
||||
12
apps/web/drizzle.config.ts
Normal file
12
apps/web/drizzle.config.ts
Normal 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
|
||||
32
apps/web/drizzle/0000_third_carnage.sql
Normal file
32
apps/web/drizzle/0000_third_carnage.sql
Normal 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
|
||||
);
|
||||
16
apps/web/drizzle/0001_friendly_stingray.sql
Normal file
16
apps/web/drizzle/0001_friendly_stingray.sql
Normal 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
|
||||
);
|
||||
13
apps/web/drizzle/0002_loose_ultimatum.sql
Normal file
13
apps/web/drizzle/0002_loose_ultimatum.sql
Normal 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
|
||||
);
|
||||
31
apps/web/drizzle/0003_naive_reptil.sql
Normal file
31
apps/web/drizzle/0003_naive_reptil.sql
Normal 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);
|
||||
236
apps/web/drizzle/meta/0000_snapshot.json
Normal file
236
apps/web/drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,236 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
363
apps/web/drizzle/meta/0001_snapshot.json
Normal file
363
apps/web/drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,363 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
459
apps/web/drizzle/meta/0002_snapshot.json
Normal file
459
apps/web/drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,459 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
681
apps/web/drizzle/meta/0003_snapshot.json
Normal file
681
apps/web/drizzle/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,681 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
34
apps/web/drizzle/meta/_journal.json
Normal file
34
apps/web/drizzle/meta/_journal.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
319
apps/web/e2e/arcade-modal-session.spec.ts
Normal file
319
apps/web/e2e/arcade-modal-session.spec.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { test, expect } 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/)
|
||||
})
|
||||
})
|
||||
111
apps/web/e2e/mini-nav-persistence.spec.ts
Normal file
111
apps/web/e2e/mini-nav-persistence.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { test, expect } 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 }) => {
|
||||
// Override baseURL for this test to match running dev server
|
||||
const baseURL = 'http://localhost:3000'
|
||||
|
||||
// Start at home page
|
||||
await page.goto(baseURL)
|
||||
|
||||
// Navigate to games page - should not have game name in mini nav
|
||||
await page.click('a[href="/games"]')
|
||||
await page.waitForURL('/games')
|
||||
|
||||
// Check that mini nav doesn't show game name initially
|
||||
const initialGameName = page.locator('[data-testid="mini-nav-game-name"]')
|
||||
await expect(initialGameName).not.toBeVisible()
|
||||
|
||||
// Navigate to Memory Pairs game
|
||||
await page.click('a[href="/games/matching"]')
|
||||
await page.waitForURL('/games/matching')
|
||||
|
||||
// Verify game name appears in mini nav
|
||||
const memoryPairsName = page.locator('text=🧩 Memory Pairs')
|
||||
await expect(memoryPairsName).toBeVisible()
|
||||
|
||||
// Navigate back to games page using mini nav
|
||||
await page.click('a[href="/games"]')
|
||||
await page.waitForURL('/games')
|
||||
|
||||
// BUG: Game name should disappear but it persists
|
||||
// This test should FAIL initially, demonstrating the bug
|
||||
await expect(memoryPairsName).not.toBeVisible()
|
||||
|
||||
// Also test with Memory Lightning game
|
||||
await page.click('a[href="/games/memory-quiz"]')
|
||||
await page.waitForURL('/games/memory-quiz')
|
||||
|
||||
// Verify Memory Lightning name appears
|
||||
const memoryLightningName = page.locator('text=🧠 Memory Lightning')
|
||||
await expect(memoryLightningName).toBeVisible()
|
||||
|
||||
// Navigate back to games page
|
||||
await page.click('a[href="/games"]')
|
||||
await page.waitForURL('/games')
|
||||
|
||||
// Game name should disappear
|
||||
await expect(memoryLightningName).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should show correct game name when switching between different games', async ({ page }) => {
|
||||
// Override baseURL for this test to match running dev server
|
||||
const baseURL = 'http://localhost:3000'
|
||||
|
||||
// Start at Memory Pairs
|
||||
await page.goto(`${baseURL}/games/matching`)
|
||||
await expect(page.locator('text=🧩 Memory Pairs')).toBeVisible()
|
||||
|
||||
// Switch to Memory Lightning
|
||||
await page.click('a[href="/games/memory-quiz"]')
|
||||
await page.waitForURL('/games/memory-quiz')
|
||||
|
||||
// Should show Memory Lightning and NOT Memory Pairs
|
||||
await expect(page.locator('text=🧠 Memory Lightning')).toBeVisible()
|
||||
await expect(page.locator('text=🧩 Memory Pairs')).not.toBeVisible()
|
||||
|
||||
// Switch back to Memory Pairs
|
||||
await page.click('a[href="/games/matching"]')
|
||||
await page.waitForURL('/games/matching')
|
||||
|
||||
// Should show Memory Pairs and NOT Memory Lightning
|
||||
await expect(page.locator('text=🧩 Memory Pairs')).toBeVisible()
|
||||
await expect(page.locator('text=🧠 Memory Lightning')).not.toBeVisible()
|
||||
})
|
||||
|
||||
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'
|
||||
|
||||
// Start at Memory Pairs game - should show game name
|
||||
await page.goto(`${baseURL}/games/matching`)
|
||||
const memoryPairsName = page.locator('text=🧩 Memory Pairs')
|
||||
await expect(memoryPairsName).toBeVisible()
|
||||
|
||||
// Navigate to Guide page - game name should disappear
|
||||
await page.click('a[href="/guide"]')
|
||||
await page.waitForURL('/guide')
|
||||
await expect(memoryPairsName).not.toBeVisible()
|
||||
|
||||
// Navigate to Games page - game name should still be gone
|
||||
await page.click('a[href="/games"]')
|
||||
await page.waitForURL('/games')
|
||||
await expect(memoryPairsName).not.toBeVisible()
|
||||
|
||||
// Test another path: Game -> Create -> Games
|
||||
await page.goto(`${baseURL}/games/memory-quiz`)
|
||||
const memoryLightningName = page.locator('text=🧠 Memory Lightning')
|
||||
await expect(memoryLightningName).toBeVisible()
|
||||
|
||||
// Navigate to Create page
|
||||
await page.click('a[href="/create"]')
|
||||
await page.waitForURL('/create')
|
||||
await expect(memoryLightningName).not.toBeVisible()
|
||||
|
||||
// Navigate to Games page - should not show any game name
|
||||
await page.click('a[href="/games"]')
|
||||
await page.waitForURL('/games')
|
||||
await expect(memoryLightningName).not.toBeVisible()
|
||||
await expect(memoryPairsName).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
73
apps/web/e2e/nav-slot.spec.ts
Normal file
73
apps/web/e2e/nav-slot.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Game navigation slots', () => {
|
||||
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
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Look for the game name in the navigation
|
||||
const gameNav = page.locator('[data-testid="nav-slot"], h1:has-text("Memory Pairs")')
|
||||
await expect(gameNav).toBeVisible()
|
||||
await expect(gameNav).toContainText('Memory Pairs')
|
||||
})
|
||||
|
||||
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
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Look for the game name in the navigation
|
||||
const gameNav = page.locator('[data-testid="nav-slot"], h1:has-text("Memory Lightning")')
|
||||
await expect(gameNav).toBeVisible()
|
||||
await expect(gameNav).toContainText('Memory Lightning')
|
||||
})
|
||||
|
||||
test('should maintain game name in nav after page reload', async ({ page }) => {
|
||||
// Navigate to matching game
|
||||
await page.goto('/games/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Verify game name appears
|
||||
const gameNav = page.locator('h1:has-text("Memory Pairs")')
|
||||
await expect(gameNav).toBeVisible()
|
||||
|
||||
// Reload the page
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Verify game name still appears after reload
|
||||
await expect(gameNav).toBeVisible()
|
||||
await expect(gameNav).toContainText('Memory Pairs')
|
||||
})
|
||||
|
||||
test('should show different game names when navigating between games', async ({ page }) => {
|
||||
// Start with matching game
|
||||
await page.goto('/games/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const matchingNav = page.locator('h1:has-text("Memory Pairs")')
|
||||
await expect(matchingNav).toBeVisible()
|
||||
|
||||
// Navigate to memory quiz
|
||||
await page.goto('/games/memory-quiz')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const quizNav = page.locator('h1:has-text("Memory Lightning")')
|
||||
await expect(quizNav).toBeVisible()
|
||||
|
||||
// Verify the matching game name is gone
|
||||
await expect(matchingNav).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should not show game name on non-game pages', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Should not see any game names on the home page
|
||||
const gameNavs = page.locator('h1:has-text("Memory Pairs"), h1:has-text("Memory Lightning")')
|
||||
await expect(gameNavs).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
119
apps/web/e2e/sound-settings-persistence.spec.ts
Normal file
119
apps/web/e2e/sound-settings-persistence.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Sound Settings Persistence', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Clear localStorage before each test
|
||||
await page.goto('/')
|
||||
await page.evaluate(() => localStorage.clear())
|
||||
})
|
||||
|
||||
test('should persist sound enabled setting to localStorage', async ({ page }) => {
|
||||
await page.goto('/games/memory-quiz')
|
||||
|
||||
// Open style dropdown
|
||||
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()
|
||||
|
||||
await soundSwitch.click()
|
||||
|
||||
// Check localStorage was updated
|
||||
const storedConfig = await page.evaluate(() => {
|
||||
const stored = localStorage.getItem('soroban-abacus-display-config')
|
||||
return stored ? JSON.parse(stored) : null
|
||||
})
|
||||
|
||||
expect(storedConfig).toBeTruthy()
|
||||
expect(storedConfig.soundEnabled).toBe(true)
|
||||
|
||||
// Reload page and verify setting persists
|
||||
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()
|
||||
|
||||
await expect(soundSwitchAfterReload).toBeChecked()
|
||||
})
|
||||
|
||||
test('should persist sound volume setting to localStorage', async ({ page }) => {
|
||||
await page.goto('/games/memory-quiz')
|
||||
|
||||
// Open style dropdown
|
||||
await page.getByRole('button', { name: /style/i }).click()
|
||||
|
||||
// Find volume slider
|
||||
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
|
||||
|
||||
// Check localStorage was updated
|
||||
const storedConfig = await page.evaluate(() => {
|
||||
const stored = localStorage.getItem('soroban-abacus-display-config')
|
||||
return stored ? JSON.parse(stored) : null
|
||||
})
|
||||
|
||||
expect(storedConfig).toBeTruthy()
|
||||
expect(storedConfig.soundVolume).toBeCloseTo(0.6, 1)
|
||||
|
||||
// Reload page and verify setting persists
|
||||
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 volumeValue = await volumeSliderAfterReload.inputValue()
|
||||
expect(parseFloat(volumeValue)).toBeCloseTo(60, 0) // Allow for some variance
|
||||
})
|
||||
|
||||
test('should load default sound settings when localStorage is empty', async ({ page }) => {
|
||||
await page.goto('/games/memory-quiz')
|
||||
|
||||
// Check that default settings are loaded
|
||||
const storedConfig = await page.evaluate(() => {
|
||||
const stored = localStorage.getItem('soroban-abacus-display-config')
|
||||
return stored ? JSON.parse(stored) : null
|
||||
})
|
||||
|
||||
// Should have default values: soundEnabled: true, soundVolume: 0.8
|
||||
expect(storedConfig).toBeTruthy()
|
||||
expect(storedConfig.soundEnabled).toBe(true)
|
||||
expect(storedConfig.soundVolume).toBe(0.8)
|
||||
})
|
||||
|
||||
test('should handle invalid localStorage data gracefully', async ({ page }) => {
|
||||
// Set invalid localStorage data
|
||||
await page.goto('/')
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('soroban-abacus-display-config', 'invalid-json')
|
||||
})
|
||||
|
||||
await page.goto('/games/memory-quiz')
|
||||
|
||||
// Should fall back to defaults and not crash
|
||||
const storedConfig = await page.evaluate(() => {
|
||||
const stored = localStorage.getItem('soroban-abacus-display-config')
|
||||
return stored ? JSON.parse(stored) : null
|
||||
})
|
||||
|
||||
expect(storedConfig.soundEnabled).toBe(true)
|
||||
expect(storedConfig.soundVolume).toBe(0.8)
|
||||
})
|
||||
})
|
||||
@@ -3,27 +3,29 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently \"next dev\" \"npx @pandacss/dev --watch\"",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"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": "next lint",
|
||||
"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,32 +47,45 @@
|
||||
"@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.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react-resizable-layout": "^0.7.3"
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-resizable-layout": "^0.7.3",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@storybook/addon-docs": "^9.1.7",
|
||||
"@storybook/addon-onboarding": "^9.1.7",
|
||||
"@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.0.0",
|
||||
"@types/react-dom": "^18.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"
|
||||
},
|
||||
|
||||
@@ -47,6 +47,60 @@ export default defineConfig({
|
||||
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)' }
|
||||
},
|
||||
animations: {
|
||||
// Shake animation for errors (web_generator.py line 3419)
|
||||
shake: { value: 'shake 0.5s ease-in-out' },
|
||||
// Pulse animation for success feedback (line 2004)
|
||||
successPulse: { value: 'successPulse 0.5s ease' },
|
||||
pulse: { value: 'pulse 2s infinite' },
|
||||
// Error shake with larger amplitude (line 2009)
|
||||
errorShake: { value: 'errorShake 0.5s ease' },
|
||||
// Bounce animations (line 6271, 5065)
|
||||
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' }
|
||||
}
|
||||
},
|
||||
keyframes: {
|
||||
// Shake - horizontal oscillation for errors (line 3419)
|
||||
shake: {
|
||||
'0%, 100%': { transform: 'translateX(0)' },
|
||||
'25%': { 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)' }
|
||||
},
|
||||
// Pulse - continuous breathing effect (line 6255)
|
||||
pulse: {
|
||||
'0%, 100%': { transform: 'scale(1)' },
|
||||
'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)' }
|
||||
},
|
||||
// Bounce - vertical oscillation (line 6271)
|
||||
bounce: {
|
||||
'0%, 100%': { transform: 'translateY(0)' },
|
||||
'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' }
|
||||
},
|
||||
// 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)' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
apps/web/playwright.config.ts
Normal file
27
apps/web/playwright.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3002',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
url: 'http://localhost:3002',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
||||
58
apps/web/scripts/generate-build-info.js
Executable file
58
apps/web/scripts/generate-build-info.js
Executable 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
51
apps/web/server.js
Normal 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
29
apps/web/socket-server.js
Normal 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
176
apps/web/socket-server.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Server as SocketIOServer } from 'socket.io'
|
||||
import type { Server as HTTPServer } from 'http'
|
||||
import {
|
||||
getArcadeSession,
|
||||
applyGameMove,
|
||||
updateSessionActivity,
|
||||
deleteArcadeSession,
|
||||
createArcadeSession,
|
||||
} 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
|
||||
}
|
||||
62
apps/web/src/app/__tests__/layout.nav.test.tsx
Normal file
62
apps/web/src/app/__tests__/layout.nav.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import RootLayout from '../layout'
|
||||
|
||||
// Mock AppNavBar to verify it receives the nav prop
|
||||
const MockAppNavBar = ({ navSlot }: { navSlot?: React.ReactNode }) => (
|
||||
<div data-testid="app-nav-bar">
|
||||
{navSlot && <div data-testid="nav-slot-content">{navSlot}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
jest.mock('../../components/AppNavBar', () => ({
|
||||
AppNavBar: MockAppNavBar,
|
||||
}))
|
||||
|
||||
// Mock all context providers
|
||||
jest.mock('../../contexts/AbacusDisplayContext', () => ({
|
||||
AbacusDisplayProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
jest.mock('../../contexts/UserProfileContext', () => ({
|
||||
UserProfileProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
jest.mock('../../contexts/GameModeContext', () => ({
|
||||
GameModeProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
jest.mock('../../contexts/FullscreenContext', () => ({
|
||||
FullscreenProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
describe('RootLayout with nav slot', () => {
|
||||
it('passes nav slot to AppNavBar', () => {
|
||||
const navContent = <div>Memory Lightning</div>
|
||||
const pageContent = <div>Page content</div>
|
||||
|
||||
render(
|
||||
<RootLayout nav={navContent}>
|
||||
{pageContent}
|
||||
</RootLayout>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('app-nav-bar')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('nav-slot-content')).toBeInTheDocument()
|
||||
expect(screen.getByText('Memory Lightning')).toBeInTheDocument()
|
||||
expect(screen.getByText('Page content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('works without nav slot', () => {
|
||||
const pageContent = <div>Page content</div>
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
107
apps/web/src/app/api/abacus-settings/route.ts
Normal file
107
apps/web/src/app/api/abacus-settings/route.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { NextResponse, NextRequest } from 'next/server'
|
||||
import { db } from '@/db'
|
||||
import * as schema from '@/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
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
|
||||
}
|
||||
183
apps/web/src/app/api/arcade-session/__tests__/route.test.ts
Normal file
183
apps/web/src/app/api/arcade-session/__tests__/route.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { GET, POST, DELETE } from '../route'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { deleteArcadeSession } from '@/lib/arcade/session-manager'
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
102
apps/web/src/app/api/arcade-session/route.ts
Normal file
102
apps/web/src/app/api/arcade-session/route.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
createArcadeSession,
|
||||
getArcadeSession,
|
||||
deleteArcadeSession,
|
||||
} 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 })
|
||||
}
|
||||
}
|
||||
18
apps/web/src/app/api/arcade-session/types.ts
Normal file
18
apps/web/src/app/api/arcade-session/types.ts
Normal 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
|
||||
}
|
||||
17
apps/web/src/app/api/auth/[...nextauth]/route.ts
Normal file
17
apps/web/src/app/api/auth/[...nextauth]/route.ts
Normal 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
|
||||
6
apps/web/src/app/api/build-info/route.ts
Normal file
6
apps/web/src/app/api/build-info/route.ts
Normal 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)
|
||||
}
|
||||
134
apps/web/src/app/api/players/[id]/route.ts
Normal file
134
apps/web/src/app/api/players/[id]/route.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { db, schema } from '../../../../db'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { PATCH } from '../[id]/route'
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
97
apps/web/src/app/api/players/route.ts
Normal file
97
apps/web/src/app/api/players/route.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
126
apps/web/src/app/api/user-stats/route.ts
Normal file
126
apps/web/src/app/api/user-stats/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
/**
|
||||
* 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
|
||||
let 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
19
apps/web/src/app/api/viewer/route.ts
Normal file
19
apps/web/src/app/api/viewer/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
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)]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
'use client'
|
||||
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { GameIntro } from './GameIntro'
|
||||
import { GameControls } from './GameControls'
|
||||
import { GameCountdown } from './GameCountdown'
|
||||
import { GameDisplay } from './GameDisplay'
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
'use client'
|
||||
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import type { GameMode, GameStyle, TimeoutSetting, ComplementDisplay } 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { useGameLoop } from '../hooks/useGameLoop'
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { useAIRacers } from '../hooks/useAIRacers'
|
||||
import { useAdaptiveDifficulty } from '../hooks/useAdaptiveDifficulty'
|
||||
import { useSteamJourney } from '../hooks/useSteamJourney'
|
||||
import { useSoundEffects } from '../hooks/useSoundEffects'
|
||||
import { LinearTrack } from './RaceTrack/LinearTrack'
|
||||
import { CircularTrack } from './RaceTrack/CircularTrack'
|
||||
import { SteamTrainJourney } from './RaceTrack/SteamTrainJourney'
|
||||
import { RouteCelebration } from './RouteCelebration'
|
||||
import { AbacusTarget } from './AbacusTarget'
|
||||
import { generatePassengers } from '../lib/passengerGenerator'
|
||||
|
||||
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)
|
||||
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])
|
||||
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
120
apps/web/src/app/arcade/complement-race/components/GameIntro.tsx
Normal file
120
apps/web/src/app/arcade/complement-race/components/GameIntro.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
'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'
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
'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>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
'use client'
|
||||
|
||||
import { useSpring, animated } 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { AIRacer } from '../../lib/gameTypes'
|
||||
import { SpeechBubble } from '../AISystem/SpeechBubble'
|
||||
import { useComplementRace } from '../../context/ComplementRaceContext'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useUserProfile } from '@/contexts/UserProfileContext'
|
||||
import { useSoundEffects } from '../../hooks/useSoundEffects'
|
||||
|
||||
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 } = 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])
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { Station, Passenger, ComplementQuestion } from '../../lib/gameTypes'
|
||||
import { PassengerCard } from '../PassengerCard'
|
||||
import { PressureGauge } from '../PressureGauge'
|
||||
import { AbacusTarget } from '../AbacusTarget'
|
||||
|
||||
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'
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client'
|
||||
|
||||
import type { AIRacer } from '../../lib/gameTypes'
|
||||
import { SpeechBubble } from '../AISystem/SpeechBubble'
|
||||
import { useComplementRace } from '../../context/ComplementRaceContext'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useUserProfile } from '@/contexts/UserProfileContext'
|
||||
|
||||
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 } = 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { Station, Passenger } 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 && tiesAndRails.leftRailPath && (
|
||||
<path
|
||||
d={tiesAndRails.leftRailPath}
|
||||
fill="none"
|
||||
stroke="#C0C0C0"
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Right rail */}
|
||||
{tiesAndRails && 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'
|
||||
@@ -0,0 +1,294 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState, useMemo, memo } from 'react'
|
||||
import { useSpring, animated } from '@react-spring/web'
|
||||
import { useSteamJourney } from '../../hooks/useSteamJourney'
|
||||
import { usePassengerAnimations, type BoardingAnimation, type DisembarkingAnimation } from '../../hooks/usePassengerAnimations'
|
||||
import { useTrainTransforms } from '../../hooks/useTrainTransforms'
|
||||
import { useTrackManagement } from '../../hooks/useTrackManagement'
|
||||
import { useComplementRace } from '../../context/ComplementRaceContext'
|
||||
import { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { getRouteTheme } from '../../lib/routeThemes'
|
||||
import { calculateMaxConcurrentPassengers } from '../../lib/passengerGenerator'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useUserProfile } from '@/contexts/UserProfileContext'
|
||||
import { TrainTerrainBackground } from './TrainTerrainBackground'
|
||||
import { RailroadTrackPath } from './RailroadTrackPath'
|
||||
import { TrainAndCars } from './TrainAndCars'
|
||||
import { GameHUD } from './GameHUD'
|
||||
|
||||
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 } = 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
'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'
|
||||
@@ -0,0 +1,198 @@
|
||||
'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'
|
||||
@@ -0,0 +1,165 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, test, expect, vi } from 'vitest'
|
||||
import { GameHUD } from '../GameHUD'
|
||||
import type { Station, Passenger } from '../../../lib/gameTypes'
|
||||
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, test, expect } 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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
'use client'
|
||||
|
||||
import React, { createContext, useContext, useReducer, ReactNode } from 'react'
|
||||
import type { GameState, GameAction, AIRacer, DifficultyTracker, Station, Passenger } 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.20, // 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
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, test, expect, beforeEach, vi } from 'vitest'
|
||||
import { usePassengerAnimations } from '../usePassengerAnimations'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,350 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, 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)
|
||||
let 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))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 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 { renderHook, act } from '@testing-library/react'
|
||||
import { ReactNode } from 'react'
|
||||
import { ComplementRaceProvider } from '../../context/ComplementRaceContext'
|
||||
import { useSteamJourney } from '../useSteamJourney'
|
||||
import { useComplementRace } from '../../context/ComplementRaceContext'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,506 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, test, expect, beforeEach, vi } from 'vitest'
|
||||
import { useTrackManagement } from '../useTrackManagement'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import type { Station, Passenger } from '../../lib/gameTypes'
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,364 @@
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { describe, test, expect, beforeEach, vi } from 'vitest'
|
||||
import { useTrackManagement } from '../useTrackManagement'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import type { Station, Passenger } from '../../lib/gameTypes'
|
||||
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,298 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, test, expect, beforeEach, vi } from 'vitest'
|
||||
import { useTrainTransforms } from '../useTrainTransforms'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
114
apps/web/src/app/arcade/complement-race/hooks/useAIRacers.ts
Normal file
114
apps/web/src/app/arcade/complement-race/hooks/useAIRacers.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { getAICommentary, type CommentaryContext } from '../components/AISystem/aiCommentary'
|
||||
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])
|
||||
|
||||
return {
|
||||
aiRacers: state.aiRacers
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { useEffect } from 'react'
|
||||
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
|
||||
let 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.60) {
|
||||
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.20 * 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
|
||||
}
|
||||
}
|
||||
67
apps/web/src/app/arcade/complement-race/hooks/useGameLoop.ts
Normal file
67
apps/web/src/app/arcade/complement-race/hooks/useGameLoop.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
354
apps/web/src/app/arcade/complement-race/hooks/useSoundEffects.ts
Normal file
354
apps/web/src/app/arcade/complement-race/hooks/useSoundEffects.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
428
apps/web/src/app/arcade/complement-race/hooks/useSteamJourney.ts
Normal file
428
apps/web/src/app/arcade/complement-race/hooks/useSteamJourney.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
import { useEffect, useRef, useMemo } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { generatePassengers, calculateMaxConcurrentPassengers } 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.pressure, state.elapsedTime, 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.correctAnswers, 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator'
|
||||
import type { Station, Passenger } from '../lib/gameTypes'
|
||||
import { generateLandmarks, type Landmark } from '../lib/landmarks'
|
||||
|
||||
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,
|
||||
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)
|
||||
}
|
||||
}, [trackData, 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)
|
||||
}
|
||||
}, [trackData, landmarks, pathRef])
|
||||
|
||||
return {
|
||||
trackData,
|
||||
tiesAndRails,
|
||||
stationPositions,
|
||||
landmarks,
|
||||
landmarkPositions,
|
||||
displayPassengers
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState, useMemo } 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Railroad Track Generator
|
||||
*
|
||||
* Generates dynamic curved railroad tracks with proper ballast, ties, and rails.
|
||||
* Based on the original Python implementation with SVG path generation.
|
||||
*/
|
||||
|
||||
export interface Waypoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface TrackElements {
|
||||
ballastPath: string
|
||||
referencePath: string
|
||||
ties: Array<{ x1: number; y1: number; x2: number; y2: number }>
|
||||
leftRailPath: string
|
||||
rightRailPath: string
|
||||
}
|
||||
|
||||
export class RailroadTrackGenerator {
|
||||
private viewWidth: number
|
||||
private viewHeight: number
|
||||
|
||||
constructor(viewWidth = 800, viewHeight = 600) {
|
||||
this.viewWidth = viewWidth
|
||||
this.viewHeight = viewHeight
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate complete track elements for rendering
|
||||
*/
|
||||
generateTrack(routeNumber: number = 1): TrackElements {
|
||||
const waypoints = this.generateTrackWaypoints(routeNumber)
|
||||
const pathData = this.generateSmoothPath(waypoints)
|
||||
|
||||
return {
|
||||
ballastPath: pathData,
|
||||
referencePath: pathData,
|
||||
ties: [],
|
||||
leftRailPoints: [],
|
||||
rightRailPoints: []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeded random number generator for deterministic randomness
|
||||
*/
|
||||
private seededRandom(seed: number): number {
|
||||
const x = Math.sin(seed) * 10000
|
||||
return x - Math.floor(x)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate waypoints for track with controlled randomness
|
||||
* Based on route number for variety across different routes
|
||||
*/
|
||||
private generateTrackWaypoints(routeNumber: number): Waypoint[] {
|
||||
// Base waypoints - tracks span from left tunnel (x=20) to right tunnel (x=780)
|
||||
// viewBox is "-50 -50 900 700", so x ranges from -50 to 850
|
||||
const baseWaypoints: Waypoint[] = [
|
||||
{ x: 20, y: 300 }, // Start at left tunnel center
|
||||
{ x: 120, y: 260 }, // Emerging from left tunnel
|
||||
{ x: 240, y: 200 }, // Climb into hills
|
||||
{ x: 380, y: 170 }, // Mountain pass
|
||||
{ x: 520, y: 220 }, // Descent to valley
|
||||
{ x: 660, y: 160 }, // Bridge over canyon
|
||||
{ x: 780, y: 300 } // Enter right tunnel center
|
||||
]
|
||||
|
||||
// Add deterministic randomness based on route number (but keep start/end fixed)
|
||||
return baseWaypoints.map((point, index) => {
|
||||
if (index === 0 || index === baseWaypoints.length - 1) {
|
||||
return point // Keep start/end points fixed
|
||||
}
|
||||
|
||||
// Use seeded randomness for consistent track per route
|
||||
const seed1 = routeNumber * 12.9898 + index * 78.233
|
||||
const seed2 = routeNumber * 43.789 + index * 67.123
|
||||
const randomX = (this.seededRandom(seed1) - 0.5) * 60 // ±30 pixels
|
||||
const randomY = (this.seededRandom(seed2) - 0.5) * 80 // ±40 pixels
|
||||
|
||||
return {
|
||||
x: point.x + randomX,
|
||||
y: point.y + randomY
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate smooth cubic bezier curves through waypoints
|
||||
*/
|
||||
private generateSmoothPath(waypoints: Waypoint[]): string {
|
||||
if (waypoints.length < 2) return ''
|
||||
|
||||
let pathData = `M ${waypoints[0].x} ${waypoints[0].y}`
|
||||
|
||||
for (let i = 1; i < waypoints.length; i++) {
|
||||
const current = waypoints[i]
|
||||
const previous = waypoints[i - 1]
|
||||
|
||||
// Calculate control points for smooth curves
|
||||
const dx = current.x - previous.x
|
||||
const dy = current.y - previous.y
|
||||
|
||||
const cp1x = previous.x + dx * 0.3
|
||||
const cp1y = previous.y + dy * 0.2
|
||||
const cp2x = current.x - dx * 0.3
|
||||
const cp2y = current.y - dy * 0.2
|
||||
|
||||
pathData += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${current.x} ${current.y}`
|
||||
}
|
||||
|
||||
return pathData
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate gentle curves through densely sampled waypoints
|
||||
* Uses very gentle control points to avoid wobbles in straight sections
|
||||
*/
|
||||
private generateGentlePath(waypoints: Waypoint[]): string {
|
||||
if (waypoints.length < 2) return ''
|
||||
|
||||
let pathData = `M ${waypoints[0].x} ${waypoints[0].y}`
|
||||
|
||||
for (let i = 1; i < waypoints.length; i++) {
|
||||
const current = waypoints[i]
|
||||
const previous = waypoints[i - 1]
|
||||
|
||||
// Use extremely gentle control points for very dense sampling
|
||||
const dx = current.x - previous.x
|
||||
const dy = current.y - previous.y
|
||||
|
||||
const cp1x = previous.x + dx * 0.33
|
||||
const cp1y = previous.y + dy * 0.33
|
||||
const cp2x = current.x - dx * 0.33
|
||||
const cp2y = current.y - dy * 0.33
|
||||
|
||||
pathData += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${current.x} ${current.y}`
|
||||
}
|
||||
|
||||
return pathData
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate railroad ties and rails along the path
|
||||
* This requires an SVG path element to measure
|
||||
*/
|
||||
generateTiesAndRails(pathElement: SVGPathElement): {
|
||||
ties: Array<{ x1: number; y1: number; x2: number; y2: number }>
|
||||
leftRailPath: string
|
||||
rightRailPath: string
|
||||
} {
|
||||
const pathLength = pathElement.getTotalLength()
|
||||
const tieSpacing = 12 // Distance between ties in pixels
|
||||
const gaugeWidth = 15 // Standard gauge (tie extends 15px each side)
|
||||
const tieCount = Math.floor(pathLength / tieSpacing)
|
||||
|
||||
const ties: Array<{ x1: number; y1: number; x2: number; y2: number }> = []
|
||||
|
||||
// Generate ties at normal spacing
|
||||
for (let i = 0; i < tieCount; i++) {
|
||||
const distance = i * tieSpacing
|
||||
const point = pathElement.getPointAtLength(distance)
|
||||
|
||||
// Calculate perpendicular angle for tie orientation
|
||||
const nextDistance = Math.min(distance + 2, pathLength)
|
||||
const nextPoint = pathElement.getPointAtLength(nextDistance)
|
||||
const angle = Math.atan2(nextPoint.y - point.y, nextPoint.x - point.x)
|
||||
const perpAngle = angle + Math.PI / 2
|
||||
|
||||
// Calculate tie end points
|
||||
const leftX = point.x + Math.cos(perpAngle) * gaugeWidth
|
||||
const leftY = point.y + Math.sin(perpAngle) * gaugeWidth
|
||||
const rightX = point.x - Math.cos(perpAngle) * gaugeWidth
|
||||
const rightY = point.y - Math.sin(perpAngle) * gaugeWidth
|
||||
|
||||
// Store tie
|
||||
ties.push({ x1: leftX, y1: leftY, x2: rightX, y2: rightY })
|
||||
}
|
||||
|
||||
// Generate rail paths as smooth curves (not polylines)
|
||||
// Sample points along the path and create offset waypoints
|
||||
const railSampling = 2 // Sample every 2 pixels for waypoints (very dense sampling for smooth curves)
|
||||
const sampleCount = Math.floor(pathLength / railSampling)
|
||||
|
||||
const leftRailWaypoints: Waypoint[] = []
|
||||
const rightRailWaypoints: Waypoint[] = []
|
||||
|
||||
for (let i = 0; i <= sampleCount; i++) {
|
||||
const distance = Math.min(i * railSampling, pathLength)
|
||||
const point = pathElement.getPointAtLength(distance)
|
||||
|
||||
// Calculate perpendicular angle with longer lookahead for smoother curves
|
||||
const nextDistance = Math.min(distance + 8, pathLength)
|
||||
const nextPoint = pathElement.getPointAtLength(nextDistance)
|
||||
const angle = Math.atan2(nextPoint.y - point.y, nextPoint.x - point.x)
|
||||
const perpAngle = angle + Math.PI / 2
|
||||
|
||||
// Calculate offset positions for rails
|
||||
const leftX = point.x + Math.cos(perpAngle) * gaugeWidth
|
||||
const leftY = point.y + Math.sin(perpAngle) * gaugeWidth
|
||||
const rightX = point.x - Math.cos(perpAngle) * gaugeWidth
|
||||
const rightY = point.y - Math.sin(perpAngle) * gaugeWidth
|
||||
|
||||
leftRailWaypoints.push({ x: leftX, y: leftY })
|
||||
rightRailWaypoints.push({ x: rightX, y: rightY })
|
||||
}
|
||||
|
||||
// Generate smooth curved paths through the rail waypoints with gentle control points
|
||||
const leftRailPath = this.generateGentlePath(leftRailWaypoints)
|
||||
const rightRailPath = this.generateGentlePath(rightRailWaypoints)
|
||||
|
||||
return { ties, leftRailPath, rightRailPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate train position and rotation along path
|
||||
*/
|
||||
getTrainTransform(
|
||||
pathElement: SVGPathElement,
|
||||
progress: number // 0-100%
|
||||
): { x: number; y: number; rotation: number } {
|
||||
const pathLength = pathElement.getTotalLength()
|
||||
const targetLength = (progress / 100) * pathLength
|
||||
|
||||
// Get exact point on curved path
|
||||
const point = pathElement.getPointAtLength(targetLength)
|
||||
|
||||
// Calculate rotation based on path direction
|
||||
const lookAheadDistance = Math.min(5, pathLength - targetLength)
|
||||
const nextPoint = pathElement.getPointAtLength(targetLength + lookAheadDistance)
|
||||
|
||||
// Calculate angle between current and next point
|
||||
const deltaX = nextPoint.x - point.x
|
||||
const deltaY = nextPoint.y - point.y
|
||||
const angleRadians = Math.atan2(deltaY, deltaX)
|
||||
const angleDegrees = angleRadians * (180 / Math.PI)
|
||||
|
||||
return {
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
rotation: angleDegrees
|
||||
}
|
||||
}
|
||||
}
|
||||
156
apps/web/src/app/arcade/complement-race/lib/gameTypes.ts
Normal file
156
apps/web/src/app/arcade/complement-race/lib/gameTypes.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
export type GameMode = 'friends5' | 'friends10' | 'mixed'
|
||||
export type GameStyle = 'practice' | 'sprint' | 'survival'
|
||||
export type TimeoutSetting = 'preschool' | 'kindergarten' | 'relaxed' | 'slow' | 'normal' | 'fast' | 'expert'
|
||||
export type ComplementDisplay = 'number' | 'abacus' | 'random' // How to display the complement number
|
||||
|
||||
export interface ComplementQuestion {
|
||||
number: number
|
||||
targetSum: number
|
||||
correctAnswer: number
|
||||
showAsAbacus: boolean // For random mode, this is decided once per question
|
||||
}
|
||||
|
||||
export interface AIRacer {
|
||||
id: string
|
||||
position: number
|
||||
speed: number
|
||||
name: string
|
||||
personality: 'competitive' | 'analytical'
|
||||
icon: string
|
||||
lastComment: number
|
||||
commentCooldown: number
|
||||
previousPosition: number
|
||||
}
|
||||
|
||||
export interface DifficultyTracker {
|
||||
pairPerformance: Map<string, PairPerformance>
|
||||
baseTimeLimit: number
|
||||
currentTimeLimit: number
|
||||
difficultyLevel: number
|
||||
consecutiveCorrect: number
|
||||
consecutiveIncorrect: number
|
||||
learningMode: boolean
|
||||
adaptationRate: number
|
||||
}
|
||||
|
||||
export interface PairPerformance {
|
||||
attempts: number
|
||||
correct: number
|
||||
avgTime: number
|
||||
difficulty: number
|
||||
}
|
||||
|
||||
export interface Station {
|
||||
id: string
|
||||
name: string
|
||||
position: number // 0-100% along track
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface Passenger {
|
||||
id: string
|
||||
name: string
|
||||
avatar: string
|
||||
originStationId: string
|
||||
destinationStationId: string
|
||||
isUrgent: boolean
|
||||
isBoarded: boolean
|
||||
isDelivered: boolean
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
// Game configuration
|
||||
mode: GameMode
|
||||
style: GameStyle
|
||||
timeoutSetting: TimeoutSetting
|
||||
complementDisplay: ComplementDisplay // How to display the complement number
|
||||
|
||||
// Current question
|
||||
currentQuestion: ComplementQuestion | null
|
||||
previousQuestion: ComplementQuestion | null
|
||||
|
||||
// Game progress
|
||||
score: number
|
||||
streak: number
|
||||
bestStreak: number
|
||||
totalQuestions: number
|
||||
correctAnswers: number
|
||||
|
||||
// Game status
|
||||
isGameActive: boolean
|
||||
isPaused: boolean
|
||||
gamePhase: 'intro' | 'controls' | 'countdown' | 'playing' | 'results'
|
||||
|
||||
// Timing
|
||||
gameStartTime: number | null
|
||||
questionStartTime: number
|
||||
|
||||
// Race mechanics
|
||||
raceGoal: number
|
||||
timeLimit: number | null
|
||||
speedMultiplier: number
|
||||
aiRacers: AIRacer[]
|
||||
|
||||
// Adaptive difficulty
|
||||
difficultyTracker: DifficultyTracker
|
||||
|
||||
// Survival mode specific
|
||||
playerLap: number
|
||||
aiLaps: Map<string, number>
|
||||
survivalMultiplier: number
|
||||
|
||||
// Sprint mode specific
|
||||
momentum: number
|
||||
trainPosition: number
|
||||
pressure: number // 0-150 PSI
|
||||
elapsedTime: number // milliseconds elapsed in 60-second journey
|
||||
lastCorrectAnswerTime: number
|
||||
currentRoute: number
|
||||
stations: Station[]
|
||||
passengers: Passenger[]
|
||||
deliveredPassengers: number
|
||||
cumulativeDistance: number // Total distance across all routes
|
||||
showRouteCelebration: boolean
|
||||
|
||||
// Input
|
||||
currentInput: string
|
||||
|
||||
// UI state
|
||||
showScoreModal: boolean
|
||||
activeSpeechBubbles: Map<string, string> // racerId -> message
|
||||
adaptiveFeedback: { message: string; type: string } | null
|
||||
}
|
||||
|
||||
export type GameAction =
|
||||
| { type: 'SET_MODE'; mode: GameMode }
|
||||
| { type: 'SET_STYLE'; style: GameStyle }
|
||||
| { type: 'SET_TIMEOUT'; timeout: TimeoutSetting }
|
||||
| { type: 'SET_COMPLEMENT_DISPLAY'; display: ComplementDisplay }
|
||||
| { type: 'SHOW_CONTROLS' }
|
||||
| { type: 'START_COUNTDOWN' }
|
||||
| { type: 'BEGIN_GAME' }
|
||||
| { type: 'NEXT_QUESTION' }
|
||||
| { type: 'SUBMIT_ANSWER'; answer: number }
|
||||
| { type: 'UPDATE_INPUT'; input: string }
|
||||
| { type: 'UPDATE_AI_POSITIONS'; positions: Array<{id: string, position: number}> }
|
||||
| { type: 'TRIGGER_AI_COMMENTARY'; racerId: string; message: string; context: string }
|
||||
| { type: 'CLEAR_AI_COMMENT'; racerId: string }
|
||||
| { type: 'UPDATE_DIFFICULTY_TRACKER'; tracker: DifficultyTracker }
|
||||
| { type: 'UPDATE_AI_SPEEDS'; racers: AIRacer[] }
|
||||
| { type: 'SHOW_ADAPTIVE_FEEDBACK'; feedback: { message: string; type: string } }
|
||||
| { type: 'CLEAR_ADAPTIVE_FEEDBACK' }
|
||||
| { type: 'UPDATE_MOMENTUM'; momentum: number }
|
||||
| { type: 'UPDATE_TRAIN_POSITION'; position: number }
|
||||
| { type: 'UPDATE_STEAM_JOURNEY'; momentum: number; trainPosition: number; pressure: number; elapsedTime: number }
|
||||
| { type: 'COMPLETE_LAP'; racerId: string }
|
||||
| { type: 'PAUSE_RACE' }
|
||||
| { type: 'RESUME_RACE' }
|
||||
| { type: 'END_RACE' }
|
||||
| { type: 'SHOW_RESULTS' }
|
||||
| { type: 'RESET_GAME' }
|
||||
| { type: 'GENERATE_PASSENGERS'; passengers: Passenger[] }
|
||||
| { type: 'BOARD_PASSENGER'; passengerId: string }
|
||||
| { type: 'DELIVER_PASSENGER'; passengerId: string; points: number }
|
||||
| { type: 'START_NEW_ROUTE'; routeNumber: number; stations: Station[] }
|
||||
| { type: 'COMPLETE_ROUTE' }
|
||||
| { type: 'HIDE_ROUTE_CELEBRATION' }
|
||||
103
apps/web/src/app/arcade/complement-race/lib/landmarks.ts
Normal file
103
apps/web/src/app/arcade/complement-race/lib/landmarks.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Geographic landmarks for Steam Train Journey
|
||||
* Landmarks add visual variety to the landscape based on route themes
|
||||
*/
|
||||
|
||||
export interface Landmark {
|
||||
emoji: string
|
||||
position: number // 0-100% along track
|
||||
offset: { x: number; y: number } // Offset from track position
|
||||
size: number // Font size multiplier
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate landmarks for a specific route
|
||||
* Different route themes have different landmark types
|
||||
*/
|
||||
export function generateLandmarks(routeNumber: number): Landmark[] {
|
||||
const seed = routeNumber * 456.789
|
||||
|
||||
// Deterministic randomness for landmark placement
|
||||
const random = (index: number) => {
|
||||
return Math.abs(Math.sin(seed + index * 2.7))
|
||||
}
|
||||
|
||||
const landmarks: Landmark[] = []
|
||||
|
||||
// Route theme determines landmark types
|
||||
const themeIndex = (routeNumber - 1) % 10
|
||||
|
||||
// Generate 4-6 landmarks along the route
|
||||
const landmarkCount = Math.floor(random(0) * 3) + 4
|
||||
|
||||
for (let i = 0; i < landmarkCount; i++) {
|
||||
const position = (i + 1) * (100 / (landmarkCount + 1))
|
||||
const offsetSide = random(i) > 0.5 ? 1 : -1
|
||||
const offsetDistance = 30 + random(i + 10) * 40
|
||||
|
||||
let emoji = '🌳' // Default tree
|
||||
let size = 24
|
||||
|
||||
// Choose emoji based on theme and position
|
||||
switch (themeIndex) {
|
||||
case 0: // Prairie Express
|
||||
emoji = random(i) > 0.6 ? '🌾' : '🌻'
|
||||
size = 20
|
||||
break
|
||||
case 1: // Mountain Climb
|
||||
emoji = random(i) > 0.5 ? '⛰️' : '🗻'
|
||||
size = 32
|
||||
break
|
||||
case 2: // Coastal Run
|
||||
emoji = random(i) > 0.7 ? '🌊' : random(i) > 0.4 ? '🏖️' : '⛵'
|
||||
size = 24
|
||||
break
|
||||
case 3: // Desert Crossing
|
||||
emoji = random(i) > 0.6 ? '🌵' : '🏜️'
|
||||
size = 28
|
||||
break
|
||||
case 4: // Forest Trail
|
||||
emoji = random(i) > 0.7 ? '🌲' : random(i) > 0.4 ? '🌳' : '🦌'
|
||||
size = 26
|
||||
break
|
||||
case 5: // Canyon Route
|
||||
emoji = random(i) > 0.5 ? '🏞️' : '🪨'
|
||||
size = 30
|
||||
break
|
||||
case 6: // River Valley
|
||||
emoji = random(i) > 0.6 ? '🌊' : random(i) > 0.3 ? '🌳' : '🦆'
|
||||
size = 24
|
||||
break
|
||||
case 7: // Highland Pass
|
||||
emoji = random(i) > 0.6 ? '🗻' : '☁️'
|
||||
size = 28
|
||||
break
|
||||
case 8: // Lakeside Journey
|
||||
emoji = random(i) > 0.7 ? '🏞️' : random(i) > 0.4 ? '🌳' : '🦢'
|
||||
size = 26
|
||||
break
|
||||
case 9: // Grand Circuit
|
||||
emoji = random(i) > 0.7 ? '🎪' : random(i) > 0.4 ? '🎡' : '🎠'
|
||||
size = 28
|
||||
break
|
||||
}
|
||||
|
||||
// Add bridges at specific positions (around 40-60%)
|
||||
if (position > 40 && position < 60 && random(i + 20) > 0.7) {
|
||||
emoji = '🌉'
|
||||
size = 36
|
||||
}
|
||||
|
||||
landmarks.push({
|
||||
emoji,
|
||||
position,
|
||||
offset: {
|
||||
x: offsetSide * offsetDistance,
|
||||
y: random(i + 5) * 20 - 10
|
||||
},
|
||||
size
|
||||
})
|
||||
}
|
||||
|
||||
return landmarks
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import type { Passenger, Station } from './gameTypes'
|
||||
|
||||
// Names and avatars organized by gender presentation
|
||||
const MASCULINE_NAMES = [
|
||||
'Ahmed', 'Bob', 'Carlos', 'Elias', 'Ethan', 'George', 'Ian', 'Kevin',
|
||||
'Marcus', 'Oliver', 'Victor', 'Xavier', 'Raj', 'David', 'Miguel', 'Jin'
|
||||
]
|
||||
|
||||
const FEMININE_NAMES = [
|
||||
'Alice', 'Bella', 'Diana', 'Devi', 'Fatima', 'Fiona', 'Hannah', 'Julia',
|
||||
'Laura', 'Nina', 'Petra', 'Rosa', 'Tessa', 'Uma', 'Wendy', 'Zara', 'Yuki'
|
||||
]
|
||||
|
||||
const GENDER_NEUTRAL_NAMES = [
|
||||
'Alex', 'Charlie', 'Jordan', 'Morgan', 'Quinn', 'Riley', 'Sam', 'Taylor'
|
||||
]
|
||||
|
||||
// Masculine-presenting avatars
|
||||
const MASCULINE_AVATARS = [
|
||||
'👨', '👨🏻', '👨🏼', '👨🏽', '👨🏾', '👨🏿',
|
||||
'👴', '👴🏻', '👴🏼', '👴🏽', '👴🏾', '👴🏿',
|
||||
'👦', '👦🏻', '👦🏼', '👦🏽', '👦🏾', '👦🏿',
|
||||
'🧔', '🧔🏻', '🧔🏼', '🧔🏽', '🧔🏾', '🧔🏿',
|
||||
'👨🦱', '👨🏻🦱', '👨🏼🦱', '👨🏽🦱', '👨🏾🦱', '👨🏿🦱',
|
||||
'👨🦰', '👨🏻🦰', '👨🏼🦰', '👨🏽🦰', '👨🏾🦰', '👨🏿🦰',
|
||||
'👱', '👱🏻', '👱🏼', '👱🏽', '👱🏾', '👱🏿'
|
||||
]
|
||||
|
||||
// Feminine-presenting avatars
|
||||
const FEMININE_AVATARS = [
|
||||
'👩', '👩🏻', '👩🏼', '👩🏽', '👩🏾', '👩🏿',
|
||||
'👵', '👵🏻', '👵🏼', '👵🏽', '👵🏾', '👵🏿',
|
||||
'👧', '👧🏻', '👧🏼', '👧🏽', '👧🏾', '👧🏿',
|
||||
'👩🦱', '👩🏻🦱', '👩🏼🦱', '👩🏽🦱', '👩🏾🦱', '👩🏿🦱',
|
||||
'👩🦰', '👩🏻🦰', '👩🏼🦰', '👩🏽🦰', '👩🏾🦰', '👩🏿🦰',
|
||||
'👱♀️', '👱🏻♀️', '👱🏼♀️', '👱🏽♀️', '👱🏾♀️', '👱🏿♀️'
|
||||
]
|
||||
|
||||
// Gender-neutral avatars
|
||||
const NEUTRAL_AVATARS = [
|
||||
'🧑', '🧑🏻', '🧑🏼', '🧑🏽', '🧑🏾', '🧑🏿'
|
||||
]
|
||||
|
||||
/**
|
||||
* Generate 3-5 passengers with random names and destinations
|
||||
* 30% chance of urgent passengers
|
||||
*/
|
||||
export function generatePassengers(stations: Station[]): Passenger[] {
|
||||
const count = Math.floor(Math.random() * 3) + 3 // 3-5 passengers
|
||||
const passengers: Passenger[] = []
|
||||
const usedCombos = new Set<string>()
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
let name: string
|
||||
let avatar: string
|
||||
let comboKey: string
|
||||
|
||||
// Keep trying until we get a unique name/avatar combo
|
||||
do {
|
||||
// Randomly choose a gender category
|
||||
const genderRoll = Math.random()
|
||||
let namePool: string[]
|
||||
let avatarPool: string[]
|
||||
|
||||
if (genderRoll < 0.45) {
|
||||
// 45% masculine
|
||||
namePool = MASCULINE_NAMES
|
||||
avatarPool = MASCULINE_AVATARS
|
||||
} else if (genderRoll < 0.9) {
|
||||
// 45% feminine
|
||||
namePool = FEMININE_NAMES
|
||||
avatarPool = FEMININE_AVATARS
|
||||
} else {
|
||||
// 10% neutral
|
||||
namePool = GENDER_NEUTRAL_NAMES
|
||||
avatarPool = NEUTRAL_AVATARS
|
||||
}
|
||||
|
||||
// Pick from the chosen category
|
||||
name = namePool[Math.floor(Math.random() * namePool.length)]
|
||||
avatar = avatarPool[Math.floor(Math.random() * avatarPool.length)]
|
||||
comboKey = `${name}-${avatar}`
|
||||
} while (usedCombos.has(comboKey) && usedCombos.size < 100) // Prevent infinite loop
|
||||
|
||||
usedCombos.add(comboKey)
|
||||
|
||||
// Pick random origin and destination stations (must be different)
|
||||
// Destination must be ahead of origin (higher position on track)
|
||||
// 40% chance to start at depot, 60% chance to start at other stations
|
||||
let originStation: Station
|
||||
let destination: Station
|
||||
|
||||
if (Math.random() < 0.4 || stations.length < 3) {
|
||||
// Start at depot (first station)
|
||||
originStation = stations[0]
|
||||
// Pick any station ahead as destination
|
||||
const stationsAhead = stations.slice(1)
|
||||
destination = stationsAhead[Math.floor(Math.random() * stationsAhead.length)]
|
||||
} else {
|
||||
// Start at a random non-depot, non-final station
|
||||
const nonDepotStations = stations.slice(1, -1) // Exclude depot and final station
|
||||
originStation = nonDepotStations[Math.floor(Math.random() * nonDepotStations.length)]
|
||||
|
||||
// Pick a station ahead of origin (higher position)
|
||||
const stationsAhead = stations.filter(s => s.position > originStation.position)
|
||||
destination = stationsAhead[Math.floor(Math.random() * stationsAhead.length)]
|
||||
}
|
||||
|
||||
// 30% chance of urgent
|
||||
const isUrgent = Math.random() < 0.3
|
||||
|
||||
passengers.push({
|
||||
id: `passenger-${Date.now()}-${i}`,
|
||||
name,
|
||||
avatar,
|
||||
originStationId: originStation.id,
|
||||
destinationStationId: destination.id,
|
||||
isUrgent,
|
||||
isBoarded: false,
|
||||
isDelivered: false
|
||||
})
|
||||
}
|
||||
|
||||
return passengers
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if train is at a station (within 3% tolerance)
|
||||
*/
|
||||
export function isTrainAtStation(trainPosition: number, stationPosition: number): boolean {
|
||||
return Math.abs(trainPosition - stationPosition) < 3
|
||||
}
|
||||
|
||||
/**
|
||||
* Find passengers that should board at current position
|
||||
*/
|
||||
export function findBoardablePassengers(
|
||||
passengers: Passenger[],
|
||||
stations: Station[],
|
||||
trainPosition: number
|
||||
): Passenger[] {
|
||||
const boardable: Passenger[] = []
|
||||
|
||||
for (const passenger of passengers) {
|
||||
// Skip if already boarded or delivered
|
||||
if (passenger.isBoarded || passenger.isDelivered) continue
|
||||
|
||||
const station = stations.find(s => s.id === passenger.originStationId)
|
||||
if (!station) continue
|
||||
|
||||
if (isTrainAtStation(trainPosition, station.position)) {
|
||||
boardable.push(passenger)
|
||||
}
|
||||
}
|
||||
|
||||
return boardable
|
||||
}
|
||||
|
||||
/**
|
||||
* Find passengers that should be delivered at current position
|
||||
*/
|
||||
export function findDeliverablePassengers(
|
||||
passengers: Passenger[],
|
||||
stations: Station[],
|
||||
trainPosition: number
|
||||
): Array<{ passenger: Passenger; station: Station; points: number }> {
|
||||
const deliverable: Array<{ passenger: Passenger; station: Station; points: number }> = []
|
||||
|
||||
for (const passenger of passengers) {
|
||||
// Only check boarded passengers
|
||||
if (!passenger.isBoarded || passenger.isDelivered) continue
|
||||
|
||||
const station = stations.find(s => s.id === passenger.destinationStationId)
|
||||
if (!station) continue
|
||||
|
||||
if (isTrainAtStation(trainPosition, station.position)) {
|
||||
const points = passenger.isUrgent ? 20 : 10
|
||||
deliverable.push({ passenger, station, points })
|
||||
}
|
||||
}
|
||||
|
||||
return deliverable
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the maximum number of passengers that will be on the train
|
||||
* concurrently at any given moment during the route
|
||||
*/
|
||||
export function calculateMaxConcurrentPassengers(
|
||||
passengers: Passenger[],
|
||||
stations: Station[]
|
||||
): number {
|
||||
// Create events for boarding and delivery
|
||||
interface StationEvent {
|
||||
position: number
|
||||
isBoarding: boolean // true = board, false = delivery
|
||||
}
|
||||
|
||||
const events: StationEvent[] = []
|
||||
|
||||
for (const passenger of passengers) {
|
||||
const originStation = stations.find(s => s.id === passenger.originStationId)
|
||||
const destStation = stations.find(s => s.id === passenger.destinationStationId)
|
||||
|
||||
if (originStation && destStation) {
|
||||
events.push({ position: originStation.position, isBoarding: true })
|
||||
events.push({ position: destStation.position, isBoarding: false })
|
||||
}
|
||||
}
|
||||
|
||||
// Sort events by position, with deliveries before boardings at the same position
|
||||
events.sort((a, b) => {
|
||||
if (a.position !== b.position) return a.position - b.position
|
||||
// At same position, deliveries happen before boarding
|
||||
return a.isBoarding ? 1 : -1
|
||||
})
|
||||
|
||||
// Track current passenger count and maximum
|
||||
let currentCount = 0
|
||||
let maxCount = 0
|
||||
|
||||
for (const event of events) {
|
||||
if (event.isBoarding) {
|
||||
currentCount++
|
||||
maxCount = Math.max(maxCount, currentCount)
|
||||
} else {
|
||||
currentCount--
|
||||
}
|
||||
}
|
||||
|
||||
return maxCount
|
||||
}
|
||||
26
apps/web/src/app/arcade/complement-race/lib/routeThemes.ts
Normal file
26
apps/web/src/app/arcade/complement-race/lib/routeThemes.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Route themes for Steam Train Journey
|
||||
* Each route has a unique name and emoji to make the journey feel varied
|
||||
*/
|
||||
|
||||
export const ROUTE_THEMES = [
|
||||
{ name: 'Prairie Express', emoji: '🌾' },
|
||||
{ name: 'Mountain Climb', emoji: '⛰️' },
|
||||
{ name: 'Coastal Run', emoji: '🌊' },
|
||||
{ name: 'Desert Crossing', emoji: '🏜️' },
|
||||
{ name: 'Forest Trail', emoji: '🌲' },
|
||||
{ name: 'Canyon Route', emoji: '🏞️' },
|
||||
{ name: 'River Valley', emoji: '🏞️' },
|
||||
{ name: 'Highland Pass', emoji: '🗻' },
|
||||
{ name: 'Lakeside Journey', emoji: '🏔️' },
|
||||
{ name: 'Grand Circuit', emoji: '🎪' }
|
||||
]
|
||||
|
||||
/**
|
||||
* Get route theme for a given route number
|
||||
* Cycles through themes if route number exceeds available themes
|
||||
*/
|
||||
export function getRouteTheme(routeNumber: number): { name: string; emoji: string } {
|
||||
const index = (routeNumber - 1) % ROUTE_THEMES.length
|
||||
return ROUTE_THEMES[index]
|
||||
}
|
||||
15
apps/web/src/app/arcade/complement-race/page.tsx
Normal file
15
apps/web/src/app/arcade/complement-race/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceProvider } from './context/ComplementRaceContext'
|
||||
import { ComplementRaceGame } from './components/ComplementRaceGame'
|
||||
|
||||
export default function ComplementRacePage() {
|
||||
return (
|
||||
<PageWithNav navTitle="Speed Complement Race" navEmoji="🏁">
|
||||
<ComplementRaceProvider>
|
||||
<ComplementRaceGame />
|
||||
</ComplementRaceProvider>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
15
apps/web/src/app/arcade/complement-race/practice/page.tsx
Normal file
15
apps/web/src/app/arcade/complement-race/practice/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
|
||||
import { ComplementRaceGame } from '../components/ComplementRaceGame'
|
||||
|
||||
export default function PracticeModePage() {
|
||||
return (
|
||||
<PageWithNav navTitle="Practice Mode" navEmoji="🏁">
|
||||
<ComplementRaceProvider initialStyle="practice">
|
||||
<ComplementRaceGame />
|
||||
</ComplementRaceProvider>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
15
apps/web/src/app/arcade/complement-race/sprint/page.tsx
Normal file
15
apps/web/src/app/arcade/complement-race/sprint/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
|
||||
import { ComplementRaceGame } from '../components/ComplementRaceGame'
|
||||
|
||||
export default function SprintModePage() {
|
||||
return (
|
||||
<PageWithNav navTitle="Steam Sprint" navEmoji="🚂">
|
||||
<ComplementRaceProvider initialStyle="sprint">
|
||||
<ComplementRaceGame />
|
||||
</ComplementRaceProvider>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
15
apps/web/src/app/arcade/complement-race/survival/page.tsx
Normal file
15
apps/web/src/app/arcade/complement-race/survival/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
|
||||
import { ComplementRaceGame } from '../components/ComplementRaceGame'
|
||||
|
||||
export default function SurvivalModePage() {
|
||||
return (
|
||||
<PageWithNav navTitle="Survival Mode" navEmoji="🔄">
|
||||
<ComplementRaceProvider initialStyle="survival">
|
||||
<ComplementRaceGame />
|
||||
</ComplementRaceProvider>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
727
apps/web/src/app/arcade/matching/components/EmojiPicker.tsx
Normal file
727
apps/web/src/app/arcade/matching/components/EmojiPicker.tsx
Normal file
@@ -0,0 +1,727 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { PLAYER_EMOJIS } from '../../../../constants/playerEmojis'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import emojiData from 'emojibase-data/en/data.json'
|
||||
|
||||
// Proper TypeScript interface for emojibase-data structure
|
||||
interface EmojibaseEmoji {
|
||||
label: string
|
||||
hexcode: string
|
||||
tags?: string[]
|
||||
emoji: string
|
||||
text: string
|
||||
type: number
|
||||
order: number
|
||||
group: number
|
||||
subgroup: number
|
||||
version: number
|
||||
emoticon?: string | string[] // Can be string, array, or undefined
|
||||
}
|
||||
|
||||
interface EmojiPickerProps {
|
||||
currentEmoji: string
|
||||
onEmojiSelect: (emoji: string) => void
|
||||
onClose: () => void
|
||||
playerNumber: number
|
||||
}
|
||||
|
||||
// Emoji group categories from emojibase (matching Unicode CLDR group IDs)
|
||||
const EMOJI_GROUPS = {
|
||||
0: { name: 'Smileys & Emotion', icon: '😀' },
|
||||
1: { name: 'People & Body', icon: '👤' },
|
||||
3: { name: 'Animals & Nature', icon: '🐶' },
|
||||
4: { name: 'Food & Drink', icon: '🍎' },
|
||||
5: { name: 'Travel & Places', icon: '🚗' },
|
||||
6: { name: 'Activities', icon: '⚽' },
|
||||
7: { name: 'Objects', icon: '💡' },
|
||||
8: { name: 'Symbols', icon: '❤️' },
|
||||
9: { name: 'Flags', icon: '🏁' }
|
||||
} as const
|
||||
|
||||
// Create a map of emoji to their searchable data and group
|
||||
const emojiMap = new Map<string, { keywords: string[], group: number }>()
|
||||
;(emojiData as EmojibaseEmoji[]).forEach((emoji) => {
|
||||
if (emoji.emoji) {
|
||||
// Handle emoticon field which can be string, array, or undefined
|
||||
const emoticons: string[] = []
|
||||
if (emoji.emoticon) {
|
||||
if (Array.isArray(emoji.emoticon)) {
|
||||
emoticons.push(...emoji.emoticon.map((e) => e.toLowerCase()))
|
||||
} else {
|
||||
emoticons.push(emoji.emoticon.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
emojiMap.set(emoji.emoji, {
|
||||
keywords: [
|
||||
emoji.label?.toLowerCase(),
|
||||
...(emoji.tags || []).map((tag: string) => tag.toLowerCase()),
|
||||
...emoticons
|
||||
].filter(Boolean),
|
||||
group: emoji.group
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Enhanced search function using emojibase-data
|
||||
function getEmojiKeywords(emoji: string): string[] {
|
||||
const data = emojiMap.get(emoji)
|
||||
if (data) {
|
||||
return data.keywords
|
||||
}
|
||||
|
||||
// Fallback categories for emojis not in emojibase-data
|
||||
if (/[\u{1F600}-\u{1F64F}]/u.test(emoji)) return ['face', 'emotion', 'person', 'expression']
|
||||
if (/[\u{1F400}-\u{1F43F}]/u.test(emoji)) return ['animal', 'nature', 'cute', 'pet']
|
||||
if (/[\u{1F440}-\u{1F4FF}]/u.test(emoji)) return ['object', 'symbol', 'tool']
|
||||
if (/[\u{1F300}-\u{1F3FF}]/u.test(emoji)) return ['nature', 'travel', 'activity', 'place']
|
||||
if (/[\u{1F680}-\u{1F6FF}]/u.test(emoji)) return ['transport', 'travel', 'vehicle']
|
||||
if (/[\u{2600}-\u{26FF}]/u.test(emoji)) return ['symbol', 'misc', 'sign']
|
||||
|
||||
return ['misc', 'other']
|
||||
}
|
||||
|
||||
export function EmojiPicker({ currentEmoji, onEmojiSelect, onClose, playerNumber }: EmojiPickerProps) {
|
||||
const [searchFilter, setSearchFilter] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState<number | null>(null)
|
||||
const [hoveredEmoji, setHoveredEmoji] = useState<string | null>(null)
|
||||
const [hoverPosition, setHoverPosition] = useState({ x: 0, y: 0 })
|
||||
|
||||
// Enhanced search functionality - clear separation between default and search
|
||||
const isSearching = searchFilter.trim().length > 0
|
||||
const isCategoryFiltered = selectedCategory !== null && !isSearching
|
||||
|
||||
// Calculate which categories have emojis
|
||||
const availableCategories = useMemo(() => {
|
||||
const categoryCounts: Record<number, number> = {}
|
||||
PLAYER_EMOJIS.forEach(emoji => {
|
||||
const data = emojiMap.get(emoji)
|
||||
if (data && data.group !== undefined) {
|
||||
categoryCounts[data.group] = (categoryCounts[data.group] || 0) + 1
|
||||
}
|
||||
})
|
||||
return Object.keys(EMOJI_GROUPS)
|
||||
.map(Number)
|
||||
.filter(groupId => categoryCounts[groupId] > 0)
|
||||
}, [])
|
||||
|
||||
const displayEmojis = useMemo(() => {
|
||||
// Start with all emojis
|
||||
let emojis = PLAYER_EMOJIS
|
||||
|
||||
// Apply category filter first (unless searching)
|
||||
if (isCategoryFiltered) {
|
||||
emojis = emojis.filter(emoji => {
|
||||
const data = emojiMap.get(emoji)
|
||||
return data && data.group === selectedCategory
|
||||
})
|
||||
}
|
||||
|
||||
// Then apply search filter
|
||||
if (!isSearching) {
|
||||
return emojis
|
||||
}
|
||||
|
||||
const searchTerm = searchFilter.toLowerCase().trim()
|
||||
|
||||
const results = PLAYER_EMOJIS.filter(emoji => {
|
||||
const keywords = getEmojiKeywords(emoji)
|
||||
return keywords.some(keyword =>
|
||||
keyword && keyword.includes(searchTerm)
|
||||
)
|
||||
})
|
||||
|
||||
// Sort results by relevance
|
||||
const sortedResults = results.sort((a, b) => {
|
||||
const aKeywords = getEmojiKeywords(a)
|
||||
const bKeywords = getEmojiKeywords(b)
|
||||
|
||||
// Exact match priority
|
||||
const aExact = aKeywords.some(k => k === searchTerm)
|
||||
const bExact = bKeywords.some(k => k === searchTerm)
|
||||
|
||||
if (aExact && !bExact) return -1
|
||||
if (!aExact && bExact) return 1
|
||||
|
||||
// Word boundary matches (start of word)
|
||||
const aStartsWithTerm = aKeywords.some(k => k && k.startsWith(searchTerm))
|
||||
const bStartsWithTerm = bKeywords.some(k => k && k.startsWith(searchTerm))
|
||||
|
||||
if (aStartsWithTerm && !bStartsWithTerm) return -1
|
||||
if (!aStartsWithTerm && bStartsWithTerm) return 1
|
||||
|
||||
// Score by number of matching keywords
|
||||
const aScore = aKeywords.filter(k => k && k.includes(searchTerm)).length
|
||||
const bScore = bKeywords.filter(k => k && k.includes(searchTerm)).length
|
||||
|
||||
return bScore - aScore
|
||||
})
|
||||
|
||||
return sortedResults
|
||||
}, [searchFilter, isSearching, selectedCategory, isCategoryFiltered])
|
||||
|
||||
return (
|
||||
<div className={css({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
animation: 'fadeIn 0.2s ease',
|
||||
padding: '20px'
|
||||
})}>
|
||||
<div className={css({
|
||||
background: 'white',
|
||||
borderRadius: '20px',
|
||||
padding: '24px',
|
||||
width: '90vw',
|
||||
height: '90vh',
|
||||
maxWidth: '1200px',
|
||||
maxHeight: '800px',
|
||||
boxShadow: '0 20px 40px rgba(0,0,0,0.3)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
})}>
|
||||
|
||||
{/* Header */}
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
borderBottom: '2px solid',
|
||||
borderColor: 'gray.100',
|
||||
paddingBottom: '12px',
|
||||
flexShrink: 0
|
||||
})}>
|
||||
<h3 className={css({
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.800',
|
||||
margin: 0
|
||||
})}>
|
||||
Choose Character for Player {playerNumber}
|
||||
</h3>
|
||||
<button
|
||||
className={css({
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: 'gray.500',
|
||||
_hover: { color: 'gray.700' },
|
||||
padding: '4px'
|
||||
})}
|
||||
onClick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Current Selection & Search */}
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '16px',
|
||||
flexShrink: 0
|
||||
})}>
|
||||
<div className={css({
|
||||
padding: '8px 12px',
|
||||
background: playerNumber === 1
|
||||
? 'linear-gradient(135deg, #74b9ff, #0984e3)'
|
||||
: playerNumber === 2
|
||||
? 'linear-gradient(135deg, #fd79a8, #e84393)'
|
||||
: playerNumber === 3
|
||||
? 'linear-gradient(135deg, #a78bfa, #8b5cf6)'
|
||||
: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
|
||||
borderRadius: '12px',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flexShrink: 0
|
||||
})}>
|
||||
<div className={css({ fontSize: '24px' })}>
|
||||
{currentEmoji}
|
||||
</div>
|
||||
<div className={css({ fontSize: '12px', fontWeight: 'bold' })}>
|
||||
Current
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search: face, smart, heart, animal, food..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
_focus: {
|
||||
outline: 'none',
|
||||
borderColor: 'blue.400',
|
||||
boxShadow: '0 0 0 3px rgba(66, 153, 225, 0.1)'
|
||||
}
|
||||
})}
|
||||
/>
|
||||
|
||||
{isSearching && (
|
||||
<div className={css({
|
||||
fontSize: '12px',
|
||||
color: 'gray.600',
|
||||
flexShrink: 0,
|
||||
padding: '4px 8px',
|
||||
background: displayEmojis.length > 0 ? 'green.100' : 'red.100',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: displayEmojis.length > 0 ? 'green.300' : 'red.300'
|
||||
})}>
|
||||
{displayEmojis.length > 0 ? `✓ ${displayEmojis.length} found` : '✗ No matches'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category Tabs */}
|
||||
{!isSearching && (
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
overflowX: 'auto',
|
||||
paddingBottom: '8px',
|
||||
marginBottom: '12px',
|
||||
flexShrink: 0,
|
||||
'&::-webkit-scrollbar': {
|
||||
height: '6px'
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: '#cbd5e1',
|
||||
borderRadius: '3px'
|
||||
}
|
||||
})}>
|
||||
<button
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
className={css({
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
border: selectedCategory === null ? '2px solid #3b82f6' : '2px solid #e5e7eb',
|
||||
background: selectedCategory === null ? '#eff6ff' : 'white',
|
||||
color: selectedCategory === null ? '#1e40af' : '#6b7280',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.2s ease',
|
||||
_hover: {
|
||||
background: selectedCategory === null ? '#dbeafe' : '#f9fafb',
|
||||
transform: 'translateY(-1px)'
|
||||
}
|
||||
})}
|
||||
>
|
||||
✨ All
|
||||
</button>
|
||||
{availableCategories.map((groupId) => {
|
||||
const group = EMOJI_GROUPS[groupId as keyof typeof EMOJI_GROUPS]
|
||||
return (
|
||||
<button
|
||||
key={groupId}
|
||||
onClick={() => setSelectedCategory(Number(groupId))}
|
||||
className={css({
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
border: selectedCategory === Number(groupId) ? '2px solid #3b82f6' : '2px solid #e5e7eb',
|
||||
background: selectedCategory === Number(groupId) ? '#eff6ff' : 'white',
|
||||
color: selectedCategory === Number(groupId) ? '#1e40af' : '#6b7280',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.2s ease',
|
||||
_hover: {
|
||||
background: selectedCategory === Number(groupId) ? '#dbeafe' : '#f9fafb',
|
||||
transform: 'translateY(-1px)'
|
||||
}
|
||||
})}
|
||||
>
|
||||
{group.icon} {group.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Mode Header */}
|
||||
{isSearching && displayEmojis.length > 0 && (
|
||||
<div className={css({
|
||||
padding: '8px 12px',
|
||||
background: 'blue.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.200',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '12px',
|
||||
flexShrink: 0
|
||||
})}>
|
||||
<div className={css({
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: 'blue.700',
|
||||
marginBottom: '4px'
|
||||
})}>
|
||||
🔍 Search Results for "{searchFilter}"
|
||||
</div>
|
||||
<div className={css({
|
||||
fontSize: '12px',
|
||||
color: 'blue.600'
|
||||
})}>
|
||||
Showing {displayEmojis.length} of {PLAYER_EMOJIS.length} emojis • Clear search to see all
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Default Mode Header */}
|
||||
{!isSearching && (
|
||||
<div className={css({
|
||||
padding: '8px 12px',
|
||||
background: 'gray.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '12px',
|
||||
flexShrink: 0
|
||||
})}>
|
||||
<div className={css({
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.700',
|
||||
marginBottom: '4px'
|
||||
})}>
|
||||
{selectedCategory !== null
|
||||
? `${EMOJI_GROUPS[selectedCategory as keyof typeof EMOJI_GROUPS].icon} ${EMOJI_GROUPS[selectedCategory as keyof typeof EMOJI_GROUPS].name}`
|
||||
: '📝 All Available Characters'}
|
||||
</div>
|
||||
<div className={css({
|
||||
fontSize: '12px',
|
||||
color: 'gray.600'
|
||||
})}>
|
||||
{displayEmojis.length} emojis {selectedCategory !== null ? 'in category' : 'available'} • Use search to find specific emojis
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Emoji Grid - Only show when there are emojis to display */}
|
||||
{displayEmojis.length > 0 && (
|
||||
<div className={css({
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
minHeight: 0,
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '10px'
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: '#f1f5f9',
|
||||
borderRadius: '5px'
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: '#cbd5e1',
|
||||
borderRadius: '5px',
|
||||
'&:hover': {
|
||||
background: '#94a3b8'
|
||||
}
|
||||
}
|
||||
})}>
|
||||
<div className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(16, 1fr)',
|
||||
gap: '4px',
|
||||
padding: '4px',
|
||||
'@media (max-width: 1200px)': {
|
||||
gridTemplateColumns: 'repeat(14, 1fr)'
|
||||
},
|
||||
'@media (max-width: 1000px)': {
|
||||
gridTemplateColumns: 'repeat(12, 1fr)'
|
||||
},
|
||||
'@media (max-width: 800px)': {
|
||||
gridTemplateColumns: 'repeat(10, 1fr)'
|
||||
},
|
||||
'@media (max-width: 600px)': {
|
||||
gridTemplateColumns: 'repeat(8, 1fr)'
|
||||
}
|
||||
})}>
|
||||
{displayEmojis.map(emoji => {
|
||||
const isSelected = emoji === currentEmoji
|
||||
const getSelectedBg = () => {
|
||||
if (!isSelected) return 'transparent'
|
||||
if (playerNumber === 1) return 'blue.100'
|
||||
if (playerNumber === 2) return 'pink.100'
|
||||
if (playerNumber === 3) return 'purple.100'
|
||||
return 'yellow.100'
|
||||
}
|
||||
const getSelectedBorder = () => {
|
||||
if (!isSelected) return 'transparent'
|
||||
if (playerNumber === 1) return 'blue.400'
|
||||
if (playerNumber === 2) return 'pink.400'
|
||||
if (playerNumber === 3) return 'purple.400'
|
||||
return 'yellow.400'
|
||||
}
|
||||
const getHoverBg = () => {
|
||||
if (!isSelected) return 'gray.100'
|
||||
if (playerNumber === 1) return 'blue.200'
|
||||
if (playerNumber === 2) return 'pink.200'
|
||||
if (playerNumber === 3) return 'purple.200'
|
||||
return 'yellow.200'
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={emoji}
|
||||
className={css({
|
||||
aspectRatio: '1',
|
||||
background: getSelectedBg(),
|
||||
border: '2px solid',
|
||||
borderColor: getSelectedBorder(),
|
||||
borderRadius: '6px',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.1s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
_hover: {
|
||||
background: getHoverBg(),
|
||||
transform: 'scale(1.15)',
|
||||
zIndex: 1,
|
||||
fontSize: '24px'
|
||||
}
|
||||
})}
|
||||
onMouseEnter={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
setHoveredEmoji(emoji)
|
||||
setHoverPosition({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top
|
||||
})
|
||||
}}
|
||||
onMouseLeave={() => setHoveredEmoji(null)}
|
||||
onClick={() => {
|
||||
onEmojiSelect(emoji)
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results message */}
|
||||
{isSearching && displayEmojis.length === 0 && (
|
||||
<div className={css({
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center',
|
||||
color: 'gray.500'
|
||||
})}>
|
||||
<div className={css({ fontSize: '48px', marginBottom: '16px' })}>🔍</div>
|
||||
<div className={css({ fontSize: '18px', fontWeight: 'bold', marginBottom: '8px' })}>
|
||||
No emojis found for "{searchFilter}"
|
||||
</div>
|
||||
<div className={css({ fontSize: '14px', marginBottom: '12px' })}>
|
||||
Try searching for "face", "smart", "heart", "animal", "food", etc.
|
||||
</div>
|
||||
<button
|
||||
className={css({
|
||||
background: 'blue.500',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
_hover: { background: 'blue.600' }
|
||||
})}
|
||||
onClick={() => setSearchFilter('')}
|
||||
>
|
||||
Clear search to see all {PLAYER_EMOJIS.length} emojis
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick selection hint */}
|
||||
<div className={css({
|
||||
marginTop: '8px',
|
||||
padding: '6px 12px',
|
||||
background: 'gray.50',
|
||||
borderRadius: '8px',
|
||||
fontSize: '11px',
|
||||
color: 'gray.600',
|
||||
textAlign: 'center',
|
||||
flexShrink: 0
|
||||
})}>
|
||||
💡 Powered by emojibase-data • Try: "face", "smart", "heart", "animal", "food" • Click to select
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Magnifying Glass Preview - SUPER POWERED! */}
|
||||
{hoveredEmoji && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${hoverPosition.x}px`,
|
||||
top: `${hoverPosition.y - 120}px`,
|
||||
transform: 'translateX(-50%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10000,
|
||||
animation: 'magnifyIn 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)'
|
||||
}}
|
||||
>
|
||||
{/* Outer glow ring */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '-20px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(59, 130, 246, 0.3) 0%, transparent 70%)',
|
||||
animation: 'pulseGlow 2s ease-in-out infinite'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Main preview card */}
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)',
|
||||
borderRadius: '24px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.4), 0 0 0 4px rgba(59, 130, 246, 0.6), inset 0 2px 4px rgba(255,255,255,0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '120px',
|
||||
lineHeight: 1,
|
||||
minWidth: '160px',
|
||||
minHeight: '160px',
|
||||
position: 'relative',
|
||||
animation: 'emojiFloat 3s ease-in-out infinite'
|
||||
}}
|
||||
>
|
||||
{/* Sparkle effects */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
right: '10px',
|
||||
fontSize: '20px',
|
||||
animation: 'sparkle 1.5s ease-in-out infinite',
|
||||
animationDelay: '0s'
|
||||
}}>✨</div>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '15px',
|
||||
left: '15px',
|
||||
fontSize: '16px',
|
||||
animation: 'sparkle 1.5s ease-in-out infinite',
|
||||
animationDelay: '0.5s'
|
||||
}}>✨</div>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '20px',
|
||||
left: '20px',
|
||||
fontSize: '12px',
|
||||
animation: 'sparkle 1.5s ease-in-out infinite',
|
||||
animationDelay: '1s'
|
||||
}}>✨</div>
|
||||
|
||||
{hoveredEmoji}
|
||||
</div>
|
||||
|
||||
{/* Arrow pointing down with glow */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-12px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '14px solid transparent',
|
||||
borderRight: '14px solid transparent',
|
||||
borderTop: '14px solid white',
|
||||
filter: 'drop-shadow(0 4px 8px rgba(0,0,0,0.3))'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add magnifying animations */}
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
@keyframes magnifyIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.5);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes pulseGlow {
|
||||
0%, 100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
@keyframes emojiFloat {
|
||||
0%, 100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
@keyframes sparkle {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(180deg);
|
||||
}
|
||||
}
|
||||
` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Add fade in animation
|
||||
const fadeInAnimation = `
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: scale(0.9); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
`
|
||||
|
||||
// Inject animation styles
|
||||
if (typeof document !== 'undefined' && !document.getElementById('emoji-picker-animations')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'emoji-picker-animations'
|
||||
style.textContent = fadeInAnimation
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
525
apps/web/src/app/arcade/matching/components/GameCard.tsx
Normal file
525
apps/web/src/app/arcade/matching/components/GameCard.tsx
Normal file
@@ -0,0 +1,525 @@
|
||||
'use client'
|
||||
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import type { GameCardProps } from '../context/types'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
|
||||
export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false }: GameCardProps) {
|
||||
const appConfig = useAbacusConfig()
|
||||
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
|
||||
|
||||
// Get active players array for mapping numeric IDs to actual players
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
.map(id => playerMap.get(id))
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined)
|
||||
|
||||
// Helper to get player index from ID (0-based)
|
||||
const getPlayerIndex = (playerId: string | undefined): number => {
|
||||
if (!playerId) return -1
|
||||
return activePlayers.findIndex(p => p.id === playerId)
|
||||
}
|
||||
|
||||
const cardBackStyles = css({
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backfaceVisibility: 'hidden',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontSize: '28px',
|
||||
fontWeight: 'bold',
|
||||
textShadow: '1px 1px 2px rgba(0,0,0,0.3)',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
userSelect: 'none',
|
||||
transition: 'all 0.2s ease'
|
||||
})
|
||||
|
||||
const cardFrontStyles = css({
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backfaceVisibility: 'hidden',
|
||||
borderRadius: '12px',
|
||||
background: 'white',
|
||||
border: '3px solid',
|
||||
transform: 'rotateY(180deg)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '8px',
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.2s ease'
|
||||
})
|
||||
|
||||
// Dynamic styling based on card type and state
|
||||
const getCardBackGradient = () => {
|
||||
if (isMatched) {
|
||||
// Player-specific colors for matched cards - find player index by ID
|
||||
const playerIndex = getPlayerIndex(card.matchedBy)
|
||||
if (playerIndex === 0) {
|
||||
return 'linear-gradient(135deg, #74b9ff, #0984e3)' // Blue for first player
|
||||
} else if (playerIndex === 1) {
|
||||
return 'linear-gradient(135deg, #fd79a8, #e84393)' // Pink for second player
|
||||
}
|
||||
return 'linear-gradient(135deg, #48bb78, #38a169)' // Default green for single player or 3+
|
||||
}
|
||||
|
||||
switch (card.type) {
|
||||
case 'abacus':
|
||||
return 'linear-gradient(135deg, #7b4397, #dc2430)'
|
||||
case 'number':
|
||||
return 'linear-gradient(135deg, #2E86AB, #A23B72)'
|
||||
case 'complement':
|
||||
return 'linear-gradient(135deg, #F18F01, #6A994E)'
|
||||
default:
|
||||
return 'linear-gradient(135deg, #667eea, #764ba2)'
|
||||
}
|
||||
}
|
||||
|
||||
const getCardBackIcon = () => {
|
||||
if (isMatched) {
|
||||
// Show player emoji for matched cards in multiplayer mode
|
||||
if (card.matchedBy) {
|
||||
const matchedPlayer = activePlayers.find(p => p.id === card.matchedBy)
|
||||
return matchedPlayer?.emoji || '✓'
|
||||
}
|
||||
return '✓' // Default checkmark for single player
|
||||
}
|
||||
|
||||
switch (card.type) {
|
||||
case 'abacus':
|
||||
return '🧮'
|
||||
case 'number':
|
||||
return '🔢'
|
||||
case 'complement':
|
||||
return '🤝'
|
||||
default:
|
||||
return '❓'
|
||||
}
|
||||
}
|
||||
|
||||
const getBorderColor = () => {
|
||||
if (isMatched) {
|
||||
// Player-specific border colors for matched cards
|
||||
const playerIndex = getPlayerIndex(card.matchedBy)
|
||||
if (playerIndex === 0) {
|
||||
return '#74b9ff' // Blue for first player
|
||||
} else if (playerIndex === 1) {
|
||||
return '#fd79a8' // Pink for second player
|
||||
}
|
||||
return '#48bb78' // Default green for single player or 3+
|
||||
}
|
||||
if (isFlipped) return '#667eea'
|
||||
return '#e2e8f0'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
perspective: '1000px',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
cursor: disabled || isMatched ? 'default' : 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
_hover: disabled || isMatched ? {} : {
|
||||
transform: 'translateY(-2px)'
|
||||
}
|
||||
})}
|
||||
onClick={disabled || isMatched ? undefined : onClick}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
textAlign: 'center',
|
||||
transition: 'transform 0.6s cubic-bezier(0.4, 0.0, 0.2, 1)',
|
||||
transformStyle: 'preserve-3d',
|
||||
transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)'
|
||||
})}
|
||||
>
|
||||
{/* Card Back (hidden/face-down state) */}
|
||||
<div
|
||||
className={cardBackStyles}
|
||||
style={{
|
||||
background: getCardBackGradient()
|
||||
}}
|
||||
>
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
})}>
|
||||
<div className={css({ fontSize: '32px' })}>
|
||||
{getCardBackIcon()}
|
||||
</div>
|
||||
{isMatched && (
|
||||
<div className={css({ fontSize: '14px', opacity: 0.9 })}>
|
||||
{card.matchedBy ? 'Claimed!' : 'Matched!'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Front (revealed/face-up state) */}
|
||||
<div
|
||||
className={cardFrontStyles}
|
||||
style={{
|
||||
borderColor: getBorderColor(),
|
||||
boxShadow: isMatched
|
||||
? getPlayerIndex(card.matchedBy) === 0
|
||||
? '0 0 20px rgba(116, 185, 255, 0.4)' // Blue glow for player 1
|
||||
: getPlayerIndex(card.matchedBy) === 1
|
||||
? '0 0 20px rgba(253, 121, 168, 0.4)' // Pink glow for player 2
|
||||
: '0 0 20px rgba(72, 187, 120, 0.4)' // Default green glow
|
||||
: isFlipped
|
||||
? '0 0 15px rgba(102, 126, 234, 0.3)'
|
||||
: 'none'
|
||||
}}
|
||||
>
|
||||
{/* Player Badge for matched cards */}
|
||||
{isMatched && card.matchedBy && (
|
||||
<>
|
||||
{/* Explosion Ring */}
|
||||
<div className={css({
|
||||
position: 'absolute',
|
||||
top: '6px',
|
||||
right: '6px',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
border: '3px solid',
|
||||
borderColor: getPlayerIndex(card.matchedBy) === 0 ? '#74b9ff' : '#fd79a8',
|
||||
animation: 'explosionRing 0.6s ease-out',
|
||||
zIndex: 9
|
||||
})} />
|
||||
|
||||
{/* Main Badge */}
|
||||
<div className={css({
|
||||
position: 'absolute',
|
||||
top: '6px',
|
||||
right: '6px',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
background: getPlayerIndex(card.matchedBy) === 0
|
||||
? 'linear-gradient(135deg, #74b9ff, #0984e3)'
|
||||
: 'linear-gradient(135deg, #fd79a8, #e84393)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px',
|
||||
boxShadow: getPlayerIndex(card.matchedBy) === 0
|
||||
? '0 0 20px rgba(116, 185, 255, 0.6), 0 0 40px rgba(116, 185, 255, 0.4)'
|
||||
: '0 0 20px rgba(253, 121, 168, 0.6), 0 0 40px rgba(253, 121, 168, 0.4)',
|
||||
animation: 'epicClaim 1.2s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
|
||||
zIndex: 10,
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '-2px',
|
||||
left: '-2px',
|
||||
right: '-2px',
|
||||
bottom: '-2px',
|
||||
borderRadius: '50%',
|
||||
background: getPlayerIndex(card.matchedBy) === 0
|
||||
? 'linear-gradient(45deg, #74b9ff, #a29bfe, #6c5ce7, #74b9ff)'
|
||||
: 'linear-gradient(45deg, #fd79a8, #fdcb6e, #e17055, #fd79a8)',
|
||||
animation: 'spinningHalo 2s linear infinite',
|
||||
zIndex: -1
|
||||
}
|
||||
})}>
|
||||
<span className={css({
|
||||
animation: 'emojiBlast 0.8s cubic-bezier(0.68, -0.55, 0.265, 1.55) 0.4s both',
|
||||
filter: 'drop-shadow(0 0 8px rgba(255,255,255,0.8))'
|
||||
})}>
|
||||
{card.matchedBy
|
||||
? activePlayers.find(p => p.id === card.matchedBy)?.emoji || '✓'
|
||||
: '✓'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sparkle Effects */}
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '22px',
|
||||
right: '22px',
|
||||
width: '4px',
|
||||
height: '4px',
|
||||
background: '#ffeaa7',
|
||||
borderRadius: '50%',
|
||||
animation: `sparkle${i + 1} 1.5s ease-out`,
|
||||
zIndex: 8
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{card.type === 'abacus' ? (
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
'& svg': {
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%'
|
||||
}
|
||||
})}>
|
||||
<AbacusReact
|
||||
value={card.number}
|
||||
columns="auto"
|
||||
beadShape={appConfig.beadShape}
|
||||
colorScheme={appConfig.colorScheme}
|
||||
hideInactiveBeads={appConfig.hideInactiveBeads}
|
||||
scaleFactor={0.8} // Smaller for card display
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
animated={false}
|
||||
/>
|
||||
</div>
|
||||
) : card.type === 'number' ? (
|
||||
<div className={css({
|
||||
fontSize: '32px',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.800',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
})}>
|
||||
{card.number}
|
||||
</div>
|
||||
) : card.type === 'complement' ? (
|
||||
<div className={css({
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '4px'
|
||||
})}>
|
||||
<div className={css({
|
||||
fontSize: '28px',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.800'
|
||||
})}>
|
||||
{card.number}
|
||||
</div>
|
||||
<div className={css({
|
||||
fontSize: '16px',
|
||||
color: 'gray.600',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
})}>
|
||||
<span>{card.targetSum === 5 ? '✋' : '🔟'}</span>
|
||||
<span>Friends</span>
|
||||
</div>
|
||||
{card.complement !== undefined && (
|
||||
<div className={css({
|
||||
fontSize: '12px',
|
||||
color: 'gray.500'
|
||||
})}>
|
||||
+ {card.complement} = {card.targetSum}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={css({
|
||||
fontSize: '24px',
|
||||
color: 'gray.500'
|
||||
})}>
|
||||
?
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Match animation overlay */}
|
||||
{isMatched && (
|
||||
<div className={css({
|
||||
position: 'absolute',
|
||||
top: '-5px',
|
||||
left: '-5px',
|
||||
right: '-5px',
|
||||
bottom: '-5px',
|
||||
borderRadius: '16px',
|
||||
background: 'linear-gradient(45deg, transparent, rgba(72, 187, 120, 0.3), transparent)',
|
||||
animation: 'pulse 2s infinite',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1
|
||||
})} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Add global animation styles
|
||||
const globalCardAnimations = `
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes explosionRing {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes epicClaim {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0) rotate(-360deg);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: scale(1.4) rotate(-180deg);
|
||||
}
|
||||
60% {
|
||||
transform: scale(0.8) rotate(-90deg);
|
||||
}
|
||||
80% {
|
||||
transform: scale(1.1) rotate(-30deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emojiBlast {
|
||||
0% {
|
||||
transform: scale(0) rotate(180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.5) rotate(-10deg);
|
||||
opacity: 1;
|
||||
}
|
||||
85% {
|
||||
transform: scale(0.9) rotate(5deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spinningHalo {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sparkle1 {
|
||||
0% { transform: translate(0, 0) scale(0); opacity: 1; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: translate(-20px, -15px) scale(1); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes sparkle2 {
|
||||
0% { transform: translate(0, 0) scale(0); opacity: 1; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: translate(15px, -20px) scale(1); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes sparkle3 {
|
||||
0% { transform: translate(0, 0) scale(0); opacity: 1; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: translate(-25px, 10px) scale(1); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes sparkle4 {
|
||||
0% { transform: translate(0, 0) scale(0); opacity: 1; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: translate(20px, 15px) scale(1); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes sparkle5 {
|
||||
0% { transform: translate(0, 0) scale(0); opacity: 1; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: translate(-10px, -25px) scale(1); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes sparkle6 {
|
||||
0% { transform: translate(0, 0) scale(0); opacity: 1; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: translate(25px, -5px) scale(1); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes bounceIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.3);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
70% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cardFlip {
|
||||
0% { transform: rotateY(0deg); }
|
||||
100% { transform: rotateY(180deg); }
|
||||
}
|
||||
|
||||
@keyframes matchSuccess {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes invalidMove {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-3px); }
|
||||
75% { transform: translateX(3px); }
|
||||
}
|
||||
`
|
||||
|
||||
// Inject global styles
|
||||
if (typeof document !== 'undefined' && !document.getElementById('memory-card-animations')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'memory-card-animations'
|
||||
style.textContent = globalCardAnimations
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user