Compare commits
311 Commits
abacus-rea
...
v2.16.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa29379a9b | ||
|
|
14746c568e | ||
|
|
c8da5a8340 | ||
|
|
83b9a4d976 | ||
|
|
815f90e916 | ||
|
|
c287b19a39 | ||
|
|
a2796b4347 | ||
|
|
c12351f2c9 | ||
|
|
9a9958a659 | ||
|
|
48b47e9bdb | ||
|
|
41aa205d04 | ||
|
|
388c25451d | ||
|
|
fa827ac792 | ||
|
|
c26138ffb5 | ||
|
|
168b98b888 | ||
|
|
b128db1783 | ||
|
|
df50239079 | ||
|
|
820eeb4fb0 | ||
|
|
90be7c053c | ||
|
|
442c6b4529 | ||
|
|
75b193e1d2 | ||
|
|
8d53b589aa | ||
|
|
af85b3e481 | ||
|
|
573d0df20d | ||
|
|
d312969747 | ||
|
|
7f65a67cef | ||
|
|
4d7f6f469f | ||
|
|
71b11f4ef0 | ||
|
|
e0d08a1aa2 | ||
|
|
62f3730542 | ||
|
|
98822ecda5 | ||
|
|
db9f9096b4 | ||
|
|
1fe507bc12 | ||
|
|
14ba422919 | ||
|
|
3541466630 | ||
|
|
c27973191f | ||
|
|
d423ff7612 | ||
|
|
80ad33eec0 | ||
|
|
f160d2e4af | ||
|
|
d14979907c | ||
|
|
c32f4dd1f6 | ||
|
|
b5ee04f576 | ||
|
|
ce30fcaf55 | ||
|
|
05eacac438 | ||
|
|
65828950a2 | ||
|
|
2856f4b83f | ||
|
|
3a01f4637d | ||
|
|
97378b70b7 | ||
|
|
3158addda1 | ||
|
|
71b0aac13c | ||
|
|
0543377bda | ||
|
|
d03c789879 | ||
|
|
15029ae52f | ||
|
|
a83dc097e4 | ||
|
|
0abec1a3bb | ||
|
|
5171be3d37 | ||
|
|
fc9eb253ad | ||
|
|
d474ef07d6 | ||
|
|
3cdc0695f4 | ||
|
|
10cf71527f | ||
|
|
678f4423b6 | ||
|
|
c5268b79de | ||
|
|
d9aadd1f81 | ||
|
|
4686f59d24 | ||
|
|
1219539585 | ||
|
|
87cc0b64fb | ||
|
|
c640a79a44 | ||
|
|
0d85331652 | ||
|
|
28a2e7d651 | ||
|
|
a27c36193e | ||
|
|
cc80a1454b | ||
|
|
8175c43533 | ||
|
|
b49630f3cb | ||
|
|
c4d8032d02 | ||
|
|
01ff114258 | ||
|
|
d173a178bc | ||
|
|
431668729c | ||
|
|
b5d0bee120 | ||
|
|
9dac431c1f | ||
|
|
5bbb212da9 | ||
|
|
c30f585810 | ||
|
|
0a768c65fb | ||
|
|
5ed2ab21ca | ||
|
|
1cb175982a | ||
|
|
ee6094d59d | ||
|
|
9d0c488f2b | ||
|
|
e7d2a73ddf | ||
|
|
63517cf45d | ||
|
|
5e3261f3be | ||
|
|
1e43e6945b | ||
|
|
94a1d9b110 | ||
|
|
9fa5652173 | ||
|
|
3fa6cce17a | ||
|
|
8f3dd9ec92 | ||
|
|
30abf33ee8 | ||
|
|
caa2bea7a8 | ||
|
|
12c3c37ff8 | ||
|
|
5c32209a2c | ||
|
|
ebfc88c5ea | ||
|
|
7eb55899c8 | ||
|
|
49f12f8cab | ||
|
|
b4c8cfaad2 | ||
|
|
f005fbbb77 | ||
|
|
5751dfef5c | ||
|
|
7ebb2be392 | ||
|
|
bc219c2ad6 | ||
|
|
3c002ab29d | ||
|
|
6800747f80 | ||
|
|
99906ae53d | ||
|
|
caebefdce8 | ||
|
|
a5fac5c75c | ||
|
|
9c71092278 | ||
|
|
5310463bec | ||
|
|
4eb49d1d44 | ||
|
|
a085de816f | ||
|
|
72db1f4a2c | ||
|
|
1e17278f94 | ||
|
|
f8ca248844 | ||
|
|
4adcc09643 | ||
|
|
e06727160c | ||
|
|
98384d264e | ||
|
|
e73191a729 | ||
|
|
327aee0b4b | ||
|
|
3353bcadc2 | ||
|
|
f92f7b592a | ||
|
|
dd1104310f | ||
|
|
ddbaf55aa2 | ||
|
|
60d70cd2f2 | ||
|
|
fc1838f4f5 | ||
|
|
3c245d29fa | ||
|
|
d1b9b72cfc | ||
|
|
3c00ebfe2f | ||
|
|
be6fb1a881 | ||
|
|
e157bbff43 | ||
|
|
e71c2b4da8 | ||
|
|
40cbe96385 | ||
|
|
bd0092e69a | ||
|
|
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 |
@@ -168,9 +168,22 @@
|
||||
"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/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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
57
.claude/terminology.md
Normal file
57
.claude/terminology.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Soroban Abacus Flashcards - Terminology Reference
|
||||
|
||||
## User vs Player vs Room Member
|
||||
|
||||
**CRITICAL**: Do not confuse these three concepts!
|
||||
|
||||
### Quick Reference
|
||||
|
||||
- **USER** = Identity/account (one per person, identified by `guestId` cookie)
|
||||
- **PLAYER** = Game avatar/profile (multiple per user, from `players` table)
|
||||
- **ROOM MEMBER** = USER's participation in a multiplayer room
|
||||
|
||||
### Key Rule
|
||||
|
||||
**When a USER joins a room, their ACTIVE PLAYERS join the game.**
|
||||
|
||||
Example:
|
||||
- USER "Jane" has 3 players: Alice, Bob, Charlie
|
||||
- Alice and Bob are active (`isActive: true`)
|
||||
- When Jane joins a room, Alice and Bob participate in the game
|
||||
- The `arcade_sessions.activePlayers` array contains `[alice_id, bob_id]`
|
||||
|
||||
### Database Schema
|
||||
|
||||
```
|
||||
users (identity)
|
||||
├─ players (avatars/profiles) - where isActive = true
|
||||
└─ room_members (room participation)
|
||||
|
||||
arcade_sessions
|
||||
├─ userId: references users.id
|
||||
├─ activePlayers: Array<player.id> ← PLAYER IDs, not USER IDs!
|
||||
└─ roomId: references arcade_rooms.id
|
||||
```
|
||||
|
||||
### Common Mistakes to Avoid
|
||||
|
||||
❌ Using USER ID in `activePlayers` - should be PLAYER IDs
|
||||
❌ Assuming one USER = one PLAYER - users can have multiple players
|
||||
❌ Tracking game moves/scores by USER - should track by PLAYER
|
||||
❌ Confusing room_members.displayName with players.name - different concepts
|
||||
|
||||
### Full Documentation
|
||||
|
||||
See: `docs/terminology-user-player-room.md` for complete explanation with examples.
|
||||
|
||||
## Other Project-Specific Terms
|
||||
|
||||
### Arcade vs Games
|
||||
|
||||
- **`/games/*`** - Single player or local multiplayer (same device)
|
||||
- **`/arcade/*`** - Online multiplayer with sessions and rooms
|
||||
|
||||
### Session Types
|
||||
|
||||
- **Solo Session**: `arcade_sessions.roomId = null`, user playing alone
|
||||
- **Room Session**: `arcade_sessions.roomId = room_xyz`, shared game state across room members
|
||||
@@ -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:
|
||||
|
||||
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/
|
||||
|
||||
725
CHANGELOG.md
725
CHANGELOG.md
@@ -1,3 +1,728 @@
|
||||
## [2.16.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.6...v2.16.7) (2025-10-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* compile TypeScript server files to JavaScript for production ([83b9a4d](https://github.com/antialias/soroban-abacus-flashcards/commit/83b9a4d976fa540782826afa13a35c92e706bf1e))
|
||||
* remove standalone output mode incompatible with custom server ([c8da5a8](https://github.com/antialias/soroban-abacus-flashcards/commit/c8da5a8340c8798bba452b43244bc0e04ce8b0c5))
|
||||
* update Dockerfile for non-standalone production builds ([14746c5](https://github.com/antialias/soroban-abacus-flashcards/commit/14746c568e58f4a847e0da2d866dbaeabf5a0e8b))
|
||||
|
||||
## [2.16.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.5...v2.16.6) (2025-10-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct static files and public path in Docker image ([c287b19](https://github.com/antialias/soroban-abacus-flashcards/commit/c287b19a39e1506033db6de39aa4d3761cb65d62))
|
||||
|
||||
## [2.16.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.4...v2.16.5) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct node_modules path for pnpm symlinks in Docker ([c12351f](https://github.com/antialias/soroban-abacus-flashcards/commit/c12351f2c99daaed710a1136eb13f6ccc54cbcff))
|
||||
|
||||
## [2.16.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.3...v2.16.4) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct Docker CMD to use root-level server.js ([48b47e9](https://github.com/antialias/soroban-abacus-flashcards/commit/48b47e9bdb0da44746282cd7cf7599a69bf5130d))
|
||||
|
||||
## [2.16.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.2...v2.16.3) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use game state playerMetadata instead of GameModeContext in UI components ([388c254](https://github.com/antialias/soroban-abacus-flashcards/commit/388c25451d11b85236c1f7682fe2f7a62a15d5eb))
|
||||
|
||||
## [2.16.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.1...v2.16.2) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use only local user's players in LocalMemoryPairsProvider ([c26138f](https://github.com/antialias/soroban-abacus-flashcards/commit/c26138ffb55a237a99cb6ff399c8a2ac54a22b51))
|
||||
|
||||
## [2.16.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.0...v2.16.1) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* convert LocalMemoryPairsProvider to pure client-side with useReducer ([b128db1](https://github.com/antialias/soroban-abacus-flashcards/commit/b128db1783a8dcffe7879745c3342add2f9ffe29))
|
||||
|
||||
## [2.16.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.15.0...v2.16.0) (2025-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* fade out hover avatar when player stops hovering ([820eeb4](https://github.com/antialias/soroban-abacus-flashcards/commit/820eeb4fb03ad8be6a86dd0a26e089052224f427))
|
||||
|
||||
## [2.15.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.3...v2.15.0) (2025-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement smooth hover avatar animations with react-spring ([442c6b4](https://github.com/antialias/soroban-abacus-flashcards/commit/442c6b4529ba5c820b1fe8a64805a3d85489a8ea))
|
||||
|
||||
## [2.14.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.2...v2.14.3) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enable smooth spring animations between card hovers ([8d53b58](https://github.com/antialias/soroban-abacus-flashcards/commit/8d53b589aa17ebc6d0a9251b3006fd8a90f90a61))
|
||||
|
||||
## [2.14.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.1...v2.14.2) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct avatar positioning to prevent fly-in animation ([573d0df](https://github.com/antialias/soroban-abacus-flashcards/commit/573d0df20dcdac41021c46feb423dbf3782728f6))
|
||||
|
||||
## [2.14.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.14.0...v2.14.1) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* prevent avatar fly-in and hide local player's own hover ([7f65a67](https://github.com/antialias/soroban-abacus-flashcards/commit/7f65a67cef3d7f0ebce1bd7417972a6138acfc46))
|
||||
|
||||
## [2.14.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.13.0...v2.14.0) (2025-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* improve hover avatars with smooth animation and 3D elevation ([71b11f4](https://github.com/antialias/soroban-abacus-flashcards/commit/71b11f4ef08a5f9c3f1c1aaabca21ef023d5c0ce))
|
||||
|
||||
## [2.13.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.3...v2.13.0) (2025-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement networked hover presence for multiplayer gameplay ([62f3730](https://github.com/antialias/soroban-abacus-flashcards/commit/62f3730542334a0580f5dad1c73adc333614ee58))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* move canModifyPlayers logic into provider layer ([db9f909](https://github.com/antialias/soroban-abacus-flashcards/commit/db9f9096b446b078e1b4dfe970723bef54a6f4ae))
|
||||
* properly separate LocalMemoryPairsProvider and RoomMemoryPairsProvider ([98822ec](https://github.com/antialias/soroban-abacus-flashcards/commit/98822ecda52bf004d9950e3f4c92c834fd820e49))
|
||||
|
||||
## [2.12.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.2...v2.12.3) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* always show game control buttons in room-based sessions ([14ba422](https://github.com/antialias/soroban-abacus-flashcards/commit/14ba422919abd648e2a134ce167a5e6fd9f84e73))
|
||||
|
||||
## [2.12.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.1...v2.12.2) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use RoomMemoryPairsProvider in room page ([c279731](https://github.com/antialias/soroban-abacus-flashcards/commit/c27973191f0144604e17a8a14adf0a88df476e27))
|
||||
|
||||
## [2.12.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.12.0...v2.12.1) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* export MemoryPairsContext to fix provider hook error ([80ad33e](https://github.com/antialias/soroban-abacus-flashcards/commit/80ad33eec0b6946702eaa9cf1b1c246852864b00))
|
||||
|
||||
## [2.12.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.11.0...v2.12.0) (2025-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add networked hover state infrastructure for multiplayer presence ([d149799](https://github.com/antialias/soroban-abacus-flashcards/commit/d14979907c5df9b793a1c110028fc5b54457f507))
|
||||
|
||||
## [2.11.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.1...v2.11.0) (2025-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add pause/resume game state architecture ([05eacac](https://github.com/antialias/soroban-abacus-flashcards/commit/05eacac438dbaf405ce91e188c53dbbe2e9f9507))
|
||||
* add Resume button and config change warning to setup UI ([b5ee04f](https://github.com/antialias/soroban-abacus-flashcards/commit/b5ee04f57651f53517468fcc4c456f0ccb65a8e2))
|
||||
* implement pause/resume in game providers with optimistic updates ([ce30fca](https://github.com/antialias/soroban-abacus-flashcards/commit/ce30fcaf55270f9089249bd13ba73a25fbfa5ab4))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* convert guestId to internal userId for player ownership check ([3a01f46](https://github.com/antialias/soroban-abacus-flashcards/commit/3a01f4637d2081c66fe37c7f8cfee229442ec744))
|
||||
* implement shared session architecture for room-based multiplayer ([2856f4b](https://github.com/antialias/soroban-abacus-flashcards/commit/2856f4b83fbcc6483d96cc6e7da2fe5bc911625d))
|
||||
|
||||
## [2.10.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.1...v2.10.2) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* convert guestId to internal userId for player ownership check ([3a01f46](https://github.com/antialias/soroban-abacus-flashcards/commit/3a01f4637d2081c66fe37c7f8cfee229442ec744))
|
||||
* implement shared session architecture for room-based multiplayer ([2856f4b](https://github.com/antialias/soroban-abacus-flashcards/commit/2856f4b83fbcc6483d96cc6e7da2fe5bc911625d))
|
||||
|
||||
## [2.10.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.0...v2.10.1) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enforce player ownership authorization for multiplayer games ([71b0aac](https://github.com/antialias/soroban-abacus-flashcards/commit/71b0aac13c970c03fe8d296d41e9472ad72a00fa))
|
||||
|
||||
## [2.10.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.9.0...v2.10.0) (2025-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement rich Radix UI tooltips for player avatars ([d03c789](https://github.com/antialias/soroban-abacus-flashcards/commit/d03c7898799b378f912f47d7267a00bc7ce3d580))
|
||||
|
||||
## [2.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.7...v2.9.0) (2025-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement auto-save for player settings modal ([a83dc09](https://github.com/antialias/soroban-abacus-flashcards/commit/a83dc097e43c265a297281da54754f58ac831754))
|
||||
|
||||
## [2.8.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.6...v2.8.7) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enable real-time player name updates across room members ([5171be3](https://github.com/antialias/soroban-abacus-flashcards/commit/5171be3d37980eb1c98aa0d1e1d6e06f589763d1))
|
||||
|
||||
## [2.8.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.5...v2.8.6) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* prevent duplicate display of network avatars in nav ([d474ef0](https://github.com/antialias/soroban-abacus-flashcards/commit/d474ef07d69cf0b4f5dedd404616e3bbee7289fe))
|
||||
|
||||
## [2.8.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.4...v2.8.5) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove redirect loop by not redirecting from room page ([10cf715](https://github.com/antialias/soroban-abacus-flashcards/commit/10cf71527f7cede7fd93e502dbfc59df99b5a524))
|
||||
|
||||
## [2.8.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.3...v2.8.4) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* prevent redirect loops by checking if already at target URL ([c5268b7](https://github.com/antialias/soroban-abacus-flashcards/commit/c5268b79dee66aa02e14e2024fe1c6242a172ed3))
|
||||
|
||||
## [2.8.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.2...v2.8.3) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove ArcadeGuardedPage from room page to prevent redirect loop ([4686f59](https://github.com/antialias/soroban-abacus-flashcards/commit/4686f59d245b2b502dc0764c223a5ce84bf1af44))
|
||||
|
||||
## [2.8.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.1...v2.8.2) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* revert to showing only active players in room games ([87cc0b6](https://github.com/antialias/soroban-abacus-flashcards/commit/87cc0b64fb5f3debaf1d2f122aecfefc62922fed))
|
||||
|
||||
## [2.8.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.0...v2.8.1) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* include all players from room members in room games ([28a2e7d](https://github.com/antialias/soroban-abacus-flashcards/commit/28a2e7d6511e70b83adf7d0465789a91026bc1f7))
|
||||
|
||||
## [2.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.4...v2.8.0) (2025-10-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement room-wide multi-user game state synchronization ([8175c43](https://github.com/antialias/soroban-abacus-flashcards/commit/8175c43533c474fff48eb128c97747033bfb434a))
|
||||
|
||||
|
||||
### Tests
|
||||
|
||||
* add comprehensive tests for arcade guard and room navigation ([b49630f](https://github.com/antialias/soroban-abacus-flashcards/commit/b49630f3cb02ebbac75b4680948bbface314dccb))
|
||||
|
||||
## [2.7.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.3...v2.7.4) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* respect enabled flag in useArcadeGuard WebSocket redirects ([01ff114](https://github.com/antialias/soroban-abacus-flashcards/commit/01ff114258ff7ab43ef2bd79b41c7035fe02ac70))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* move room management pages to /arcade-rooms ([4316687](https://github.com/antialias/soroban-abacus-flashcards/commit/431668729cfb145d6e0c13947de2a82f27fa400d))
|
||||
|
||||
## [2.7.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.2...v2.7.3) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* set room sessions to use /arcade/room URL ([9dac431](https://github.com/antialias/soroban-abacus-flashcards/commit/9dac431c1f91c246f67a059cda3cff6cbef40a43))
|
||||
|
||||
## [2.7.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.1...v2.7.2) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add hasAttemptedFetch flag to prevent premature redirect ([c30f585](https://github.com/antialias/soroban-abacus-flashcards/commit/c30f58581028878350282cad5231d614590d9f2b))
|
||||
|
||||
## [2.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.0...v2.7.1) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve race condition in /arcade/room redirect ([5ed2ab2](https://github.com/antialias/soroban-abacus-flashcards/commit/5ed2ab21cab408147081a493c8dd6b1de48b2d01))
|
||||
|
||||
## [2.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.6.0...v2.7.0) (2025-10-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* extend GameModeContext to support room-based multiplayer ([ee6094d](https://github.com/antialias/soroban-abacus-flashcards/commit/ee6094d59d26a9e80ba5d023ca6dc13143bea308))
|
||||
|
||||
## [2.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.5.0...v2.6.0) (2025-10-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* refactor room addressing to /arcade/room ([e7d2a73](https://github.com/antialias/soroban-abacus-flashcards/commit/e7d2a73ddf2048691325a18e3d71a7ece444c131))
|
||||
|
||||
## [2.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.6...v2.5.0) (2025-10-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* display room info and network players in mini app nav ([5e3261f](https://github.com/antialias/soroban-abacus-flashcards/commit/5e3261f3bec8c19ec88c9a35a7e6ef8eda88a55e))
|
||||
|
||||
## [2.4.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.5...v2.4.6) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* real-time room member updates via globalThis socket.io sharing ([94a1d9b](https://github.com/antialias/soroban-abacus-flashcards/commit/94a1d9b11058bfb4b54a4753e143cf85f215e913))
|
||||
|
||||
## [2.4.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.4...v2.4.5) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* send all members (not just online) in socket broadcasts ([3fa6cce](https://github.com/antialias/soroban-abacus-flashcards/commit/3fa6cce17a7acd940cf5a9e6433bf6c4b497540c))
|
||||
|
||||
## [2.4.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.3...v2.4.4) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correctly access getSocketIO from dynamic import ([30abf33](https://github.com/antialias/soroban-abacus-flashcards/commit/30abf33ee86b36f2a98014e5b017fa8e466a2107))
|
||||
|
||||
## [2.4.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.2...v2.4.3) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve socket-server import path for Next.js build ([12c3c37](https://github.com/antialias/soroban-abacus-flashcards/commit/12c3c37ff8e1d3df71d72e527c08fa975043c504))
|
||||
|
||||
## [2.4.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.1...v2.4.2) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* broadcast member join/leave events immediately via API ([ebfc88c](https://github.com/antialias/soroban-abacus-flashcards/commit/ebfc88c5ea0a8a0fdda039fa129e1054b9c42e65))
|
||||
|
||||
## [2.4.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.4.0...v2.4.1) (2025-10-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* make leave room button actually remove user from room ([49f12f8](https://github.com/antialias/soroban-abacus-flashcards/commit/49f12f8cab631fedd33f1bc09febfdc95e444625))
|
||||
|
||||
## [2.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.3.1...v2.4.0) (2025-10-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add arcade room/session info and network players to nav ([6800747](https://github.com/antialias/soroban-abacus-flashcards/commit/6800747f80a29c91ba0311a8330d594c1074097d))
|
||||
* add real-time WebSocket updates for room membership ([7ebb2be](https://github.com/antialias/soroban-abacus-flashcards/commit/7ebb2be3927762a5fe9b6fb7fb15d6b88abb7b6a))
|
||||
* implement modal room enforcement (one room per user) ([f005fbb](https://github.com/antialias/soroban-abacus-flashcards/commit/f005fbbb773f4d250b80d71593490976af82d5a5))
|
||||
* improve room navigation and membership UI ([bc219c2](https://github.com/antialias/soroban-abacus-flashcards/commit/bc219c2ad66707f03e7a6cf587b9d190c736e26d))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* auto-cleanup orphaned arcade sessions without valid rooms ([3c002ab](https://github.com/antialias/soroban-abacus-flashcards/commit/3c002ab29d1b72a0e1ffb70bb0744dc560e7bdc2))
|
||||
* show correct join/leave button based on room membership ([5751dfe](https://github.com/antialias/soroban-abacus-flashcards/commit/5751dfef5c81981937cd5300c4256e5b74bb7488))
|
||||
|
||||
## [2.3.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.3.0...v2.3.1) (2025-10-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing DOMPoint properties to getPointAtLength mock ([1e17278](https://github.com/antialias/soroban-abacus-flashcards/commit/1e17278f942b3fbcc5d05be746178f2e780f0bd9))
|
||||
* add missing name property to Passenger test mocks ([f8ca248](https://github.com/antialias/soroban-abacus-flashcards/commit/f8ca2488447e89151085942f708f6acf350a2747))
|
||||
* add non-null assertions to skillConfiguration utilities ([9c71092](https://github.com/antialias/soroban-abacus-flashcards/commit/9c7109227822884d25f8546739c80c6e7491e28d))
|
||||
* add optional chaining to stepBeadHighlights access ([a5fac5c](https://github.com/antialias/soroban-abacus-flashcards/commit/a5fac5c75c8cd67b218a5fd5ad98818dad74ab67))
|
||||
* add showAsAbacus property to ComplementQuestion type ([4adcc09](https://github.com/antialias/soroban-abacus-flashcards/commit/4adcc096430fbb03f0a8b2f0aef4be239aff9cd0))
|
||||
* add userId to optimistic player in useCreatePlayer ([5310463](https://github.com/antialias/soroban-abacus-flashcards/commit/5310463becd0974291cff49522ae5669a575410d))
|
||||
* change TypeScript moduleResolution from bundler to node ([327aee0](https://github.com/antialias/soroban-abacus-flashcards/commit/327aee0b4b5c0b0b2bf3eeb48d861bb3068f6127))
|
||||
* convert Jest mocks to Vitest in useSteamJourney tests ([e067271](https://github.com/antialias/soroban-abacus-flashcards/commit/e06727160c70a1ab38a003104d1fef8fb83ff92d))
|
||||
* convert player IDs from number to string in arcade tests ([72db1f4](https://github.com/antialias/soroban-abacus-flashcards/commit/72db1f4a2c3f930025cd5ced3fcf7c810dcc569d))
|
||||
* rewrite layout.nav.test to match actual RootLayout props ([a085de8](https://github.com/antialias/soroban-abacus-flashcards/commit/a085de816fcdeb055addabb8aec391b111cb5f94))
|
||||
* update useArcadeGuard tests with proper useViewerId mock ([4eb49d1](https://github.com/antialias/soroban-abacus-flashcards/commit/4eb49d1d44e1d85526ef6564f88a8fbcebffb4d2))
|
||||
* use Object.defineProperty for NODE_ENV in middleware tests ([e73191a](https://github.com/antialias/soroban-abacus-flashcards/commit/e73191a7298dbb6dd15da594267ea6221062c36b))
|
||||
* wrap Buffer in Uint8Array for Next.js Response API ([98384d2](https://github.com/antialias/soroban-abacus-flashcards/commit/98384d264e4a10d1836aa9f2e69151b122ffa7b0))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add explicit package.json script references to regime docs ([3353bca](https://github.com/antialias/soroban-abacus-flashcards/commit/3353bcadc2849104248c624973274ed90b86722a))
|
||||
* establish mandatory code quality regime for Claude Code ([dd11043](https://github.com/antialias/soroban-abacus-flashcards/commit/dd1104310f4e0e85640730ea0e96e4adda4bc505))
|
||||
* expand quality regime to define "done" for all work ([f92f7b5](https://github.com/antialias/soroban-abacus-flashcards/commit/f92f7b592af38ba9d0f5b1db3a061d63d92a5093))
|
||||
|
||||
## [2.3.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.2.1...v2.3.0) (2025-10-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add Biome + ESLint linting setup ([fc1838f](https://github.com/antialias/soroban-abacus-flashcards/commit/fc1838f4f53a4f8d8f1c5303de3a63f12d9c9303))
|
||||
|
||||
|
||||
### Styles
|
||||
|
||||
* apply Biome formatting to entire codebase ([60d70cd](https://github.com/antialias/soroban-abacus-flashcards/commit/60d70cd2f2f2b1d250c4c645889af4334968cb7e))
|
||||
|
||||
## [2.2.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.2.0...v2.2.1) (2025-10-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove remaining typst-dependent files ([d1b9b72](https://github.com/antialias/soroban-abacus-flashcards/commit/d1b9b72cfc2f2ba36c40d7ae54bc6fdfcc5f34da))
|
||||
|
||||
## [2.2.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.3...v2.2.0) (2025-10-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* remove typst-related code and routes ([be6fb1a](https://github.com/antialias/soroban-abacus-flashcards/commit/be6fb1a881b983f9830d36c079b7b41f35153b8a))
|
||||
|
||||
## [2.1.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.2...v2.1.3) (2025-10-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove .npmrc from Dockerfile COPY ([e71c2b4](https://github.com/antialias/soroban-abacus-flashcards/commit/e71c2b4da85076dfc97401fc170cd88cb0aa4375))
|
||||
|
||||
## [2.1.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.1...v2.1.2) (2025-10-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* revert to default pnpm mode for Docker compatibility ([bd0092e](https://github.com/antialias/soroban-abacus-flashcards/commit/bd0092e69ac4f74ea89b8d31399cf72f57484cbb))
|
||||
|
||||
## [2.1.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.1.0...v2.1.1) (2025-10-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ignore all node_modules in Docker ([4792dde](https://github.com/antialias/soroban-abacus-flashcards/commit/4792dde1beef9c6cb84a27bc6bb6acfa43919a72))
|
||||
|
||||
## [2.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.7...v2.1.0) (2025-10-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* remove typst dependencies ([eedce28](https://github.com/antialias/soroban-abacus-flashcards/commit/eedce28572035897001f6b8a08f79beaa2360d44))
|
||||
|
||||
## [2.0.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.6...v2.0.7) (2025-10-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* preserve workspace node_modules in Docker for hoisted mode ([4f8aaf0](https://github.com/antialias/soroban-abacus-flashcards/commit/4f8aaf04aadda11ce9ec470dec44f78062929e77))
|
||||
|
||||
## [2.0.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.5...v2.0.6) (2025-10-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ignore nested node_modules in Docker ([f554592](https://github.com/antialias/soroban-abacus-flashcards/commit/f554592272c2e92d7f1ec6550211518de9c3242f))
|
||||
|
||||
## [2.0.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.4...v2.0.5) (2025-10-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use .npmrc in Docker for hoisted mode consistency ([2df8cdc](https://github.com/antialias/soroban-abacus-flashcards/commit/2df8cdc88ed03b6b04642a3441e17c6fda11d2a5))
|
||||
|
||||
## [2.0.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.3...v2.0.4) (2025-10-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove .npmrc in Docker to avoid hoisted mode issues ([2a77d75](https://github.com/antialias/soroban-abacus-flashcards/commit/2a77d755b7820b5b6b52ea99db418e6d071d726e))
|
||||
|
||||
## [2.0.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.2...v2.0.3) (2025-10-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove duplicate PlayerStatusBar story file from arcade ([4e721f7](https://github.com/antialias/soroban-abacus-flashcards/commit/4e721f765a29fe8628d4e34ef94cdf5728eea3dc))
|
||||
|
||||
## [2.0.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.1...v2.0.2) (2025-10-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update Dockerfile pnpm version and fix TypeScript config ([43077a8](https://github.com/antialias/soroban-abacus-flashcards/commit/43077a80e271a793c88f100874914ae6f3c515b5))
|
||||
|
||||
## [2.0.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.0.0...v2.0.1) (2025-10-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add @types/minimatch to abacus-react devDependencies ([fa45475](https://github.com/antialias/soroban-abacus-flashcards/commit/fa4547543dcd0cddc7cc9ff9da62f60a4717fb1f))
|
||||
|
||||
## [2.0.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v1.2.1...v2.0.0) (2025-10-07)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* abacus-react package now has independent versioning from monorepo
|
||||
|
||||
### Features
|
||||
|
||||
* **abacus-react:** add dual publishing to npm and GitHub Packages ([242ee52](https://github.com/antialias/soroban-abacus-flashcards/commit/242ee523edebe2cfc5db27cc72fba0315072bec2))
|
||||
* **abacus-react:** comprehensive README overhaul with current capabilities ([0ce351e](https://github.com/antialias/soroban-abacus-flashcards/commit/0ce351e572ac34fa816ee7533a26403c843d93f3))
|
||||
* **abacus-react:** configure GitHub Packages-only publishing workflow ([5eeedd9](https://github.com/antialias/soroban-abacus-flashcards/commit/5eeedd9a59a6b3898cadb30c413daa791a9561ee))
|
||||
* **abacus-react:** enable dual publishing to npm and GitHub Packages ([176a196](https://github.com/antialias/soroban-abacus-flashcards/commit/176a1961d05f99908a72837cf4e8ec93c0d33145))
|
||||
* **abacus-react:** enhance package description with semantic versioning details ([af037b5](https://github.com/antialias/soroban-abacus-flashcards/commit/af037b5e0a1ded5460f95498eb1fb5ac19c2e3fa))
|
||||
* **abacus-react:** implement GitHub Packages-only publishing workflow ([b194599](https://github.com/antialias/soroban-abacus-flashcards/commit/b194599f6029015b1aba0e57eb5fe9f83b89d403))
|
||||
* **abacus-react:** implement GitHub-only semantic release with manual package publishing ([33b0567](https://github.com/antialias/soroban-abacus-flashcards/commit/33b056769811d1cf1c41dee9e65f6e12188e6f5f))
|
||||
* **abacus-react:** simplify to GitHub Packages-only publishing ([acc126b](https://github.com/antialias/soroban-abacus-flashcards/commit/acc126bd5a0f0b2017263593ac2e3a180606f17b))
|
||||
* **abacus-react:** update description to mention GitHub Packages support ([af77256](https://github.com/antialias/soroban-abacus-flashcards/commit/af7725622e15801f9e56af12930c4e14c5e67c53))
|
||||
* **abacus-react:** use environment variables to override npm registry ([ad444e1](https://github.com/antialias/soroban-abacus-flashcards/commit/ad444e108f76d3014e492ddc94de0e52c61743ea))
|
||||
* add API routes for players and user stats ([6f940e2](https://github.com/antialias/soroban-abacus-flashcards/commit/6f940e24d663cc06084a943df4743c2a1c1b3c33))
|
||||
* add arcade matching game components and utilities ([ff16303](https://github.com/antialias/soroban-abacus-flashcards/commit/ff16303a7cd2880fcdfd51ef8a744e245905d87d))
|
||||
* add arcade room system database schema and managers (Phase 1) ([a9175a0](https://github.com/antialias/soroban-abacus-flashcards/commit/a9175a050c1668a6ba066078e0bdbd944b4eb960))
|
||||
* add build info API endpoint ([571664e](https://github.com/antialias/soroban-abacus-flashcards/commit/571664e725b63f22fa9f0bca8a1c518a54441dec))
|
||||
* add build info generation script ([416dc89](https://github.com/antialias/soroban-abacus-flashcards/commit/416dc897e26ab93930b52faf77da3a6ffd4a0fb9))
|
||||
* add category browsing and scrolling to emoji picker ([616a50e](https://github.com/antialias/soroban-abacus-flashcards/commit/616a50e234f79e271cb0bd9c959866d2d2e5ac82))
|
||||
* add complement display options and unify equation display ([2ed7b2c](https://github.com/antialias/soroban-abacus-flashcards/commit/2ed7b2cbf8ad7c18b14c0e86b04a3ba96cc4de0b))
|
||||
* add Complement Race game with three unique game modes ([582bce4](https://github.com/antialias/soroban-abacus-flashcards/commit/582bce411f5e89fe1ee677321d06ca7d0fd78701))
|
||||
* add comprehensive E2E testing with Playwright ([d58053f](https://github.com/antialias/soroban-abacus-flashcards/commit/d58053fad3ab06b9884b46dbb6807e938426dbb5))
|
||||
* add comprehensive Storybook stories for PlayerStatusBar ([8973241](https://github.com/antialias/soroban-abacus-flashcards/commit/8973241297d50604028bde95b9ebbf033688db89))
|
||||
* add configuration access to active player emojis in prominent nav ([6049a7f](https://github.com/antialias/soroban-abacus-flashcards/commit/6049a7f6b7481ca42a7907d11d93676549bf6629))
|
||||
* add configuration access to fullscreen player selection ([b85968b](https://github.com/antialias/soroban-abacus-flashcards/commit/b85968bcb6afa379d50242185e7743f6fe4ba982))
|
||||
* add consecutive match tracking system for escalating celebrations ([111c0ce](https://github.com/antialias/soroban-abacus-flashcards/commit/111c0ced715be7cade006387d01f4e2f52c59be9))
|
||||
* add CSS animations and visual feedback system ([80e33e2](https://github.com/antialias/soroban-abacus-flashcards/commit/80e33e25b3d30a44a1a5294997d56949e2aeef8b))
|
||||
* add deployment info modal with keyboard shortcut ([43be7ac](https://github.com/antialias/soroban-abacus-flashcards/commit/43be7ac83a9ba3e0ad970f4588729ba2ad394702))
|
||||
* add direct URL routes for each game mode ([a08f053](https://github.com/antialias/soroban-abacus-flashcards/commit/a08f0535bf6dfb424e8f9764b37ce6912db6021c))
|
||||
* add exitSession to MemoryPairsContextValue interface ([abc2ea5](https://github.com/antialias/soroban-abacus-flashcards/commit/abc2ea50d07e87537ced649bcc9276ef95a3bc4e))
|
||||
* add GameControlButtons component with unit tests ([1f45c17](https://github.com/antialias/soroban-abacus-flashcards/commit/1f45c17e0a68db2a452844f87217671223cf7bb0))
|
||||
* add guest session system with JWT tokens ([10d8aaf](https://github.com/antialias/soroban-abacus-flashcards/commit/10d8aaf814275a9c3f08e0f1b39970c3ab1a8427))
|
||||
* add initialStyle prop to ComplementRaceProvider ([f3bc2f6](https://github.com/antialias/soroban-abacus-flashcards/commit/f3bc2f6d926b9c8d9229636e3ed688bf9ea3baf3))
|
||||
* add intelligent on-screen number pad for devices without keyboards ([d4740ff](https://github.com/antialias/soroban-abacus-flashcards/commit/d4740ff99709be915c41f51d973706f6ff2774b3))
|
||||
* add interactive remove buttons for players in mini nav ([fa1cf96](https://github.com/antialias/soroban-abacus-flashcards/commit/fa1cf967898bdc396b0bfdcbfe2147a06e189190))
|
||||
* add magnifying glass preview on emoji hover ([2c88b6b](https://github.com/antialias/soroban-abacus-flashcards/commit/2c88b6b5f3ba3a47007aa832c2e204bf2ebcc90b))
|
||||
* add middleware for pathname header support in [@nav](https://github.com/nav) fallback ([b7e7c4b](https://github.com/antialias/soroban-abacus-flashcards/commit/b7e7c4beff1e37e90e9e20a890c5af7a134a7fca))
|
||||
* add mini app nav to arcade page ([a854fe3](https://github.com/antialias/soroban-abacus-flashcards/commit/a854fe3dc935b10db8dc71569c0c8abd81938e4c))
|
||||
* add optimistic updates and remove dead code ([b62cf26](https://github.com/antialias/soroban-abacus-flashcards/commit/b62cf26fb6597bae9a590f8b8d630fd31a8dd321))
|
||||
* add passenger boarding system with station-based pickup ([23a9016](https://github.com/antialias/soroban-abacus-flashcards/commit/23a9016245e061b65151735db31aebdc9d36ed1d))
|
||||
* add player types and migration utilities ([79f44b2](https://github.com/antialias/soroban-abacus-flashcards/commit/79f44b25d6c17f119c3ae225fb449be27c77c56d))
|
||||
* add PlayerStatusBar with escalating celebration animations ([7f8c90a](https://github.com/antialias/soroban-abacus-flashcards/commit/7f8c90acea84b208df0e3e23e80a02cf425c0950))
|
||||
* add prominent game context display to mini nav with smooth transitions ([8792393](https://github.com/antialias/soroban-abacus-flashcards/commit/8792393956b01a5a8ca67d78209e6defb6a11903))
|
||||
* add React Query setup with api helper ([a3878a8](https://github.com/antialias/soroban-abacus-flashcards/commit/a3878a8537fe123fa345d2d2990b3cd76132ba1e))
|
||||
* add realistic mountains with peaks and ground terrain ([99cdfa8](https://github.com/antialias/soroban-abacus-flashcards/commit/99cdfa8a0ba63e7b523466d8b2108bca05b0310a))
|
||||
* add security tests and userId injection protection ([aa1ad31](https://github.com/antialias/soroban-abacus-flashcards/commit/aa1ad315ef75af5b6833a3a3628a9bbceb80c03c))
|
||||
* add server-side validation for player modifications during active arcade sessions ([3b3cad4](https://github.com/antialias/soroban-abacus-flashcards/commit/3b3cad4b769b0ed9ed8e6dc2363bcaf13cc8e08a))
|
||||
* add Setup button to exit arcade sessions ([ae1318e](https://github.com/antialias/soroban-abacus-flashcards/commit/ae1318e8bf2c584853ceeb38336d871110f13a39))
|
||||
* add smooth spring animations to pressure gauge ([863a2e1](https://github.com/antialias/soroban-abacus-flashcards/commit/863a2e1319448381c853540301886fb4a169e112))
|
||||
* add sound settings support to AbacusReact component ([90b9ffa](https://github.com/antialias/soroban-abacus-flashcards/commit/90b9ffa0d8659891bfe8062217e45245bbff5d5a))
|
||||
* add train car system with smooth boarding/disembarking animations ([1613912](https://github.com/antialias/soroban-abacus-flashcards/commit/1613912740756d984205e3625791c1d8a2a6fa51))
|
||||
* add Web Audio API sound effects system with 16 sound types ([90ba866](https://github.com/antialias/soroban-abacus-flashcards/commit/90ba86640c7062a00d2c7553827a61524ec17da1))
|
||||
* **complement-race:** add abacus displays to pressure gauge ([c5ebc63](https://github.com/antialias/soroban-abacus-flashcards/commit/c5ebc635afb6e78f9f4b1192ff39dcec53879a60))
|
||||
* complete themed navigation system with game-specific chrome ([0a4bf17](https://github.com/antialias/soroban-abacus-flashcards/commit/0a4bf1765cbd86bf6f67fb3b99c577cfe3cce075))
|
||||
* create mode selection landing page for Complement Race ([1ff9695](https://github.com/antialias/soroban-abacus-flashcards/commit/1ff9695f6930f5232b2ad80ddcbd32bbc182d4e7))
|
||||
* create PlayerConfigDialog component for player customization ([4f2a661](https://github.com/antialias/soroban-abacus-flashcards/commit/4f2a661494add3f61b714d0bead07b0e0bc3f92d))
|
||||
* create StandardGameLayout for perfect viewport sizing ([728a920](https://github.com/antialias/soroban-abacus-flashcards/commit/728a92076a6ac9ef71f0c75d2e9503575881130a))
|
||||
* display passengers visually on train and at stations ([1599904](https://github.com/antialias/soroban-abacus-flashcards/commit/159990489fec9162d9ed9ecf77c7592b776bbb23))
|
||||
* dynamic player card rendering on games page ([81d17f2](https://github.com/antialias/soroban-abacus-flashcards/commit/81d17f23976cc340e23c63f8e27f1a15afd1a4d0))
|
||||
* dynamically calculate train cars based on max concurrent passengers ([9ea1553](https://github.com/antialias/soroban-abacus-flashcards/commit/9ea15535d18efc25739342b0945c6d7ec7896c5d))
|
||||
* emit session-state after creating arcade session ([70d6f43](https://github.com/antialias/soroban-abacus-flashcards/commit/70d6f43d6d7ff918ab15edb6e27d4eab8c7a3de6))
|
||||
* enable prominent nav and fix layout on arcade page ([5c8c18c](https://github.com/antialias/soroban-abacus-flashcards/commit/5c8c18cbb89da38ccaab3c2ad7081e1a6d45a73e))
|
||||
* enhance emoji picker with super powered magnifying glass and hide empty categories ([d8b4e42](https://github.com/antialias/soroban-abacus-flashcards/commit/d8b4e425bf019c593abdcb7693a04e4780b18f06))
|
||||
* enhance passenger card UI with boarding status indicators ([4bbdabc](https://github.com/antialias/soroban-abacus-flashcards/commit/4bbdabc3b576ba8cdda5a053878b3f2e9004afca))
|
||||
* extend ground terrain to cover entire track area ([ee48417](https://github.com/antialias/soroban-abacus-flashcards/commit/ee48417abfe9f5a2788d6de8ff522f60c13b6066))
|
||||
* extend player customization to support all 4 players ([72f8dee](https://github.com/antialias/soroban-abacus-flashcards/commit/72f8dee183b17c88d51748b5131b5a51906a24b3))
|
||||
* extend railroad track to viewport edges ([eadd7da](https://github.com/antialias/soroban-abacus-flashcards/commit/eadd7da6dbc4103342dd673f03f97850cdc20f23))
|
||||
* extend track and tunnels to absolute viewport edges ([f7419bc](https://github.com/antialias/soroban-abacus-flashcards/commit/f7419bc6a0c03cbe2dbbc095e47891ee67d10b51))
|
||||
* implement [@nav](https://github.com/nav) parallel routes for game name display in mini navigation ([885fc72](https://github.com/antialias/soroban-abacus-flashcards/commit/885fc725dc0bb41bbb5e500c2c907c6182192854))
|
||||
* implement cozy sound effects for abacus with variable intensity ([c95be1d](https://github.com/antialias/soroban-abacus-flashcards/commit/c95be1df6dbe74aad08b9a1feb1f33688212be0b))
|
||||
* implement cozy sound effects for abacus with variable intensity ([cea5fad](https://github.com/antialias/soroban-abacus-flashcards/commit/cea5fadbe4b4d5ae9e0ee988e9b1c4db09f21ba6))
|
||||
* implement game control callbacks in MemoryPairsGame ([4758ad2](https://github.com/antialias/soroban-abacus-flashcards/commit/4758ad2f266ef3f3f67c22533fbb5f475dd8bd5b))
|
||||
* implement game theming system with context-based navigation chrome ([3fa11c4](https://github.com/antialias/soroban-abacus-flashcards/commit/3fa11c4fbcbeabeb3bdd0db38374fb9a13cbb754))
|
||||
* implement innovative dynamic two-panel layout for on-screen keyboard ([4bb8f6d](https://github.com/antialias/soroban-abacus-flashcards/commit/4bb8f6daf1f3eecb5cbaf31bf4057f43e43aeb07))
|
||||
* implement mobile-first responsive design for speed memory quiz ([13efc4d](https://github.com/antialias/soroban-abacus-flashcards/commit/13efc4d0705bb9e71a2002689a4ebac109caacc2))
|
||||
* implement simple fixed bottom keyboard bar ([9ef72d7](https://github.com/antialias/soroban-abacus-flashcards/commit/9ef72d7e88a6a9b30cfd7a7d3944197cc1e0037a))
|
||||
* implement smooth train exit with fade-out through right tunnel ([0176694](https://github.com/antialias/soroban-abacus-flashcards/commit/01766944f0267b1f2adeb6a30c9f89d48038a7f8))
|
||||
* implement toggleable on-screen keyboard to prevent UI overlap ([701d23c](https://github.com/antialias/soroban-abacus-flashcards/commit/701d23c36992b09c075e1a394f8a72edffb919f9))
|
||||
* improve game availability logic and messaging ([9a3fa93](https://github.com/antialias/soroban-abacus-flashcards/commit/9a3fa93e53d05475844b54052acbc838d7487d23))
|
||||
* increase landmark emoji size for better visibility ([0bcd7a3](https://github.com/antialias/soroban-abacus-flashcards/commit/0bcd7a30d42a0a0d5bdfcf5abd8eb3eb9a8b6a73))
|
||||
* integrate GameControlButtons into navigation ([fbd8cd4](https://github.com/antialias/soroban-abacus-flashcards/commit/fbd8cd4a6bca44bbc0f7c4e8153900558805a84a))
|
||||
* integrate remaining game sound effects ([600bc35](https://github.com/antialias/soroban-abacus-flashcards/commit/600bc35bc3111a455290638e7be31d0032ff656c))
|
||||
* integrate sound effects into game flow (countdown, answers, performance) ([8c3a855](https://github.com/antialias/soroban-abacus-flashcards/commit/8c3a85523930fca7f2dcc53c79454fb9be523d55))
|
||||
* integrate user profiles with PlayerStatusBar and game results ([beff646](https://github.com/antialias/soroban-abacus-flashcards/commit/beff64652c72a5cd0c008891b6dc2f5167e28b62))
|
||||
* make Steam Sprint infinite mode ([32c3a35](https://github.com/antialias/soroban-abacus-flashcards/commit/32c3a35eabd10f8c9b50a55cfb525a76ea050914))
|
||||
* make SVG span full viewport width for sprint mode ([7488bb3](https://github.com/antialias/soroban-abacus-flashcards/commit/7488bb38033b2d3d3fc18b9f09373506d69e25a5))
|
||||
* migrate abacus display settings to database ([92ef136](https://github.com/antialias/soroban-abacus-flashcards/commit/92ef1360a4792d0b36f3a35e100bd9f3c7451656))
|
||||
* migrate contexts to React Query (remove localStorage) ([fe01a1f](https://github.com/antialias/soroban-abacus-flashcards/commit/fe01a1fe182293aeadd5cbfd73f0a54a858ae38e))
|
||||
* migrate contexts to UUID-based player system ([2b94cad](https://github.com/antialias/soroban-abacus-flashcards/commit/2b94cad11bd05b1a324e360c56be686c3c6a4b64))
|
||||
* preserve track and passengers during route transitions ([f2e7165](https://github.com/antialias/soroban-abacus-flashcards/commit/f2e71657dc1587c2b6df1f4227160b8a261c6084))
|
||||
* redesign passenger cards with vintage train station aesthetic ([651bc21](https://github.com/antialias/soroban-abacus-flashcards/commit/651bc2158361fbaafb0b011ab90006b21d3a7c85))
|
||||
* set up automated npm publishing for @soroban/abacus-react package ([dd80d29](https://github.com/antialias/soroban-abacus-flashcards/commit/dd80d29c979e20b4d3624cf66be79ec51d5f53a9))
|
||||
* set up Drizzle ORM with SQLite database ([5d5afd4](https://github.com/antialias/soroban-abacus-flashcards/commit/5d5afd4e6860241ff45c7173d4aad2b7156a41b1))
|
||||
* skip countdown for train mode (sprint) ([65dafc9](https://github.com/antialias/soroban-abacus-flashcards/commit/65dafc92153399336f200a566bc91f869fdfcbb1))
|
||||
* skip intro screen and start directly at game setup ([4b6888a](https://github.com/antialias/soroban-abacus-flashcards/commit/4b6888af05c6be9616cf20b9d2b8b66ac13cf253))
|
||||
* sync URL with selected game mode ([3920bba](https://github.com/antialias/soroban-abacus-flashcards/commit/3920bbad33ef5dd6323d2baea498943f5115dbec))
|
||||
* UI polish for Sprint mode (viewport, backgrounds, data attributes) ([90ad789](https://github.com/antialias/soroban-abacus-flashcards/commit/90ad789ff1f94f52b98de9fd934a623eab452387))
|
||||
* update nav components for UUID players ([e85d041](https://github.com/antialias/soroban-abacus-flashcards/commit/e85d0415f23049da861533bbec2a65e1d84adfe1))
|
||||
* use CSS transitions for smooth fullscreen player selection collapse ([3189832](https://github.com/antialias/soroban-abacus-flashcards/commit/31898328a391614a0fe8d24ec9d2881bfb6e6984))
|
||||
* wire player configuration through nav component hierarchy ([edfdd81](https://github.com/antialias/soroban-abacus-flashcards/commit/edfdd8122774e36dbda9acea741a5e248be95676))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **abacus-react:** add debugging and explicit authentication for npm publish ([b82e9bb](https://github.com/antialias/soroban-abacus-flashcards/commit/b82e9bb9d6adf3793065067f96c6fbbfd1a78bca))
|
||||
* **abacus-react:** add packages: write permission for GitHub Packages publishing ([8e16487](https://github.com/antialias/soroban-abacus-flashcards/commit/8e1648737de9305f82872cb9b86b98b5045f77a7))
|
||||
* **abacus-react:** apply global columnPosts styling and fix reckoning bar width ([bb9959f](https://github.com/antialias/soroban-abacus-flashcards/commit/bb9959f7fb8985e0c6496247306838d97e7121f7))
|
||||
* **abacus-react:** force npm to use GitHub Packages registry ([5e6c901](https://github.com/antialias/soroban-abacus-flashcards/commit/5e6c901f73a68b60ec05f19c4a991ca8affc1589))
|
||||
* **abacus-react:** improve publishing workflow with better version sync ([7a4ecd2](https://github.com/antialias/soroban-abacus-flashcards/commit/7a4ecd2b5970ed8b6bfde8938b36917f8e7a7176))
|
||||
* **abacus-react:** improve workspace dependency cleanup and add validation ([11fd6f9](https://github.com/antialias/soroban-abacus-flashcards/commit/11fd6f9b3deb1122d3788a7e0698de891eeb0f3a))
|
||||
* **abacus-react:** resolve workspace dependencies before npm publish ([834b062](https://github.com/antialias/soroban-abacus-flashcards/commit/834b062b2d22356b9d96bb9c3c444eccaa51d793))
|
||||
* **abacus-react:** simplify semantic-release config to resolve dependency issues ([88cab38](https://github.com/antialias/soroban-abacus-flashcards/commit/88cab380ef383c941b41671d58d3e35fcaefb2d3))
|
||||
* **abacus-react:** temporarily allow test failures during setup phase ([e3db7f4](https://github.com/antialias/soroban-abacus-flashcards/commit/e3db7f4daf16fce82bccfe47dcaa90d7f4896a79))
|
||||
* add CLEAR_MISMATCH move to allow mismatch feedback to auto-dismiss ([158f527](https://github.com/antialias/soroban-abacus-flashcards/commit/158f52773d20dfab7dc55575d9999f32b4c589a2))
|
||||
* add missing GameThemeContext file for themed navigation ([d4fbdd1](https://github.com/antialias/soroban-abacus-flashcards/commit/d4fbdd14630e2f2fcdbc0de23ccc4ccd9eb74b48))
|
||||
* add npmrc for hoisting and fix template paths ([5c65ac5](https://github.com/antialias/soroban-abacus-flashcards/commit/5c65ac5caabb7197f069344d0ed29d02c3de2b9a))
|
||||
* add Python setuptools and build tools for better-sqlite3 compilation ([a216a3d](https://github.com/antialias/soroban-abacus-flashcards/commit/a216a3d3435a132c8add0a7c711b021bf4b1555f))
|
||||
* add testing mode for on-screen keyboard and fix toggle functionality ([904074c](https://github.com/antialias/soroban-abacus-flashcards/commit/904074ca821b62cd6b1e129354eb36c5dd4b5e7f))
|
||||
* align all bottom UI elements to same 20px baseline ([076c97a](https://github.com/antialias/soroban-abacus-flashcards/commit/076c97abac7a33f600b80083d8990a8c4a51be99))
|
||||
* align bottom-positioned UI elements ([227cfab](https://github.com/antialias/soroban-abacus-flashcards/commit/227cfabf113bc875ea3a61f0de41a9093ad1dd30))
|
||||
* allow navigation to game setup pages without active session ([c7ad3c0](https://github.com/antialias/soroban-abacus-flashcards/commit/c7ad3c069502580d1e72e7cc01e7b1f793ba9357))
|
||||
* change pressure gauge to fixed positioning to stay above terrain ([1b11031](https://github.com/antialias/soroban-abacus-flashcards/commit/1b110315982f631dadca96a37fc88db98a7f9cca))
|
||||
* change question display to fixed positioning with higher z-index ([4ac8758](https://github.com/antialias/soroban-abacus-flashcards/commit/4ac875895781dba5e115eeb3336ef76744b782bb))
|
||||
* **complement-race:** improve abacus display in equations ([491b299](https://github.com/antialias/soroban-abacus-flashcards/commit/491b299e28ee82c49069cf892609b1b2b3c0aee3))
|
||||
* **complement-race:** prevent passengers being left behind at delivery stations ([e6ebecb](https://github.com/antialias/soroban-abacus-flashcards/commit/e6ebecb09b1e5dd78c2dc11e125399082fb420ab))
|
||||
* correct emoji category group IDs to match Unicode CLDR ([b2a21b7](https://github.com/antialias/soroban-abacus-flashcards/commit/b2a21b79ad705a5b52767317af00e4d666d33907))
|
||||
* defer URL update until game starts ([12c54b2](https://github.com/antialias/soroban-abacus-flashcards/commit/12c54b27b717e852b96585eacdf6e9d964e32c50))
|
||||
* delay passenger display update until train resets ([e06a750](https://github.com/antialias/soroban-abacus-flashcards/commit/e06a7504549bb4e0fcc38bd03249b9c0386c3079))
|
||||
* disable turn validation in arcade mode matching game ([7c0e6b1](https://github.com/antialias/soroban-abacus-flashcards/commit/7c0e6b142b90f0fc3d444b3dcc1fff1512a0a3b2))
|
||||
* eliminate rail jaggies on sharp curves by increasing sampling density ([46d4af2](https://github.com/antialias/soroban-abacus-flashcards/commit/46d4af2bdad761366890171cc666ba24d5309257))
|
||||
* enable shamefully-hoist for semantic-release dependencies ([6168c29](https://github.com/antialias/soroban-abacus-flashcards/commit/6168c292d5f15748e80610103a6a787c0cf29d0f))
|
||||
* enforce playerId must be explicitly provided in arcade moves ([d5a8a2a](https://github.com/antialias/soroban-abacus-flashcards/commit/d5a8a2a14cb14ecd00827ddc96873f3db79573fd))
|
||||
* ensure consistent r×c grid layout for memory matching game ([f1a0633](https://github.com/antialias/soroban-abacus-flashcards/commit/f1a0633596fd1bb53418e56e28f3f27d3fce8b54))
|
||||
* ensure game names persist in navigation on page reload ([9191b12](https://github.com/antialias/soroban-abacus-flashcards/commit/9191b124934b9a5577a91f67e8fb6f83b173cc4f))
|
||||
* ensure passengers only travel forward on train route ([8ad3144](https://github.com/antialias/soroban-abacus-flashcards/commit/8ad3144d2da9b4ceedd62a9f379f664fa9381afe))
|
||||
* export missing hooks and types from @soroban/abacus-react package ([423ba55](https://github.com/antialias/soroban-abacus-flashcards/commit/423ba5535023928f1e0198b2bd01c3c6cf7ee848))
|
||||
* implement route-based theme detection for page reload persistence ([3dcff2f](https://github.com/antialias/soroban-abacus-flashcards/commit/3dcff2ff888558d7b746a732cfd53a1897c2b1df))
|
||||
* improve navigation chrome background color extraction from gradients ([00bfcbc](https://github.com/antialias/soroban-abacus-flashcards/commit/00bfcbcdee28d63094c09a4ae0359789ebcf4a22))
|
||||
* increase question display zIndex to stay above terrain ([8c8b8e0](https://github.com/antialias/soroban-abacus-flashcards/commit/8c8b8e08b4d51e6462d4b7dd258a25e64bf16dba))
|
||||
* lazy-load database connection to prevent build-time access ([af8d993](https://github.com/antialias/soroban-abacus-flashcards/commit/af8d9936285c697ff45700115eba83b5debdf9ad))
|
||||
* make results screen compact to fit viewport without scrolling ([9d4cba0](https://github.com/antialias/soroban-abacus-flashcards/commit/9d4cba05be84e7c162d706c8697eede23314b1a4))
|
||||
* migrate viewport from metadata to separate viewport export ([1fe12c4](https://github.com/antialias/soroban-abacus-flashcards/commit/1fe12c4837b1229d0f0ab93c55d0ffb504eb8721))
|
||||
* move auth.ts to src/ to match @/ path alias ([7829d8a](https://github.com/antialias/soroban-abacus-flashcards/commit/7829d8a0fb86dac07aa1b2fb0b68908e7e8381b8))
|
||||
* move fontWeight to style object for station names ([05a3ddb](https://github.com/antialias/soroban-abacus-flashcards/commit/05a3ddb086e28529efb321943f4e423dbe5ed6a6))
|
||||
* only show configuration gear icon for players 1 and 2 ([d0a3bc7](https://github.com/antialias/soroban-abacus-flashcards/commit/d0a3bc7dc1efc00c1db63fb50bc2ab3c6aabdd59))
|
||||
* pass player IDs (not user IDs) in all arcade game moves ([d00abd2](https://github.com/antialias/soroban-abacus-flashcards/commit/d00abd25e755c0304517a7953cb78022a073b7c3))
|
||||
* passengers now board/disembark based on their car position, not locomotive ([96782b0](https://github.com/antialias/soroban-abacus-flashcards/commit/96782b0e7a6d0db5a4435ca303b1d819947ce460))
|
||||
* position tunnels at absolute viewBox edges ([1a5fa28](https://github.com/antialias/soroban-abacus-flashcards/commit/1a5fa2873bcda9a24cc578a7ecea43632077a0a1))
|
||||
* prevent layout shift when selecting Steam Sprint mode ([73a5974](https://github.com/antialias/soroban-abacus-flashcards/commit/73a59745a5145b734b0893a489c5d018e3c9475c))
|
||||
* prevent multiple passengers from boarding same car in single frame ([63b0b55](https://github.com/antialias/soroban-abacus-flashcards/commit/63b0b552a89a1165137de125bf57246a7cf6ac73))
|
||||
* prevent premature passenger display during route transitions ([fe9ea67](https://github.com/antialias/soroban-abacus-flashcards/commit/fe9ea67f56847859b4fb4fa4f747022f0a2e5a70))
|
||||
* prevent random passenger repopulation during route transitions ([db56ce8](https://github.com/antialias/soroban-abacus-flashcards/commit/db56ce89ee1d4e7583b60cde4e1d2610ee31123a))
|
||||
* prevent route celebration from immediately reappearing ([1a80934](https://github.com/antialias/soroban-abacus-flashcards/commit/1a8093416e0feff774b6cdc6dfafdbafbb8baf7f))
|
||||
* redesign matching game setup page for StandardGameLayout ([cc1f27f](https://github.com/antialias/soroban-abacus-flashcards/commit/cc1f27f0f82256f9344531814e8b965fa547d555))
|
||||
* reduce landmark size from 4.0x to 2.0x multiplier ([c928e90](https://github.com/antialias/soroban-abacus-flashcards/commit/c928e907854e18264266958d813d4e1d4c03e760))
|
||||
* regenerate lockfile with correct dependency order ([51bf448](https://github.com/antialias/soroban-abacus-flashcards/commit/51bf448c9f159152e89296d9014dde688fcf3a97))
|
||||
* regenerate lockfile with node-linker=hoisted from scratch ([480960c](https://github.com/antialias/soroban-abacus-flashcards/commit/480960c2c8e0c50fe2b6ec69a34b772751a8bf41))
|
||||
* regenerate pnpm lockfile for pnpm 9 compatibility ([4ab1aef](https://github.com/antialias/soroban-abacus-flashcards/commit/4ab1aef9b8fcf15cc03e86c829ca9885e7201b77))
|
||||
* remove double PageWithNav wrapper on matching page ([b58bcd9](https://github.com/antialias/soroban-abacus-flashcards/commit/b58bcd92ee0521a6413f4d6e9656c9ccb1c72851))
|
||||
* remove duplicate CAR_SPACING and MAX_CARS declarations ([e704a28](https://github.com/antialias/soroban-abacus-flashcards/commit/e704a28524e1217b6f56ca5a51784db73f5eadce))
|
||||
* remove duplicate previousPassengersRef declaration ([fad8636](https://github.com/antialias/soroban-abacus-flashcards/commit/fad86367638b1a48b7e1544976148f43b061c832))
|
||||
* remove frozen lockfile flag from publishing workflow to resolve dependency installation issues ([18af973](https://github.com/antialias/soroban-abacus-flashcards/commit/18af9730ffbcd822da292161815ffd09ad97f66c))
|
||||
* remove hard-coded car count from game loop ([6c90a68](https://github.com/antialias/soroban-abacus-flashcards/commit/6c90a68c49b5f7fbc262c38f9a8828f5c725cb6a))
|
||||
* remove unnecessary zIndex from question display ([db52e14](https://github.com/antialias/soroban-abacus-flashcards/commit/db52e14dfe9aceaaa1f98fc79100c04adc84611a))
|
||||
* reposition on-screen keyboard to avoid covering abacus tiles ([6e5b4ec](https://github.com/antialias/soroban-abacus-flashcards/commit/6e5b4ec7bf7e2af5f724628693d3c4ee8c5b3968))
|
||||
* require activePlayers in START_GAME, never fallback to userId ([ea1b1a2](https://github.com/antialias/soroban-abacus-flashcards/commit/ea1b1a2f69a35a6a27f7f952971509b2bb2e6f8d))
|
||||
* reset momentum and pressure when starting new route ([3ea88d7](https://github.com/antialias/soroban-abacus-flashcards/commit/3ea88d7a5a6ed427b17cf408d993918870b75f7f))
|
||||
* resolve circular dependency errors in memory quiz on-screen keyboard ([d25e2c4](https://github.com/antialias/soroban-abacus-flashcards/commit/d25e2c4c006b54a51eaa3a93fa8462e3a06221b7))
|
||||
* resolve JSX parsing error with emoji in guide page ([bf046c9](https://github.com/antialias/soroban-abacus-flashcards/commit/bf046c999b51ba422284a139ebadde2c35187ac7))
|
||||
* resolve mini navigation game name persistence across all routes ([3fa314a](https://github.com/antialias/soroban-abacus-flashcards/commit/3fa314aaa5de7b9c26a5390a52996c7d5ef9ea51))
|
||||
* resolve runtime error - calculateOptimalGrid not defined ([fbc84fe](https://github.com/antialias/soroban-abacus-flashcards/commit/fbc84febda5507d434cf60aa0fce32350e01ec96))
|
||||
* resolve SSR/client hydration mismatch for themed navigation ([301e65d](https://github.com/antialias/soroban-abacus-flashcards/commit/301e65dfa66d0de6b6efbbfbd09b717308ab57f1))
|
||||
* resolve TypeScript errors in PlayerStatusBar component ([a935e5a](https://github.com/antialias/soroban-abacus-flashcards/commit/a935e5aed8c4584d21c8fc4359453b7dec494464))
|
||||
* restore navigation to all pages using PageWithNav ([183706d](https://github.com/antialias/soroban-abacus-flashcards/commit/183706dade12080a748b0c074d0bd71fb0471d7e))
|
||||
* show "Return to Arcade" button only during active game ([4153929](https://github.com/antialias/soroban-abacus-flashcards/commit/4153929a2ab199836249d53d92c1be4979782b73))
|
||||
* smooth rail curves and deterministic track generation ([4f79c08](https://github.com/antialias/soroban-abacus-flashcards/commit/4f79c08d73c090165a1c419e7aa8ef543bc23e7e))
|
||||
* stabilize route completion threshold to prevent stuck trains ([b7233f9](https://github.com/antialias/soroban-abacus-flashcards/commit/b7233f9e4afe1dadfe29ecc4430c74074a2674fc))
|
||||
* update lockfile and fix Makefile paths ([7ba746b](https://github.com/antialias/soroban-abacus-flashcards/commit/7ba746b6bdcc4c06172e1dbacd856da9416e010a))
|
||||
* update matching game for UUID player system ([2e041dd](https://github.com/antialias/soroban-abacus-flashcards/commit/2e041ddc4443be1d032139ad9850bbc28db5c171))
|
||||
* update memory pairs game to use StandardGameLayout ([8df76c0](https://github.com/antialias/soroban-abacus-flashcards/commit/8df76c08fdf4108b88ce95de252cb8bd559fc5e4))
|
||||
* update memory quiz to use StandardGameLayout ([3f86163](https://github.com/antialias/soroban-abacus-flashcards/commit/3f86163c142e577a64adfb3bf262656d2e100ced))
|
||||
* update pnpm version to 8.15.6 to resolve ERR_INVALID_THIS error in workflow ([0b9bfed](https://github.com/antialias/soroban-abacus-flashcards/commit/0b9bfed12dfd48d9eacae69b378e28e188d3f2b1))
|
||||
* update race track components for new player system ([ae4e8fc](https://github.com/antialias/soroban-abacus-flashcards/commit/ae4e8fcb5a62c07fb9ffa9a70c07e45ca8be88c8))
|
||||
* update tutorial tests to use consolidated AbacusDisplayProvider ([899fc69](https://github.com/antialias/soroban-abacus-flashcards/commit/899fc6975f1fa14ddb42b2ead03524c9389e7c38))
|
||||
* update workflow to support Personal Access Token for GitHub Packages publishing auth ([ae4b71b](https://github.com/antialias/soroban-abacus-flashcards/commit/ae4b71b98655364887a729ef9d2b67b6a753d6e9))
|
||||
* upgrade CI dependencies and fix deprecated actions ([6a51c1e](https://github.com/antialias/soroban-abacus-flashcards/commit/6a51c1e9bdc299d86b8001eba35f930fe16cd60c))
|
||||
* use displayPassengers for station rendering in RailroadTrackPath ([a9e0d19](https://github.com/antialias/soroban-abacus-flashcards/commit/a9e0d197348377cb66581150df47d2d1127ad09a))
|
||||
* use node-linker=hoisted for full dependency hoisting ([d3b2e0b](https://github.com/antialias/soroban-abacus-flashcards/commit/d3b2e0b2e152150886110edd80dfe43f70df63d9))
|
||||
* use player IDs instead of array indices in matching game ([ccd0d6d](https://github.com/antialias/soroban-abacus-flashcards/commit/ccd0d6d94ccd1cc25eed602d32a9cf884bda2ee6))
|
||||
* use style fontSize instead of attribute for landmarks ([ebc6894](https://github.com/antialias/soroban-abacus-flashcards/commit/ebc6894746d1d490c3a5cae19c38bb86fe8fdc65))
|
||||
* use UUID player IDs in session creation fallback ([22541df](https://github.com/antialias/soroban-abacus-flashcards/commit/22541df99f049ce99e020e12c2d28b33434de51d))
|
||||
* wrap animated pressure value in animated.span to prevent React error ([5c5954b](https://github.com/antialias/soroban-abacus-flashcards/commit/5c5954be74708fb7019802a6dd80b10e9b2c1d6a))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* optimize React rendering with memoization and consolidated effects ([93cb070](https://github.com/antialias/soroban-abacus-flashcards/commit/93cb070ca503effa05541f7ed217bb4260359581))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* completely remove [@nav](https://github.com/nav) parallel routes and simplify navigation ([54ff20c](https://github.com/antialias/soroban-abacus-flashcards/commit/54ff20c7555e028b50471ac83a7030921c76f43b))
|
||||
* consolidate abacus display context management ([a387b03](https://github.com/antialias/soroban-abacus-flashcards/commit/a387b030fa9fe36f2c8c0e65e34ec9ee872a7afa))
|
||||
* extract ActivePlayersList component from PageWithNav ([2849576](https://github.com/antialias/soroban-abacus-flashcards/commit/28495767a9b792e6f8b9cc9f4df85e1b27a15c35))
|
||||
* extract AddPlayerButton component from PageWithNav ([57a72e3](https://github.com/antialias/soroban-abacus-flashcards/commit/57a72e34a5df198ac795ff83cb6bfc6fb8a2f27e))
|
||||
* extract FullscreenPlayerSelection component from PageWithNav ([66f5223](https://github.com/antialias/soroban-abacus-flashcards/commit/66f52234e12594947069c4ffed3648ce034c3e79))
|
||||
* extract GameContextNav orchestration component ([e3f552d](https://github.com/antialias/soroban-abacus-flashcards/commit/e3f552d8f588b25d13eca20fbcd4f43b30de917f))
|
||||
* extract GameHUD component from SteamTrainJourney ([78d5234](https://github.com/antialias/soroban-abacus-flashcards/commit/78d5234a79eb5293ae8eb279fb46b5aa2bbb6ae7))
|
||||
* extract GameModeIndicator component from PageWithNav ([d67315f](https://github.com/antialias/soroban-abacus-flashcards/commit/d67315f771a3f660be58495c1236f74b49001d23))
|
||||
* extract guide components to fix syntax error in large file ([c77e880](https://github.com/antialias/soroban-abacus-flashcards/commit/c77e880be32456c1e91d37358d85c445b2f707df))
|
||||
* extract RailroadTrackPath component from SteamTrainJourney ([d9acc0e](https://github.com/antialias/soroban-abacus-flashcards/commit/d9acc0efea397ce58013160efbb2ed4fbee244b2))
|
||||
* extract TrainAndCars component from SteamTrainJourney ([5ae22e4](https://github.com/antialias/soroban-abacus-flashcards/commit/5ae22e4645614bc67e7dece509d92bdd85250e28))
|
||||
* extract TrainTerrainBackground component from SteamTrainJourney ([05bb035](https://github.com/antialias/soroban-abacus-flashcards/commit/05bb035db5483892f101cef0971bf08575cb041b))
|
||||
* extract usePassengerAnimations hook from SteamTrainJourney ([32abde1](https://github.com/antialias/soroban-abacus-flashcards/commit/32abde107ca050bfc191a325d0bf074d53df33fc))
|
||||
* extract useTrackManagement hook from SteamTrainJourney ([a1f2b97](https://github.com/antialias/soroban-abacus-flashcards/commit/a1f2b9736a0ff087dae44110708254e6da966b79))
|
||||
* extract useTrainTransforms hook from SteamTrainJourney ([a2512d5](https://github.com/antialias/soroban-abacus-flashcards/commit/a2512d573823e187aad08f3fed365c4211023bb3))
|
||||
* make game mode a computed property from active player count ([386c88a](https://github.com/antialias/soroban-abacus-flashcards/commit/386c88a3c03eb6de7814f65a6556a2d0ab50386b))
|
||||
* remove drag-and-drop UI from EnhancedChampionArena ([982fa45](https://github.com/antialias/soroban-abacus-flashcards/commit/982fa45c08b34fdb64ec0835bc591b850e1c1373))
|
||||
* remove duplicate game control buttons from game phases ([9165014](https://github.com/antialias/soroban-abacus-flashcards/commit/9165014997ccf6f4859646709d7e800933b4868e))
|
||||
* remove redundant game titles from game screens ([402724c](https://github.com/antialias/soroban-abacus-flashcards/commit/402724c80e1776b90be1fc02186813389560f380))
|
||||
* replace bulky MemoryGrid stats with compact progress display ([c4d6691](https://github.com/antialias/soroban-abacus-flashcards/commit/c4d6691715d09066661be4a0af7e917c6217ed8c))
|
||||
* simplify navigation flow and enhance GameControls UI ([920aaa6](https://github.com/antialias/soroban-abacus-flashcards/commit/920aaa639887b42dcb225cb42ce146e67d29a98e))
|
||||
* simplify PageWithNav by extracting nav components ([98cfa56](https://github.com/antialias/soroban-abacus-flashcards/commit/98cfa5645bc21faaeffa676d205bdbdf604eb488))
|
||||
* split deployment info into server/client components ([5e7b273](https://github.com/antialias/soroban-abacus-flashcards/commit/5e7b273b339cd11d7b4b55dd50a1bf6c823b41d5))
|
||||
* streamline GamePhase header and integrate PlayerStatusBar ([dcefa74](https://github.com/antialias/soroban-abacus-flashcards/commit/dcefa74902c65618f79eda32c94a5b8736b15b55))
|
||||
* streamline UI and remove duplicate information displays ([7a3e34b](https://github.com/antialias/soroban-abacus-flashcards/commit/7a3e34b4faab62c069e6698a935ad66ee80037d2))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add comprehensive workflow documentation for automated npm publishing ([f923b53](https://github.com/antialias/soroban-abacus-flashcards/commit/f923b53a44c2875fe152c9bd326d6a427d07a71e))
|
||||
* add server persistence migration plan ([dd0df8c](https://github.com/antialias/soroban-abacus-flashcards/commit/dd0df8c274f513947e43f014b9086f77077f0196))
|
||||
|
||||
|
||||
### Tests
|
||||
|
||||
* add comprehensive unit tests for refactored hooks and components ([5d20839](https://github.com/antialias/soroban-abacus-flashcards/commit/5d2083903e9eb751d810921e06dc51b7137f726f))
|
||||
* add E2E tests for arcade modal session behavior ([619be98](https://github.com/antialias/soroban-abacus-flashcards/commit/619be9859c548b6c3f0af45379a61f690bcb8e13))
|
||||
|
||||
## [1.2.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v1.2.0...v1.2.1) (2025-09-28)
|
||||
|
||||
|
||||
|
||||
39
Dockerfile
39
Dockerfile
@@ -1,8 +1,11 @@
|
||||
# Multi-stage build for Soroban Abacus Flashcards
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# Install Python and build tools for better-sqlite3
|
||||
RUN apk add --no-cache python3 py3-setuptools make g++
|
||||
|
||||
# Install pnpm and turbo
|
||||
RUN npm install -g pnpm@8.0.0 turbo@1.10.0
|
||||
RUN npm install -g pnpm@9.15.4 turbo@1.10.0
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -14,7 +17,7 @@ COPY packages/core/client/typescript/package.json ./packages/core/client/typescr
|
||||
COPY packages/abacus-react/package.json ./packages/abacus-react/
|
||||
COPY packages/templates/package.json ./packages/templates/
|
||||
|
||||
# Install dependencies
|
||||
# Install dependencies (will use .npmrc with hoisted mode)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Builder stage
|
||||
@@ -31,20 +34,44 @@ RUN turbo build --filter=@soroban/web
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python and build tools for better-sqlite3 (needed at runtime)
|
||||
RUN apk add --no-cache python3 py3-setuptools make g++
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
# Copy built Next.js application
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next ./apps/web/.next
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||
|
||||
# Copy server files (compiled from TypeScript)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/server.js ./apps/web/
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/socket-server.js ./apps/web/
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/src ./apps/web/src
|
||||
|
||||
# Copy database migrations
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/drizzle ./apps/web/drizzle
|
||||
|
||||
# Copy node_modules (for dependencies)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/node_modules ./apps/web/node_modules
|
||||
|
||||
# Copy package.json files for module resolution
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/package.json ./apps/web/
|
||||
|
||||
# Set up environment
|
||||
WORKDIR /app/apps/web
|
||||
|
||||
# Create data directory for SQLite database
|
||||
RUN mkdir -p data && chown nextjs:nodejs data
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
ENV NODE_ENV production
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
CMD ["node", "server.js"]
|
||||
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.
|
||||
|
||||
|
||||
492
apps/web/.claude/ARCADE_ARCHITECTURE.md
Normal file
492
apps/web/.claude/ARCADE_ARCHITECTURE.md
Normal file
@@ -0,0 +1,492 @@
|
||||
# Arcade Game Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
The arcade system supports two distinct game modes that must remain completely isolated from each other:
|
||||
|
||||
1. **Local Play** - Games without network synchronization (can be single-player OR local multiplayer)
|
||||
2. **Room-Based Play** - Networked games with real-time synchronization across room members
|
||||
|
||||
## Core Terminology
|
||||
|
||||
Following `docs/terminology-user-player-room.md`:
|
||||
|
||||
- **USER** - Identity (guest or authenticated account), retrieved via `useViewerId()`, one per browser/account
|
||||
- **PLAYER** - Game avatar/profile (e.g., "Alice 👧", "Bob 👦"), stored in `players` table
|
||||
- **PLAYER ROSTER** - All PLAYERS belonging to a USER (can have many)
|
||||
- **ACTIVE PLAYERS** - PLAYERS where `isActive = true` - these are the ones that actually participate in games
|
||||
- **ROOM MEMBER** - A USER's participation in a multiplayer room (tracked in `room_members` table)
|
||||
|
||||
**Important:** A USER can have many PLAYERS in their roster, but only the ACTIVE PLAYERS (where `isActive = true`) participate in games. This enables "hot-potato" style local multiplayer where multiple people share the same device. This is LOCAL play (not networked), even though multiple PLAYERS participate.
|
||||
|
||||
In arcade sessions:
|
||||
- `arcade_sessions.userId` - The USER who owns the session
|
||||
- `arcade_sessions.activePlayers` - Array of PLAYER IDs (only active players with `isActive = true`)
|
||||
- `arcade_sessions.roomId` - If present, the room ID for networked play (references `arcade_rooms.id`)
|
||||
|
||||
## Critical Architectural Requirements
|
||||
|
||||
### 1. Mode Isolation (MUST ENFORCE)
|
||||
|
||||
**Local Play** (`/arcade/[game-name]`)
|
||||
- MUST NOT sync game state across the network
|
||||
- MUST NOT use room data, even if the USER is currently a member of an active room
|
||||
- MUST create isolated, per-USER game sessions
|
||||
- Game state lives only in the current browser tab/session
|
||||
- CAN have multiple ACTIVE PLAYERS from the same USER (local multiplayer / hot-potato)
|
||||
- State is NOT shared across the network, only within the browser session
|
||||
|
||||
**Room-Based Play** (`/arcade/room`)
|
||||
- MUST sync game state across all room members via network
|
||||
- MUST use the USER's current active room
|
||||
- MUST coordinate moves via server WebSocket
|
||||
- Game state is shared across all ACTIVE PLAYERS from all USERS in the room
|
||||
- When a PLAYER makes a move, all room members see it in real-time
|
||||
- CAN ALSO have multiple ACTIVE PLAYERS per USER (networked + local multiplayer combined)
|
||||
|
||||
### 2. Room ID Usage Rules
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG: Always checking for room data
|
||||
const { roomData } = useRoomData()
|
||||
useArcadeSession({ roomId: roomData?.id }) // This causes the bug!
|
||||
|
||||
// ✅ CORRECT: Explicit mode control via separate providers
|
||||
<LocalMemoryPairsProvider> {/* Never passes roomId */}
|
||||
<RoomMemoryPairsProvider> {/* Always passes roomId */}
|
||||
```
|
||||
|
||||
**Key principle:** The presence of a `roomId` parameter in `useArcadeSession` determines synchronization behavior:
|
||||
- `roomId` present → room-wide network sync enabled (room-based play)
|
||||
- `roomId` undefined → local play only (no network sync)
|
||||
|
||||
### 3. Composition Over Flags (PREFERRED APPROACH)
|
||||
|
||||
**✅ Option 1: Separate Providers (CLEAREST - USE THIS)**
|
||||
|
||||
Create two distinct provider components:
|
||||
|
||||
```typescript
|
||||
// context/LocalMemoryPairsProvider.tsx
|
||||
export function LocalMemoryPairsProvider({ children }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { activePlayers } = useGameMode() // Gets active players (isActive = true)
|
||||
// NEVER fetch room data for local play
|
||||
|
||||
const { state, sendMove } = useArcadeSession<MemoryPairsState>({
|
||||
userId: viewerId || '',
|
||||
roomId: undefined, // Explicitly undefined - no network sync
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// ... rest of provider logic
|
||||
// Note: activePlayers contains only PLAYERS with isActive = true
|
||||
}
|
||||
|
||||
// context/RoomMemoryPairsProvider.tsx
|
||||
export function RoomMemoryPairsProvider({ children }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData() // OK to fetch for room-based play
|
||||
const { activePlayers } = useGameMode() // Gets active players (isActive = true)
|
||||
|
||||
const { state, sendMove } = useArcadeSession<MemoryPairsState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // Pass roomId for network sync
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// ... rest of provider logic
|
||||
}
|
||||
```
|
||||
|
||||
Then use them explicitly:
|
||||
```typescript
|
||||
// /arcade/matching/page.tsx (Local Play)
|
||||
export default function MatchingPage() {
|
||||
return (
|
||||
<ArcadeGuardedPage>
|
||||
<LocalMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</LocalMemoryPairsProvider>
|
||||
</ArcadeGuardedPage>
|
||||
)
|
||||
}
|
||||
|
||||
// /arcade/room/page.tsx (Room-Based Play)
|
||||
export default function RoomPage() {
|
||||
// ... room validation logic
|
||||
if (roomData.gameName === 'matching') {
|
||||
return (
|
||||
<RoomMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</RoomMemoryPairsProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits of separate providers:**
|
||||
- Compile-time safety - impossible to mix modes
|
||||
- Clear intent - any developer can see which mode at a glance
|
||||
- No runtime conditionals needed
|
||||
- Easier to test - each provider tests separately
|
||||
|
||||
**❌ Avoid:** Runtime flag checking scattered throughout code
|
||||
```typescript
|
||||
// Anti-pattern: Too many conditionals
|
||||
if (isRoomBased) { ... } else { ... }
|
||||
```
|
||||
|
||||
### 4. How Synchronization Works
|
||||
|
||||
#### Local Play Flow
|
||||
```
|
||||
USER Action → useArcadeSession (roomId: undefined)
|
||||
→ WebSocket emit('join-arcade-session', { userId })
|
||||
→ Server creates isolated session for userId
|
||||
→ Session key = userId
|
||||
→ session.activePlayers = USER's active player IDs (isActive = true)
|
||||
→ State changes only affect this USER's browser tabs
|
||||
|
||||
Note: Multiple ACTIVE PLAYERS from same USER can participate (local multiplayer),
|
||||
but state is NEVER synced across network
|
||||
```
|
||||
|
||||
#### Room-Based Play Flow
|
||||
```
|
||||
USER Action (on behalf of PLAYER)
|
||||
→ useArcadeSession (roomId: 'room_xyz')
|
||||
→ WebSocket emit('join-arcade-session', { userId, roomId })
|
||||
→ Server creates/joins shared session for roomId
|
||||
→ session.activePlayers = ALL active players from ALL room members
|
||||
→ Socket joins TWO rooms: `arcade:${userId}` AND `game:${roomId}`
|
||||
→ PLAYER makes move
|
||||
→ Server validates PLAYER ownership (is this PLAYER owned by this USER?)
|
||||
→ State changes broadcast to:
|
||||
- arcade:${userId} - All tabs of this USER (for optimistic reconciliation)
|
||||
- game:${roomId} - All USERS in the room (for network sync)
|
||||
|
||||
Note: Each USER can still have multiple ACTIVE PLAYERS (local + networked multiplayer)
|
||||
```
|
||||
|
||||
The server-side logic uses `roomId` to determine session scope:
|
||||
- No `roomId`: Session key = `userId` (isolated to USER's browser)
|
||||
- With `roomId`: Session key = `roomId` (shared across all room members)
|
||||
|
||||
See `docs/MULTIPLAYER_SYNC_ARCHITECTURE.md` for detailed socket room mechanics.
|
||||
|
||||
### 5. USER vs PLAYER in Game Logic
|
||||
|
||||
**Important distinction:**
|
||||
- **Session ownership**: Tracked by USER ID (`useViewerId()`)
|
||||
- **Player roster**: All PLAYERS for a USER (can be many)
|
||||
- **Active players**: PLAYERS with `isActive = true` (these join the game)
|
||||
- **Game actions**: Performed by PLAYER ID (from `players` table)
|
||||
- **Move validation**: Server checks that PLAYER ID belongs to the requesting USER
|
||||
- **Local multiplayer**: One USER with multiple ACTIVE PLAYERS (hot-potato style, same device)
|
||||
- **Networked multiplayer**: Multiple USERS, each with their own ACTIVE PLAYERS, in a room
|
||||
|
||||
```typescript
|
||||
// ✅ Correct: USER owns session, ACTIVE PLAYERS participate
|
||||
const { data: viewerId } = useViewerId() // USER ID
|
||||
const { activePlayers } = useGameMode() // ACTIVE PLAYER IDs (isActive = true)
|
||||
|
||||
// activePlayers might be [player_001, player_002]
|
||||
// even though USER has 5 total PLAYERS in their roster
|
||||
|
||||
const { state, sendMove } = useArcadeSession({
|
||||
userId: viewerId, // Session owned by USER
|
||||
roomId: undefined, // Local play (or roomData?.id for room-based)
|
||||
// ...
|
||||
})
|
||||
|
||||
// When PLAYER flips card:
|
||||
sendMove({
|
||||
type: 'FLIP_CARD',
|
||||
playerId: currentPlayerId, // PLAYER ID from activePlayers
|
||||
data: { cardId: '...' }
|
||||
})
|
||||
```
|
||||
|
||||
**Example Scenarios:**
|
||||
|
||||
1. **Single-player local game:**
|
||||
- USER: "guest_abc"
|
||||
- Player roster: ["player_001" (active), "player_002" (inactive), "player_003" (inactive)]
|
||||
- Active PLAYERS in game: ["player_001"]
|
||||
- Mode: Local play (no roomId)
|
||||
|
||||
2. **Local multiplayer (hot-potato):**
|
||||
- USER: "guest_abc"
|
||||
- Player roster: ["player_001" (active), "player_002" (active), "player_003" (active), "player_004" (inactive)]
|
||||
- Active PLAYERS in game: ["player_001", "player_002", "player_003"] (3 kids sharing device)
|
||||
- Mode: Local play (no roomId)
|
||||
- Game rotates turns between the 3 active PLAYERS, but NO network sync
|
||||
|
||||
3. **Room-based networked play:**
|
||||
- USER A: "guest_abc"
|
||||
- Player roster: 5 total PLAYERS
|
||||
- Active PLAYERS: ["player_001", "player_002"]
|
||||
- USER B: "guest_def"
|
||||
- Player roster: 3 total PLAYERS
|
||||
- Active PLAYERS: ["player_003"]
|
||||
- Mode: Room-based play (roomId: "room_xyz")
|
||||
- Total PLAYERS in game: 3 (player_001, player_002, player_003)
|
||||
- All 3 synced across network
|
||||
|
||||
4. **Room-based + local multiplayer combined:**
|
||||
- USER A: "guest_abc" with 3 active PLAYERS (3 kids at Device A)
|
||||
- USER B: "guest_def" with 2 active PLAYERS (2 kids at Device B)
|
||||
- Mode: Room-based play (roomId: "room_xyz")
|
||||
- 5 total active PLAYERS across 2 devices, all synced over network
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
### Mistake 1: Conditional Room Usage
|
||||
```typescript
|
||||
// ❌ BAD: Room sync leaks into local play
|
||||
const { roomData } = useRoomData()
|
||||
useArcadeSession({
|
||||
roomId: roomData?.id // Local play will sync if USER is in a room!
|
||||
})
|
||||
```
|
||||
|
||||
### Mistake 2: Shared Components Without Mode Context
|
||||
```typescript
|
||||
// ❌ BAD: Same provider used for both modes
|
||||
export default function LocalGamePage() {
|
||||
return <GameProvider><Game /></GameProvider> // Which mode?
|
||||
}
|
||||
```
|
||||
|
||||
### Mistake 3: Confusing "multiplayer" with "networked"
|
||||
```typescript
|
||||
// ❌ BAD: Thinking multiple PLAYERS means room-based
|
||||
if (activePlayers.length > 1) {
|
||||
// Must be room-based! WRONG!
|
||||
// Could be local multiplayer (hot-potato style)
|
||||
}
|
||||
|
||||
// ✅ CORRECT: Check for roomId to determine network sync
|
||||
const isNetworked = !!roomId
|
||||
const isLocalMultiplayer = activePlayers.length > 1 && !roomId
|
||||
```
|
||||
|
||||
### Mistake 4: Using all PLAYERS instead of only active ones
|
||||
```typescript
|
||||
// ❌ BAD: Including inactive players
|
||||
const allPlayers = await db.query.players.findMany({
|
||||
where: eq(players.userId, userId)
|
||||
})
|
||||
|
||||
// ✅ CORRECT: Only active players join the game
|
||||
const activePlayers = await db.query.players.findMany({
|
||||
where: and(
|
||||
eq(players.userId, userId),
|
||||
eq(players.isActive, true)
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
### Mistake 5: Mixing USER ID and PLAYER ID
|
||||
```typescript
|
||||
// ❌ BAD: Using USER ID for game actions
|
||||
sendMove({
|
||||
type: 'FLIP_CARD',
|
||||
playerId: viewerId, // WRONG! viewerId is USER ID, not PLAYER ID
|
||||
data: { cardId: '...' }
|
||||
})
|
||||
|
||||
// ✅ CORRECT: Use PLAYER ID from game state
|
||||
sendMove({
|
||||
type: 'FLIP_CARD',
|
||||
playerId: state.currentPlayer, // PLAYER ID from activePlayers
|
||||
data: { cardId: '...' }
|
||||
})
|
||||
```
|
||||
|
||||
### Mistake 6: Server-Side Ambiguity
|
||||
```typescript
|
||||
// ❌ BAD: Server can't distinguish intent
|
||||
socket.on('join-arcade-session', ({ userId, roomId }) => {
|
||||
// If roomId exists, did USER want local or room-based play?
|
||||
// This happens when provider always passes roomData?.id
|
||||
})
|
||||
```
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
Tests MUST verify mode isolation:
|
||||
|
||||
### Local Play Tests
|
||||
```typescript
|
||||
it('should NOT sync state when USER is in a room but playing locally', async () => {
|
||||
// Setup: USER is a member of an active room
|
||||
// Action: USER navigates to /arcade/matching
|
||||
// Assert: Game state is NOT shared with other room members
|
||||
// Assert: Other room members' actions do NOT affect this game
|
||||
})
|
||||
|
||||
it('should create isolated sessions for concurrent local games', () => {
|
||||
// Setup: Two USERS who are members of the same room
|
||||
// Action: Both navigate to /arcade/matching separately
|
||||
// Assert: Each has independent game state
|
||||
// Assert: USER A's moves do NOT appear in USER B's game
|
||||
})
|
||||
|
||||
it('should support local multiplayer without network sync', () => {
|
||||
// Setup: USER with 3 active PLAYERS in roster (hot-potato style)
|
||||
// Action: USER plays at /arcade/matching with the 3 active PLAYERS
|
||||
// Assert: All 3 active PLAYERS participate in the same session
|
||||
// Assert: Inactive PLAYERS do NOT participate
|
||||
// Assert: State is NOT synced across network
|
||||
// Assert: Game rotates turns between active PLAYERS locally
|
||||
})
|
||||
|
||||
it('should only include active players in game', () => {
|
||||
// Setup: USER has 5 PLAYERS in roster, but only 2 are active
|
||||
// Action: USER starts a local game
|
||||
// Assert: Only the 2 active PLAYERS are in activePlayers array
|
||||
// Assert: Inactive PLAYERS are not included
|
||||
})
|
||||
|
||||
it('should sync across USER tabs but not across network', () => {
|
||||
// Setup: USER opens /arcade/matching in 2 browser tabs
|
||||
// Action: PLAYER makes move in Tab 1
|
||||
// Assert: Tab 2 sees the move (multi-tab sync)
|
||||
// Assert: Other USERS do NOT see the move (no network sync)
|
||||
})
|
||||
```
|
||||
|
||||
### Room-Based Play Tests
|
||||
```typescript
|
||||
it('should sync state across all room members', async () => {
|
||||
// Setup: Two USERS are members of the same room
|
||||
// Action: USER A's PLAYER flips card at /arcade/room
|
||||
// Assert: USER B sees the card flip in real-time
|
||||
})
|
||||
|
||||
it('should sync across multiple active PLAYERS from multiple USERS', () => {
|
||||
// Setup: USER A has 2 active PLAYERS, USER B has 1 active PLAYER in same room
|
||||
// Action: USER A's PLAYER 1 makes move
|
||||
// Assert: All 3 PLAYERS see the move (networked)
|
||||
})
|
||||
|
||||
it('should only include active players in room games', () => {
|
||||
// Setup: USER A (5 PLAYERS, 2 active), USER B (3 PLAYERS, 1 active) join room
|
||||
// Action: Game starts
|
||||
// Assert: session.activePlayers = [userA_player1, userA_player2, userB_player1]
|
||||
// Assert: Inactive PLAYERS are NOT included
|
||||
})
|
||||
|
||||
it('should handle combined local + networked multiplayer', () => {
|
||||
// Setup: USER A (3 active PLAYERS), USER B (2 active PLAYERS) in same room
|
||||
// Action: Any PLAYER makes a move
|
||||
// Assert: All 5 active PLAYERS see the move across both devices
|
||||
})
|
||||
|
||||
it('should fail gracefully when no room exists', () => {
|
||||
// Setup: USER is not a member of any room
|
||||
// Action: Navigate to /arcade/room
|
||||
// Assert: Shows "No active room" message
|
||||
// Assert: Does not create a session
|
||||
})
|
||||
|
||||
it('should validate PLAYER ownership', async () => {
|
||||
// Setup: USER A in room with active PLAYER 'alice'
|
||||
// Action: USER A attempts move for PLAYER 'bob' (owned by USER B)
|
||||
// Assert: Server rejects the move
|
||||
// Assert: Error indicates unauthorized PLAYER
|
||||
})
|
||||
```
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
When adding a new game or modifying existing ones:
|
||||
|
||||
- [ ] Create separate `LocalGameProvider` and `RoomGameProvider` components
|
||||
- [ ] Local provider never calls `useRoomData()`
|
||||
- [ ] Local provider passes `roomId: undefined` to `useArcadeSession`
|
||||
- [ ] Room provider calls `useRoomData()` and passes `roomId: roomData?.id`
|
||||
- [ ] Both providers use `useGameMode()` to get active players
|
||||
- [ ] Local play page uses `LocalGameProvider`
|
||||
- [ ] `/arcade/room` page uses `RoomGameProvider`
|
||||
- [ ] Game components correctly use PLAYER IDs (not USER IDs) for moves
|
||||
- [ ] Game supports multiple active PLAYERS from same USER (local multiplayer)
|
||||
- [ ] Inactive PLAYERS are never included in game sessions
|
||||
- [ ] Tests verify mode isolation (local doesn't network sync, room-based does)
|
||||
- [ ] Tests verify PLAYER ownership validation
|
||||
- [ ] Tests verify only active PLAYERS participate
|
||||
- [ ] Tests verify local multiplayer works (multiple active PLAYERS, one USER)
|
||||
- [ ] Documentation updated if behavior changes
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
src/app/arcade/
|
||||
├── [game-name]/ # Local play games
|
||||
│ ├── page.tsx # Uses LocalGameProvider
|
||||
│ └── context/
|
||||
│ ├── LocalGameProvider.tsx # roomId: undefined
|
||||
│ └── RoomGameProvider.tsx # roomId: roomData?.id
|
||||
├── room/ # Room-based play
|
||||
│ └── page.tsx # Uses RoomGameProvider
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Architecture Decision Records
|
||||
|
||||
### Why separate providers instead of auto-detect from route?
|
||||
|
||||
While we could detect mode based on the route (`/arcade/room` vs `/arcade/matching`), separate providers are clearer and prevent accidental misuse. Future developers can immediately see the intent, and the type system can enforce correctness.
|
||||
|
||||
### Why being in a room doesn't mean all games sync?
|
||||
|
||||
A USER being a room member does NOT mean all their games should network sync. They should be able to play local games while remaining in a room for future room-based sessions. Mode is determined by the page they're on, not their room membership status.
|
||||
|
||||
### Why not use a single shared provider with mode props?
|
||||
|
||||
We tried that. It led to the current bug where local play accidentally synced with rooms. Separate providers make the distinction compile-time safe rather than runtime conditional, and eliminate the possibility of accidentally passing `roomId` when we shouldn't.
|
||||
|
||||
### Why do we track sessions by USER but moves by PLAYER?
|
||||
|
||||
- **Sessions** are per-USER because each USER can have their own game session
|
||||
- **Moves** are per-PLAYER because PLAYERS are the game avatars that score points
|
||||
- **Only active PLAYERS** (isActive = true) participate in games
|
||||
- This allows:
|
||||
- One USER with multiple active PLAYERS (local multiplayer / hot-potato)
|
||||
- Multiple USERS in one room (networked play)
|
||||
- Combined: Multiple USERS each with multiple active PLAYERS (local + networked)
|
||||
- Proper ownership validation (server checks USER owns PLAYER)
|
||||
- PLAYERS can be toggled active/inactive without deleting them
|
||||
|
||||
### Why use "local" vs "room-based" instead of "solo" vs "multiplayer"?
|
||||
|
||||
- **"Solo"** is misleading - a USER can have multiple active PLAYERS in local play (hot-potato style)
|
||||
- **"Multiplayer"** is ambiguous - it could mean local multiplayer OR networked multiplayer
|
||||
- **"Local play"** clearly means: no network sync (but can have multiple active PLAYERS)
|
||||
- **"Room-based play"** clearly means: network sync across room members
|
||||
|
||||
## Related Files
|
||||
|
||||
- `src/hooks/useArcadeSession.ts` - Session management with optional roomId
|
||||
- `src/hooks/useArcadeSocket.ts` - WebSocket connection with sync logic (socket rooms: `arcade:${userId}` and `game:${roomId}`)
|
||||
- `src/hooks/useRoomData.ts` - Fetches USER's current room membership
|
||||
- `src/hooks/useViewerId.ts` - Retrieves current USER ID
|
||||
- `src/contexts/GameModeContext.tsx` - Provides active PLAYER information
|
||||
- `src/app/arcade/matching/context/ArcadeMemoryPairsContext.tsx` - Game context (needs refactoring to separate providers)
|
||||
- `src/app/arcade/matching/page.tsx` - Local play entry point
|
||||
- `src/app/arcade/room/page.tsx` - Room-based play entry point
|
||||
- `docs/terminology-user-player-room.md` - Terminology guide (USER/PLAYER/MEMBER)
|
||||
- `docs/MULTIPLAYER_SYNC_ARCHITECTURE.md` - Technical details of room-based sync
|
||||
|
||||
## Version History
|
||||
|
||||
- **2025-10-09**: Initial documentation
|
||||
- Issue identified: Local play was syncing with rooms over network
|
||||
- Root cause: Same provider always fetched `roomData` and passed `roomId` to `useArcadeSession`
|
||||
- Solution: Separate providers for local vs room-based play
|
||||
- Terminology clarification: "local" vs "room-based" (not "solo" vs "multiplayer")
|
||||
- Active players: Only PLAYERS with `isActive = true` participate in games
|
||||
416
apps/web/.claude/ARCADE_SETUP_PATTERN.md
Normal file
416
apps/web/.claude/ARCADE_SETUP_PATTERN.md
Normal file
@@ -0,0 +1,416 @@
|
||||
# Arcade Setup Pattern
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the **standard synchronized setup pattern** for arcade games. Following this pattern ensures that:
|
||||
|
||||
1. ✅ **Setup is synchronized** - All room members see the same setup screen and config changes in real-time
|
||||
2. ✅ **No local state hacks** - Configuration lives entirely in session state, no React state merging
|
||||
3. ✅ **Optimistic updates** - Config changes feel instant with client-side prediction
|
||||
4. ✅ **Consistent pattern** - All games follow the same architecture
|
||||
|
||||
**Reference Implementation**: `src/app/arcade/matching/*` (Matching game)
|
||||
|
||||
---
|
||||
|
||||
## Core Concept
|
||||
|
||||
Setup configuration is **game state**, not UI state. Configuration changes are **moves** that are validated, synchronized, and can be made by any room member.
|
||||
|
||||
### Key Principles
|
||||
|
||||
1. **Game state includes configuration** during ALL phases (setup, playing, results)
|
||||
2. **No local React state** for configuration - use session state directly
|
||||
3. **Standard move types** that all games should implement
|
||||
4. **Setup phase is collaborative** - any room member can configure the game
|
||||
|
||||
---
|
||||
|
||||
## Required Move Types
|
||||
|
||||
Every arcade game must support these standard moves:
|
||||
|
||||
### 1. `GO_TO_SETUP`
|
||||
|
||||
Transitions game to setup phase, allowing reconfiguration.
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'GO_TO_SETUP',
|
||||
playerId: string,
|
||||
data: {}
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Can be called from any phase (setup, playing, results)
|
||||
- Sets `gamePhase: 'setup'`
|
||||
- Resets game progression (scores, cards, etc.)
|
||||
- Preserves configuration (players can modify it)
|
||||
- Synchronized across all room members
|
||||
|
||||
### 2. `SET_CONFIG`
|
||||
|
||||
Updates a configuration field during setup phase.
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'SET_CONFIG',
|
||||
playerId: string,
|
||||
data: {
|
||||
field: string, // Config field name
|
||||
value: any // New value
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Only allowed during setup phase
|
||||
- Validates field name and value
|
||||
- Updates immediately with optimistic update
|
||||
- Synchronized across all room members
|
||||
|
||||
### 3. `START_GAME`
|
||||
|
||||
Starts the game with current configuration.
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'START_GAME',
|
||||
playerId: string,
|
||||
data: {
|
||||
activePlayers: string[],
|
||||
playerMetadata: { [playerId: string]: PlayerMetadata },
|
||||
// ... game-specific initial data
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Only allowed during setup phase
|
||||
- Uses current session state configuration
|
||||
- Initializes game-specific state
|
||||
- Sets `gamePhase: 'playing'`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### 1. Update Validation Types
|
||||
|
||||
Add move types to your game's validation types:
|
||||
|
||||
```typescript
|
||||
// src/lib/arcade/validation/types.ts
|
||||
|
||||
export interface YourGameGoToSetupMove extends GameMove {
|
||||
type: 'GO_TO_SETUP'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface YourGameSetConfigMove extends GameMove {
|
||||
type: 'SET_CONFIG'
|
||||
data: {
|
||||
field: 'configField1' | 'configField2' | 'configField3'
|
||||
value: any
|
||||
}
|
||||
}
|
||||
|
||||
export type YourGameMove =
|
||||
| YourGameStartGameMove
|
||||
| YourGameGoToSetupMove
|
||||
| YourGameSetConfigMove
|
||||
| ... // other game-specific moves
|
||||
```
|
||||
|
||||
### 2. Implement Validators
|
||||
|
||||
Add validators for setup moves in your game's validator class:
|
||||
|
||||
```typescript
|
||||
// src/lib/arcade/validation/YourGameValidator.ts
|
||||
|
||||
export class YourGameValidator implements GameValidator<YourGameState, YourGameMove> {
|
||||
validateMove(state, move, context) {
|
||||
switch (move.type) {
|
||||
case 'GO_TO_SETUP':
|
||||
return this.validateGoToSetup(state)
|
||||
|
||||
case 'SET_CONFIG':
|
||||
return this.validateSetConfig(state, move.data.field, move.data.value)
|
||||
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data)
|
||||
|
||||
// ... other moves
|
||||
}
|
||||
}
|
||||
|
||||
private validateGoToSetup(state: YourGameState): ValidationResult {
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
gamePhase: 'setup',
|
||||
// Reset game progression, preserve configuration
|
||||
// ... reset scores, game data, etc.
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private validateSetConfig(
|
||||
state: YourGameState,
|
||||
field: string,
|
||||
value: any
|
||||
): ValidationResult {
|
||||
// Only during setup
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Cannot change config outside setup' }
|
||||
}
|
||||
|
||||
// Validate field-specific values
|
||||
switch (field) {
|
||||
case 'configField1':
|
||||
if (!isValidValue(value)) {
|
||||
return { valid: false, error: 'Invalid value' }
|
||||
}
|
||||
break
|
||||
// ... validate other fields
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState: { ...state, [field]: value },
|
||||
}
|
||||
}
|
||||
|
||||
private validateStartGame(state: YourGameState, data: any): ValidationResult {
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Can only start from setup' }
|
||||
}
|
||||
|
||||
// Use current state configuration to initialize game
|
||||
const initialGameData = initializeYourGame(state.configField1, state.configField2)
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
...initialGameData,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Add Optimistic Updates
|
||||
|
||||
Update `applyMoveOptimistically` in your providers:
|
||||
|
||||
```typescript
|
||||
// src/app/arcade/your-game/context/YourGameProvider.tsx
|
||||
|
||||
function applyMoveOptimistically(state: YourGameState, move: GameMove): YourGameState {
|
||||
switch (move.type) {
|
||||
case 'GO_TO_SETUP':
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'setup',
|
||||
// Reset game state, preserve config
|
||||
}
|
||||
|
||||
case 'SET_CONFIG':
|
||||
const { field, value } = move.data
|
||||
return {
|
||||
...state,
|
||||
[field]: value,
|
||||
}
|
||||
|
||||
case 'START_GAME':
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
// ... initialize game data from move
|
||||
}
|
||||
|
||||
// ... other moves
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Remove Local State from Providers
|
||||
|
||||
**❌ OLD PATTERN (Don't do this):**
|
||||
|
||||
```typescript
|
||||
// DON'T: Local React state for configuration
|
||||
const [localDifficulty, setLocalDifficulty] = useState(6)
|
||||
|
||||
// DON'T: Merge hack
|
||||
const effectiveState = state.gamePhase === 'setup'
|
||||
? { ...state, difficulty: localDifficulty }
|
||||
: state
|
||||
|
||||
// DON'T: Direct setter
|
||||
const setDifficulty = (value) => setLocalDifficulty(value)
|
||||
```
|
||||
|
||||
**✅ NEW PATTERN (Do this):**
|
||||
|
||||
```typescript
|
||||
// DO: Use session state directly
|
||||
const { state, sendMove } = useArcadeSession(...)
|
||||
|
||||
// DO: Send move for config changes
|
||||
const setDifficulty = useCallback((value) => {
|
||||
const playerId = activePlayers[0] || ''
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId,
|
||||
data: { field: 'difficulty', value },
|
||||
})
|
||||
}, [activePlayers, sendMove])
|
||||
|
||||
// DO: Use state directly (no merging!)
|
||||
const contextValue = { state: { ...state, gameMode }, ... }
|
||||
```
|
||||
|
||||
### 5. Update Action Creators
|
||||
|
||||
All configuration actions should send moves:
|
||||
|
||||
```typescript
|
||||
export function YourGameProvider({ children }) {
|
||||
const { state, sendMove } = useArcadeSession(...)
|
||||
|
||||
const setConfigField = useCallback((value) => {
|
||||
const playerId = activePlayers[0] || ''
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId,
|
||||
data: { field: 'configField', value },
|
||||
})
|
||||
}, [activePlayers, sendMove])
|
||||
|
||||
const goToSetup = useCallback(() => {
|
||||
const playerId = activePlayers[0] || state.currentPlayer || ''
|
||||
sendMove({
|
||||
type: 'GO_TO_SETUP',
|
||||
playerId,
|
||||
data: {},
|
||||
})
|
||||
}, [activePlayers, state.currentPlayer, sendMove])
|
||||
|
||||
const startGame = useCallback(() => {
|
||||
// Use current session state config (not local state!)
|
||||
const initialData = initializeGame(state.config1, state.config2)
|
||||
|
||||
const playerId = activePlayers[0]
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId,
|
||||
data: {
|
||||
...initialData,
|
||||
activePlayers,
|
||||
playerMetadata: capturePlayerMetadata(players, activePlayers),
|
||||
},
|
||||
})
|
||||
}, [state.config1, state.config2, activePlayers, sendMove])
|
||||
|
||||
return <YourGameContext.Provider value={...} />
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benefits of This Pattern
|
||||
|
||||
### 1. **Synchronized Setup**
|
||||
- User A clicks "Setup" → All room members see setup screen
|
||||
- User B changes difficulty → All room members see the change
|
||||
- User A clicks "Start" → All room members start playing
|
||||
|
||||
### 2. **No Special Cases**
|
||||
- Setup works like gameplay (moves + validation)
|
||||
- No conditional logic based on phase
|
||||
- No React state merging hacks
|
||||
|
||||
### 3. **Easy to Extend**
|
||||
- New games copy the same pattern
|
||||
- Well-documented and tested
|
||||
- Consistent developer experience
|
||||
|
||||
### 4. **Optimistic Updates**
|
||||
- Config changes feel instant
|
||||
- Client-side prediction + server validation
|
||||
- Rollback on validation failure
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
When implementing this pattern, test these scenarios:
|
||||
|
||||
### Local Mode
|
||||
- [ ] Click setup button during game → returns to setup
|
||||
- [ ] Change config fields → updates immediately
|
||||
- [ ] Start game → uses current config
|
||||
|
||||
### Room Mode (Multi-User)
|
||||
- [ ] User A clicks setup → User B sees setup screen
|
||||
- [ ] User A changes difficulty → User B sees change in real-time
|
||||
- [ ] User B changes game type → User A sees change in real-time
|
||||
- [ ] User A starts game → Both users see game with same config
|
||||
|
||||
### Edge Cases
|
||||
- [ ] Change config rapidly → no race conditions
|
||||
- [ ] User with 0 players can see/modify setup
|
||||
- [ ] Setup → Play → Setup preserves last config
|
||||
- [ ] Invalid config values are rejected by validator
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide
|
||||
|
||||
If you have an existing game using local state, follow these steps:
|
||||
|
||||
### Step 1: Add Move Types
|
||||
Add `GO_TO_SETUP` and `SET_CONFIG` to your validation types.
|
||||
|
||||
### Step 2: Implement Validators
|
||||
Add validators for the new moves in your game validator class.
|
||||
|
||||
### Step 3: Add Optimistic Updates
|
||||
Update `applyMoveOptimistically` to handle the new moves.
|
||||
|
||||
### Step 4: Remove Local State
|
||||
1. Delete all `useState` calls for configuration
|
||||
2. Delete the `effectiveState` merging logic
|
||||
3. Update action creators to send moves instead
|
||||
|
||||
### Step 5: Test
|
||||
Run through the testing checklist above.
|
||||
|
||||
---
|
||||
|
||||
## Reference Implementation
|
||||
|
||||
See the Matching game for a complete reference implementation:
|
||||
|
||||
- **Types**: `src/lib/arcade/validation/types.ts`
|
||||
- **Validator**: `src/lib/arcade/validation/MatchingGameValidator.ts`
|
||||
- **Provider**: `src/app/arcade/matching/context/RoomMemoryPairsProvider.tsx`
|
||||
- **Optimistic Updates**: `applyMoveOptimistically` function in provider
|
||||
|
||||
Look for comments marked with:
|
||||
- `// STANDARD ARCADE PATTERN: GO_TO_SETUP`
|
||||
- `// STANDARD ARCADE PATTERN: SET_CONFIG`
|
||||
- `// NO LOCAL STATE`
|
||||
- `// NO MORE effectiveState merging!`
|
||||
|
||||
---
|
||||
|
||||
## Questions?
|
||||
|
||||
If something is unclear or you encounter issues implementing this pattern, refer to the Matching game implementation or update this document with clarifications.
|
||||
86
apps/web/.claude/CLAUDE.md
Normal file
86
apps/web/.claude/CLAUDE.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Claude Code Instructions for apps/web
|
||||
|
||||
## MANDATORY: Quality Checks for ALL Work
|
||||
|
||||
**BEFORE declaring ANY work complete, fixed, or working**, you MUST run and pass these checks:
|
||||
|
||||
### When This Applies
|
||||
- Before every commit
|
||||
- Before saying "it's done" or "it's fixed"
|
||||
- Before marking a task as complete
|
||||
- Before telling the user something is working
|
||||
- After any code changes, no matter how small
|
||||
|
||||
```bash
|
||||
npm run pre-commit
|
||||
```
|
||||
|
||||
This single command runs all quality checks in the correct order:
|
||||
1. `npm run type-check` - TypeScript type checking (must have 0 errors)
|
||||
2. `npm run format` - Auto-format all code with Biome
|
||||
3. `npm run lint:fix` - Auto-fix linting issues with Biome + ESLint
|
||||
4. `npm run lint` - Verify 0 errors, 0 warnings
|
||||
|
||||
**DO NOT COMMIT** until all checks pass with zero errors and zero warnings.
|
||||
|
||||
## Available Scripts
|
||||
|
||||
```bash
|
||||
npm run type-check # TypeScript: tsc --noEmit
|
||||
npm run format # Biome: format all files
|
||||
npm run format:check # Biome: check formatting without fixing
|
||||
npm run lint # Biome + ESLint: check for errors/warnings
|
||||
npm run lint:fix # Biome + ESLint: auto-fix issues
|
||||
npm run check # Biome: full check (format + lint + imports)
|
||||
npm run pre-commit # Run all checks (type + format + lint)
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
When asked to make ANY changes:
|
||||
|
||||
1. Make your code changes
|
||||
2. Run `npm run pre-commit`
|
||||
3. If it fails, fix the issues and run again
|
||||
4. Only after all checks pass can you:
|
||||
- Say the work is "done" or "complete"
|
||||
- Mark tasks as finished
|
||||
- Create commits
|
||||
- Tell the user it's working
|
||||
5. Push immediately after committing
|
||||
|
||||
**Nothing is complete until `npm run pre-commit` passes.**
|
||||
|
||||
## Details
|
||||
|
||||
See `.claude/CODE_QUALITY_REGIME.md` for complete documentation.
|
||||
|
||||
## No Pre-Commit Hooks
|
||||
|
||||
This project does not use git pre-commit hooks for religious reasons.
|
||||
You (Claude Code) are responsible for enforcing code quality before commits.
|
||||
|
||||
## Quick Reference: package.json Scripts
|
||||
|
||||
**Primary workflow:**
|
||||
```bash
|
||||
npm run pre-commit # ← Use this before every commit
|
||||
```
|
||||
|
||||
**Individual checks (if needed):**
|
||||
```bash
|
||||
npm run type-check # TypeScript: tsc --noEmit
|
||||
npm run format # Biome: format code (--write)
|
||||
npm run lint # Biome + ESLint: check only
|
||||
npm run lint:fix # Biome + ESLint: auto-fix
|
||||
```
|
||||
|
||||
**Additional tools:**
|
||||
```bash
|
||||
npm run format:check # Check formatting without changing files
|
||||
npm run check # Biome check (format + lint + organize imports)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Remember: Always run `npm run pre-commit` before creating commits.**
|
||||
143
apps/web/.claude/CODE_QUALITY_REGIME.md
Normal file
143
apps/web/.claude/CODE_QUALITY_REGIME.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Code Quality Regime
|
||||
|
||||
**MANDATORY**: Before declaring ANY work complete, fixed, or working, Claude MUST run these checks and fix all issues.
|
||||
|
||||
## Definition of "Done"
|
||||
|
||||
Work is NOT complete until:
|
||||
- ✅ All TypeScript errors are fixed (0 errors)
|
||||
- ✅ All code is formatted with Biome
|
||||
- ✅ All linting passes (0 errors, 0 warnings)
|
||||
- ✅ `npm run pre-commit` exits successfully
|
||||
|
||||
**Until these checks pass, the work is considered incomplete.**
|
||||
|
||||
## Quality Check Checklist (Always Required)
|
||||
|
||||
Run these before:
|
||||
- Committing code
|
||||
- Saying work is "done" or "complete"
|
||||
- Marking tasks as finished
|
||||
- Telling the user something is "working" or "fixed"
|
||||
|
||||
Run these commands in order. All must pass with 0 errors and 0 warnings:
|
||||
|
||||
```bash
|
||||
# 1. Type check
|
||||
npm run type-check
|
||||
|
||||
# 2. Format code
|
||||
npm run format
|
||||
|
||||
# 3. Lint and fix
|
||||
npm run lint:fix
|
||||
|
||||
# 4. Verify clean state
|
||||
npm run lint && npm run type-check
|
||||
```
|
||||
|
||||
## Quick Command (Run All Checks)
|
||||
|
||||
```bash
|
||||
npm run pre-commit
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
```json
|
||||
"pre-commit": "npm run type-check && npm run format && npm run lint:fix && npm run lint"
|
||||
```
|
||||
|
||||
This single command runs:
|
||||
1. `npm run type-check` → `tsc --noEmit` (TypeScript errors)
|
||||
2. `npm run format` → `npx @biomejs/biome format . --write` (auto-format)
|
||||
3. `npm run lint:fix` → `npx @biomejs/biome lint . --write && npx eslint . --fix` (auto-fix)
|
||||
4. `npm run lint` → `npx @biomejs/biome lint . && npx eslint .` (verify clean)
|
||||
|
||||
Fails fast if any step fails.
|
||||
|
||||
## The Regime Rules
|
||||
|
||||
### 1. TypeScript Errors: ZERO TOLERANCE
|
||||
- Run `npm run type-check` before every commit
|
||||
- Fix ALL TypeScript errors
|
||||
- No `@ts-ignore` or `@ts-expect-error` without explicit justification
|
||||
|
||||
### 2. Formatting: AUTOMATIC
|
||||
- Run `npm run format` before every commit
|
||||
- Biome handles all formatting automatically
|
||||
- Never commit unformatted code
|
||||
|
||||
### 3. Linting: ZERO ERRORS, ZERO WARNINGS
|
||||
- Run `npm run lint:fix` to auto-fix issues
|
||||
- Then run `npm run lint` to verify 0 errors, 0 warnings
|
||||
- Fix any remaining issues manually
|
||||
|
||||
### 4. Commit Order
|
||||
1. Make code changes
|
||||
2. Run `npm run pre-commit`
|
||||
3. If any check fails, fix and repeat
|
||||
4. Only commit when all checks pass
|
||||
5. Push immediately after commit
|
||||
|
||||
## Why No Pre-Commit Hooks?
|
||||
|
||||
This project intentionally avoids pre-commit hooks due to religious constraints.
|
||||
Instead, Claude Code is responsible for enforcing this regime through:
|
||||
|
||||
1. **This documentation** - Always visible and reference-able
|
||||
2. **Package.json scripts** - Easy to run checks
|
||||
3. **Session persistence** - This file lives in `.claude/` and is read by every session
|
||||
|
||||
## For Claude Code Sessions
|
||||
|
||||
**READ THIS FILE AT THE START OF EVERY SESSION WHERE YOU WILL COMMIT CODE**
|
||||
|
||||
When asked to commit:
|
||||
1. Check if you've run `npm run pre-commit` (or all 4 steps individually)
|
||||
2. If not, STOP and run the checks first
|
||||
3. Fix all issues before proceeding with the commit
|
||||
4. Only create commits when all checks pass
|
||||
|
||||
## Complete Scripts Reference
|
||||
|
||||
From `apps/web/package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"format": "npx @biomejs/biome format . --write",
|
||||
"format:check": "npx @biomejs/biome format .",
|
||||
"lint": "npx @biomejs/biome lint . && npx eslint .",
|
||||
"lint:fix": "npx @biomejs/biome lint . --write && npx eslint . --fix",
|
||||
"check": "npx @biomejs/biome check .",
|
||||
"pre-commit": "npm run type-check && npm run format && npm run lint:fix && npm run lint"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Tools used:**
|
||||
- TypeScript: `tsc --noEmit` (type checking only, no output)
|
||||
- Biome: Fast formatter + linter (Rust-based, 10-100x faster than Prettier)
|
||||
- ESLint: React Hooks rules only (`rules-of-hooks` validation)
|
||||
|
||||
## Emergency Override
|
||||
|
||||
If you absolutely MUST commit with failing checks:
|
||||
1. Document WHY in the commit message
|
||||
2. Create a follow-up task to fix the issues
|
||||
3. Only use for emergency hotfixes
|
||||
|
||||
## Verification
|
||||
|
||||
After following this regime, you should see:
|
||||
```
|
||||
✓ Type check passed (0 errors)
|
||||
✓ Formatting applied
|
||||
✓ Linting passed (0 errors, 0 warnings)
|
||||
✓ Ready to commit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**This regime is non-negotiable. Every commit must pass these checks.**
|
||||
19
apps/web/.claude/settings.local.json
Normal file
19
apps/web/.claude/settings.local.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm test:*)",
|
||||
"Read(//Users/antialias/projects/**)",
|
||||
"Bash(npm run lint:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git pull:*)",
|
||||
"Bash(git stash:*)",
|
||||
"Bash(npm run format:*)",
|
||||
"Bash(npm run pre-commit:*)",
|
||||
"Bash(npm run type-check:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
50
apps/web/.gitignore
vendored
Normal file
50
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# vitest
|
||||
/.vitest
|
||||
|
||||
# storybook
|
||||
storybook-static
|
||||
|
||||
# panda css
|
||||
styled-system
|
||||
|
||||
# generated
|
||||
src/generated/build-info.json
|
||||
|
||||
# biome
|
||||
.biome
|
||||
@@ -1,36 +1,31 @@
|
||||
import type { StorybookConfig } from '@storybook/nextjs';
|
||||
import type { StorybookConfig } from '@storybook/nextjs'
|
||||
|
||||
import { join, dirname } from "path"
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
/**
|
||||
* This function is used to resolve the absolute path of a package.
|
||||
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
|
||||
*/
|
||||
* This function is used to resolve the absolute path of a package.
|
||||
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
|
||||
*/
|
||||
function getAbsolutePath(value: string): any {
|
||||
return dirname(require.resolve(join(value, 'package.json')))
|
||||
}
|
||||
const config: StorybookConfig = {
|
||||
"stories": [
|
||||
"../src/**/*.mdx",
|
||||
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
|
||||
],
|
||||
"addons": [
|
||||
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: [
|
||||
getAbsolutePath('@storybook/addon-docs'),
|
||||
getAbsolutePath('@storybook/addon-onboarding')
|
||||
getAbsolutePath('@storybook/addon-onboarding'),
|
||||
],
|
||||
"framework": {
|
||||
"name": getAbsolutePath('@storybook/nextjs'),
|
||||
"options": {
|
||||
"nextConfigPath": "../next.config.js"
|
||||
}
|
||||
framework: {
|
||||
name: getAbsolutePath('@storybook/nextjs'),
|
||||
options: {
|
||||
nextConfigPath: '../next.config.js',
|
||||
},
|
||||
},
|
||||
"staticDirs": [
|
||||
"../public"
|
||||
],
|
||||
"typescript": {
|
||||
"reactDocgen": "react-docgen-typescript"
|
||||
staticDirs: ['../public'],
|
||||
typescript: {
|
||||
reactDocgen: 'react-docgen-typescript',
|
||||
},
|
||||
"webpackFinal": async (config) => {
|
||||
webpackFinal: async (config) => {
|
||||
// Handle PandaCSS styled-system imports
|
||||
if (config.resolve) {
|
||||
config.resolve.alias = {
|
||||
@@ -39,10 +34,10 @@ const config: StorybookConfig = {
|
||||
'../../styled-system/css': join(__dirname, '../styled-system/css/index.mjs'),
|
||||
'../../styled-system/patterns': join(__dirname, '../styled-system/patterns/index.mjs'),
|
||||
'../styled-system/css': join(__dirname, '../styled-system/css/index.mjs'),
|
||||
'../styled-system/patterns': join(__dirname, '../styled-system/patterns/index.mjs')
|
||||
'../styled-system/patterns': join(__dirname, '../styled-system/patterns/index.mjs'),
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
||||
};
|
||||
export default config;
|
||||
},
|
||||
}
|
||||
export default config
|
||||
|
||||
@@ -5,11 +5,11 @@ const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default preview;
|
||||
export default preview
|
||||
|
||||
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
104
apps/web/LINTING.md
Normal file
104
apps/web/LINTING.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Linting & Formatting Setup
|
||||
|
||||
This project uses **Biome** for formatting and general linting, with **ESLint** handling React Hooks rules only.
|
||||
|
||||
## Tools
|
||||
|
||||
- **@biomejs/biome** - Fast formatter + linter + import organizer
|
||||
- **eslint** + **eslint-plugin-react-hooks** - React Hooks validation only
|
||||
|
||||
## Scripts
|
||||
|
||||
```bash
|
||||
# Check formatting and lint (non-destructive)
|
||||
npm run check
|
||||
|
||||
# Lint all files
|
||||
npm run lint
|
||||
|
||||
# Fix lint issues
|
||||
npm run lint:fix
|
||||
|
||||
# Format all files
|
||||
npm run format
|
||||
|
||||
# Check formatting (dry run)
|
||||
npm run format:check
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
- `biome.jsonc` - Biome configuration (format + lint)
|
||||
- `eslint.config.js` - Minimal ESLint flat config for React Hooks only
|
||||
- `.gitignore` - Includes patterns for Biome cache
|
||||
|
||||
## What Each Tool Does
|
||||
|
||||
### Biome
|
||||
- Code formatting (Prettier-compatible)
|
||||
- General JavaScript/TypeScript linting
|
||||
- Import organization (alphabetical, remove unused)
|
||||
- Dead code detection
|
||||
- Performance optimizations
|
||||
|
||||
### ESLint (React Hooks only)
|
||||
- `react-hooks/rules-of-hooks` - Ensures hooks are called unconditionally
|
||||
- `react-hooks/exhaustive-deps` - Warns about incomplete dependency arrays
|
||||
|
||||
## IDE Integration
|
||||
|
||||
### VS Code
|
||||
Install the Biome extension:
|
||||
```
|
||||
code --install-extension biomejs.biome
|
||||
```
|
||||
|
||||
Add to `.vscode/settings.json`:
|
||||
```json
|
||||
{
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true,
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CI/CD
|
||||
|
||||
Add to your GitHub Actions workflow:
|
||||
|
||||
```yaml
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Check formatting
|
||||
run: npm run format:check
|
||||
```
|
||||
|
||||
## Migration from ESLint + Prettier
|
||||
|
||||
This setup replaces most ESLint and Prettier functionality:
|
||||
- ✅ Removed `eslint-config-next` inline config from `package.json`
|
||||
- ✅ No `.eslintrc.js` or `.prettierrc` files needed
|
||||
- ✅ ESLint now only runs React Hooks rules
|
||||
- ✅ Biome handles all formatting and general linting
|
||||
|
||||
## Why This Setup?
|
||||
|
||||
1. **Speed** - Biome is 10-100x faster than ESLint + Prettier
|
||||
2. **Simplicity** - Single tool for most concerns
|
||||
3. **Accuracy** - ESLint still catches React-specific issues Biome can't yet handle
|
||||
4. **Low Maintenance** - Minimal config overlap
|
||||
|
||||
## Customization
|
||||
|
||||
To add custom lint rules, edit:
|
||||
- `biome.jsonc` for general rules
|
||||
- `eslint.config.js` for React Hooks rules
|
||||
344
apps/web/__tests__/api-abacus-settings.e2e.test.ts
Normal file
344
apps/web/__tests__/api-abacus-settings.e2e.test.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { db, schema } from '../src/db'
|
||||
|
||||
/**
|
||||
* API Abacus Settings E2E Tests
|
||||
*
|
||||
* These tests verify the abacus-settings API endpoints work correctly.
|
||||
*/
|
||||
|
||||
describe('Abacus Settings API', () => {
|
||||
let testUserId: string
|
||||
let testGuestId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a test user with unique guest ID
|
||||
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning()
|
||||
testUserId = user.id
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up: delete test user (cascade deletes settings)
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
||||
})
|
||||
|
||||
describe('GET /api/abacus-settings', () => {
|
||||
it('creates settings with defaults if none exist', async () => {
|
||||
const [settings] = await db
|
||||
.insert(schema.abacusSettings)
|
||||
.values({ userId: testUserId })
|
||||
.returning()
|
||||
|
||||
expect(settings).toBeDefined()
|
||||
expect(settings.colorScheme).toBe('place-value')
|
||||
expect(settings.beadShape).toBe('diamond')
|
||||
expect(settings.colorPalette).toBe('default')
|
||||
expect(settings.hideInactiveBeads).toBe(false)
|
||||
expect(settings.coloredNumerals).toBe(false)
|
||||
expect(settings.scaleFactor).toBe(1.0)
|
||||
expect(settings.showNumbers).toBe(true)
|
||||
expect(settings.animated).toBe(true)
|
||||
expect(settings.interactive).toBe(false)
|
||||
expect(settings.gestures).toBe(false)
|
||||
expect(settings.soundEnabled).toBe(true)
|
||||
expect(settings.soundVolume).toBe(0.8)
|
||||
})
|
||||
|
||||
it('returns existing settings', async () => {
|
||||
// Create settings
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: testUserId,
|
||||
colorScheme: 'monochrome',
|
||||
beadShape: 'circle',
|
||||
soundEnabled: false,
|
||||
soundVolume: 0.5,
|
||||
})
|
||||
|
||||
const settings = await db.query.abacusSettings.findFirst({
|
||||
where: eq(schema.abacusSettings.userId, testUserId),
|
||||
})
|
||||
|
||||
expect(settings).toBeDefined()
|
||||
expect(settings?.colorScheme).toBe('monochrome')
|
||||
expect(settings?.beadShape).toBe('circle')
|
||||
expect(settings?.soundEnabled).toBe(false)
|
||||
expect(settings?.soundVolume).toBe(0.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/abacus-settings', () => {
|
||||
it('creates new settings if none exist', async () => {
|
||||
const [settings] = await db
|
||||
.insert(schema.abacusSettings)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
soundEnabled: false,
|
||||
})
|
||||
.returning()
|
||||
|
||||
expect(settings).toBeDefined()
|
||||
expect(settings.soundEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('updates existing settings', async () => {
|
||||
// Create initial settings
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: testUserId,
|
||||
colorScheme: 'place-value',
|
||||
beadShape: 'diamond',
|
||||
})
|
||||
|
||||
// Update
|
||||
const [updated] = await db
|
||||
.update(schema.abacusSettings)
|
||||
.set({
|
||||
colorScheme: 'heaven-earth',
|
||||
beadShape: 'square',
|
||||
})
|
||||
.where(eq(schema.abacusSettings.userId, testUserId))
|
||||
.returning()
|
||||
|
||||
expect(updated.colorScheme).toBe('heaven-earth')
|
||||
expect(updated.beadShape).toBe('square')
|
||||
})
|
||||
|
||||
it('updates only provided fields', async () => {
|
||||
// Create initial settings
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: testUserId,
|
||||
colorScheme: 'place-value',
|
||||
soundEnabled: true,
|
||||
soundVolume: 0.8,
|
||||
})
|
||||
|
||||
// Update only soundEnabled
|
||||
const [updated] = await db
|
||||
.update(schema.abacusSettings)
|
||||
.set({ soundEnabled: false })
|
||||
.where(eq(schema.abacusSettings.userId, testUserId))
|
||||
.returning()
|
||||
|
||||
expect(updated.soundEnabled).toBe(false)
|
||||
expect(updated.colorScheme).toBe('place-value') // unchanged
|
||||
expect(updated.soundVolume).toBe(0.8) // unchanged
|
||||
})
|
||||
|
||||
it('prevents setting invalid userId via foreign key constraint', async () => {
|
||||
// Create initial settings
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: testUserId,
|
||||
})
|
||||
|
||||
// Try to update with invalid userId - should fail
|
||||
await expect(async () => {
|
||||
await db
|
||||
.update(schema.abacusSettings)
|
||||
.set({
|
||||
userId: 'HACKER_ID_INVALID',
|
||||
soundEnabled: false,
|
||||
})
|
||||
.where(eq(schema.abacusSettings.userId, testUserId))
|
||||
}).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('allows updating all display settings', async () => {
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: testUserId,
|
||||
})
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.abacusSettings)
|
||||
.set({
|
||||
colorScheme: 'alternating',
|
||||
beadShape: 'circle',
|
||||
colorPalette: 'colorblind',
|
||||
hideInactiveBeads: true,
|
||||
coloredNumerals: true,
|
||||
scaleFactor: 1.5,
|
||||
showNumbers: false,
|
||||
animated: false,
|
||||
interactive: true,
|
||||
gestures: true,
|
||||
soundEnabled: false,
|
||||
soundVolume: 0.3,
|
||||
})
|
||||
.where(eq(schema.abacusSettings.userId, testUserId))
|
||||
.returning()
|
||||
|
||||
expect(updated.colorScheme).toBe('alternating')
|
||||
expect(updated.beadShape).toBe('circle')
|
||||
expect(updated.colorPalette).toBe('colorblind')
|
||||
expect(updated.hideInactiveBeads).toBe(true)
|
||||
expect(updated.coloredNumerals).toBe(true)
|
||||
expect(updated.scaleFactor).toBe(1.5)
|
||||
expect(updated.showNumbers).toBe(false)
|
||||
expect(updated.animated).toBe(false)
|
||||
expect(updated.interactive).toBe(true)
|
||||
expect(updated.gestures).toBe(true)
|
||||
expect(updated.soundEnabled).toBe(false)
|
||||
expect(updated.soundVolume).toBe(0.3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cascade delete behavior', () => {
|
||||
it('deletes settings when user is deleted', async () => {
|
||||
// Create settings
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: testUserId,
|
||||
soundEnabled: false,
|
||||
})
|
||||
|
||||
// Verify settings exist
|
||||
let settings = await db.query.abacusSettings.findFirst({
|
||||
where: eq(schema.abacusSettings.userId, testUserId),
|
||||
})
|
||||
expect(settings).toBeDefined()
|
||||
|
||||
// Delete user
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
||||
|
||||
// Verify settings are gone
|
||||
settings = await db.query.abacusSettings.findFirst({
|
||||
where: eq(schema.abacusSettings.userId, testUserId),
|
||||
})
|
||||
expect(settings).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data isolation', () => {
|
||||
it('ensures settings are isolated per user', async () => {
|
||||
// Create another user
|
||||
const testGuestId2 = `test-guest-2-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const [user2] = await db.insert(schema.users).values({ guestId: testGuestId2 }).returning()
|
||||
|
||||
try {
|
||||
// Create settings for both users
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: testUserId,
|
||||
colorScheme: 'monochrome',
|
||||
})
|
||||
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: user2.id,
|
||||
colorScheme: 'place-value',
|
||||
})
|
||||
|
||||
// Verify isolation
|
||||
const settings1 = await db.query.abacusSettings.findFirst({
|
||||
where: eq(schema.abacusSettings.userId, testUserId),
|
||||
})
|
||||
const settings2 = await db.query.abacusSettings.findFirst({
|
||||
where: eq(schema.abacusSettings.userId, user2.id),
|
||||
})
|
||||
|
||||
expect(settings1?.colorScheme).toBe('monochrome')
|
||||
expect(settings2?.colorScheme).toBe('place-value')
|
||||
} finally {
|
||||
// Clean up second user
|
||||
await db.delete(schema.users).where(eq(schema.users.id, user2.id))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Security: userId injection prevention', () => {
|
||||
it('rejects attempts to update settings with non-existent userId', async () => {
|
||||
// Create initial settings
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: testUserId,
|
||||
soundEnabled: true,
|
||||
})
|
||||
|
||||
// Attempt to inject a fake userId
|
||||
await expect(async () => {
|
||||
await db
|
||||
.update(schema.abacusSettings)
|
||||
.set({
|
||||
userId: 'HACKER_ID_NON_EXISTENT',
|
||||
soundEnabled: false,
|
||||
})
|
||||
.where(eq(schema.abacusSettings.userId, testUserId))
|
||||
}).rejects.toThrow(/FOREIGN KEY constraint failed/)
|
||||
})
|
||||
|
||||
it("prevents modifying another user's settings via userId injection", async () => {
|
||||
// Create victim user
|
||||
const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const [victimUser] = await db
|
||||
.insert(schema.users)
|
||||
.values({ guestId: victimGuestId })
|
||||
.returning()
|
||||
|
||||
try {
|
||||
// Create settings for both users
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: testUserId,
|
||||
colorScheme: 'monochrome',
|
||||
soundEnabled: true,
|
||||
})
|
||||
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: victimUser.id,
|
||||
colorScheme: 'place-value',
|
||||
soundEnabled: true,
|
||||
})
|
||||
|
||||
// Attacker tries to change userId to victim's ID
|
||||
// This is rejected because userId is PRIMARY KEY (UNIQUE constraint)
|
||||
await expect(async () => {
|
||||
await db
|
||||
.update(schema.abacusSettings)
|
||||
.set({
|
||||
userId: victimUser.id, // Trying to inject victim's ID
|
||||
soundEnabled: false,
|
||||
})
|
||||
.where(eq(schema.abacusSettings.userId, testUserId))
|
||||
}).rejects.toThrow(/UNIQUE constraint failed/)
|
||||
|
||||
// Verify victim's settings are unchanged
|
||||
const victimSettings = await db.query.abacusSettings.findFirst({
|
||||
where: eq(schema.abacusSettings.userId, victimUser.id),
|
||||
})
|
||||
expect(victimSettings?.soundEnabled).toBe(true)
|
||||
expect(victimSettings?.colorScheme).toBe('place-value')
|
||||
} finally {
|
||||
await db.delete(schema.users).where(eq(schema.users.id, victimUser.id))
|
||||
}
|
||||
})
|
||||
|
||||
it('prevents creating settings for another user via userId injection', async () => {
|
||||
// Create victim user
|
||||
const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const [victimUser] = await db
|
||||
.insert(schema.users)
|
||||
.values({ guestId: victimGuestId })
|
||||
.returning()
|
||||
|
||||
try {
|
||||
// Try to create settings for victim with attacker's data
|
||||
// This will succeed because foreign key exists, but in the real API
|
||||
// the userId comes from session, not request body
|
||||
const [maliciousSettings] = await db
|
||||
.insert(schema.abacusSettings)
|
||||
.values({
|
||||
userId: victimUser.id,
|
||||
colorScheme: 'alternating', // Attacker's preference
|
||||
})
|
||||
.returning()
|
||||
|
||||
// This test shows that at the DB level, we CAN insert for any valid userId
|
||||
// The security comes from the API layer filtering userId from request body
|
||||
// and deriving it from the session cookie instead
|
||||
expect(maliciousSettings.userId).toBe(victimUser.id)
|
||||
expect(maliciousSettings.colorScheme).toBe('alternating')
|
||||
} finally {
|
||||
await db.delete(schema.users).where(eq(schema.users.id, victimUser.id))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
455
apps/web/__tests__/api-arcade-rooms.e2e.test.ts
Normal file
455
apps/web/__tests__/api-arcade-rooms.e2e.test.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { db, schema } from '../src/db'
|
||||
import { createRoom } from '../src/lib/arcade/room-manager'
|
||||
import { addRoomMember } from '../src/lib/arcade/room-membership'
|
||||
|
||||
/**
|
||||
* Arcade Rooms API E2E Tests
|
||||
*
|
||||
* Tests the full arcade room system:
|
||||
* - Room CRUD operations
|
||||
* - Member management
|
||||
* - Access control
|
||||
* - Room code lookups
|
||||
*/
|
||||
|
||||
describe('Arcade Rooms API', () => {
|
||||
let testUserId1: string
|
||||
let testUserId2: string
|
||||
let testGuestId1: string
|
||||
let testGuestId2: string
|
||||
let testRoomId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test users
|
||||
testGuestId1 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
testGuestId2 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
const [user1] = await db.insert(schema.users).values({ guestId: testGuestId1 }).returning()
|
||||
const [user2] = await db.insert(schema.users).values({ guestId: testGuestId2 }).returning()
|
||||
|
||||
testUserId1 = user1.id
|
||||
testUserId2 = user2.id
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up rooms (cascade deletes members)
|
||||
if (testRoomId) {
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, testRoomId))
|
||||
}
|
||||
|
||||
// Clean up users
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId1))
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId2))
|
||||
})
|
||||
|
||||
describe('Room Creation', () => {
|
||||
it('creates a room with valid data', async () => {
|
||||
const room = await createRoom({
|
||||
name: 'Test Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6 },
|
||||
})
|
||||
|
||||
testRoomId = room.id
|
||||
|
||||
expect(room).toBeDefined()
|
||||
expect(room.name).toBe('Test Room')
|
||||
expect(room.createdBy).toBe(testGuestId1)
|
||||
expect(room.gameName).toBe('matching')
|
||||
expect(room.status).toBe('lobby')
|
||||
expect(room.isLocked).toBe(false)
|
||||
expect(room.ttlMinutes).toBe(60)
|
||||
expect(room.code).toMatch(/^[A-Z0-9]{6}$/)
|
||||
})
|
||||
|
||||
it('creates room with custom TTL', async () => {
|
||||
const room = await createRoom({
|
||||
name: 'Custom TTL Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
ttlMinutes: 120,
|
||||
})
|
||||
|
||||
testRoomId = room.id
|
||||
|
||||
expect(room.ttlMinutes).toBe(120)
|
||||
})
|
||||
|
||||
it('generates unique room codes', async () => {
|
||||
const room1 = await createRoom({
|
||||
name: 'Room 1',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: 'User 1',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
})
|
||||
|
||||
const room2 = await createRoom({
|
||||
name: 'Room 2',
|
||||
createdBy: testGuestId2,
|
||||
creatorName: 'User 2',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
})
|
||||
|
||||
// Clean up both rooms
|
||||
testRoomId = room1.id
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room2.id))
|
||||
|
||||
expect(room1.code).not.toBe(room2.code)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Room Retrieval', () => {
|
||||
beforeEach(async () => {
|
||||
// Create a test room
|
||||
const room = await createRoom({
|
||||
name: 'Retrieval Test Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
})
|
||||
testRoomId = room.id
|
||||
})
|
||||
|
||||
it('retrieves room by ID', async () => {
|
||||
const room = await db.query.arcadeRooms.findFirst({
|
||||
where: eq(schema.arcadeRooms.id, testRoomId),
|
||||
})
|
||||
|
||||
expect(room).toBeDefined()
|
||||
expect(room?.id).toBe(testRoomId)
|
||||
expect(room?.name).toBe('Retrieval Test Room')
|
||||
})
|
||||
|
||||
it('retrieves room by code', async () => {
|
||||
const createdRoom = await db.query.arcadeRooms.findFirst({
|
||||
where: eq(schema.arcadeRooms.id, testRoomId),
|
||||
})
|
||||
|
||||
const room = await db.query.arcadeRooms.findFirst({
|
||||
where: eq(schema.arcadeRooms.code, createdRoom!.code),
|
||||
})
|
||||
|
||||
expect(room).toBeDefined()
|
||||
expect(room?.id).toBe(testRoomId)
|
||||
})
|
||||
|
||||
it('returns undefined for non-existent room', async () => {
|
||||
const room = await db.query.arcadeRooms.findFirst({
|
||||
where: eq(schema.arcadeRooms.id, 'nonexistent-room-id'),
|
||||
})
|
||||
|
||||
expect(room).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Room Updates', () => {
|
||||
beforeEach(async () => {
|
||||
const room = await createRoom({
|
||||
name: 'Update Test Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
})
|
||||
testRoomId = room.id
|
||||
})
|
||||
|
||||
it('updates room name', async () => {
|
||||
const [updated] = await db
|
||||
.update(schema.arcadeRooms)
|
||||
.set({ name: 'Updated Name' })
|
||||
.where(eq(schema.arcadeRooms.id, testRoomId))
|
||||
.returning()
|
||||
|
||||
expect(updated.name).toBe('Updated Name')
|
||||
})
|
||||
|
||||
it('locks room', async () => {
|
||||
const [updated] = await db
|
||||
.update(schema.arcadeRooms)
|
||||
.set({ isLocked: true })
|
||||
.where(eq(schema.arcadeRooms.id, testRoomId))
|
||||
.returning()
|
||||
|
||||
expect(updated.isLocked).toBe(true)
|
||||
})
|
||||
|
||||
it('updates room status', async () => {
|
||||
const [updated] = await db
|
||||
.update(schema.arcadeRooms)
|
||||
.set({ status: 'playing' })
|
||||
.where(eq(schema.arcadeRooms.id, testRoomId))
|
||||
.returning()
|
||||
|
||||
expect(updated.status).toBe('playing')
|
||||
})
|
||||
|
||||
it('updates lastActivity on any change', async () => {
|
||||
const originalRoom = await db.query.arcadeRooms.findFirst({
|
||||
where: eq(schema.arcadeRooms.id, testRoomId),
|
||||
})
|
||||
|
||||
// Wait a bit to ensure different timestamp (at least 1 second for SQLite timestamp resolution)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100))
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.arcadeRooms)
|
||||
.set({ name: 'Activity Test', lastActivity: new Date() })
|
||||
.where(eq(schema.arcadeRooms.id, testRoomId))
|
||||
.returning()
|
||||
|
||||
expect(updated.lastActivity.getTime()).toBeGreaterThan(originalRoom!.lastActivity.getTime())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Room Deletion', () => {
|
||||
it('deletes room', async () => {
|
||||
const room = await createRoom({
|
||||
name: 'Delete Test Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
})
|
||||
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room.id))
|
||||
|
||||
const deleted = await db.query.arcadeRooms.findFirst({
|
||||
where: eq(schema.arcadeRooms.id, room.id),
|
||||
})
|
||||
|
||||
expect(deleted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('cascades delete to room members', async () => {
|
||||
const room = await createRoom({
|
||||
name: 'Cascade Test Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
})
|
||||
|
||||
// Add member
|
||||
await addRoomMember({
|
||||
roomId: room.id,
|
||||
userId: testGuestId1,
|
||||
displayName: 'Test User',
|
||||
})
|
||||
|
||||
// Verify member exists
|
||||
const membersBefore = await db.query.roomMembers.findMany({
|
||||
where: eq(schema.roomMembers.roomId, room.id),
|
||||
})
|
||||
expect(membersBefore).toHaveLength(1)
|
||||
|
||||
// Delete room
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room.id))
|
||||
|
||||
// Verify members deleted
|
||||
const membersAfter = await db.query.roomMembers.findMany({
|
||||
where: eq(schema.roomMembers.roomId, room.id),
|
||||
})
|
||||
expect(membersAfter).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Room Members', () => {
|
||||
beforeEach(async () => {
|
||||
const room = await createRoom({
|
||||
name: 'Members Test Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: 'Test User 1',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
})
|
||||
testRoomId = room.id
|
||||
})
|
||||
|
||||
it('adds member to room', async () => {
|
||||
const result = await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId1,
|
||||
displayName: 'Test User 1',
|
||||
isCreator: true,
|
||||
})
|
||||
|
||||
expect(result.member).toBeDefined()
|
||||
expect(result.member.roomId).toBe(testRoomId)
|
||||
expect(result.member.userId).toBe(testGuestId1)
|
||||
expect(result.member.displayName).toBe('Test User 1')
|
||||
expect(result.member.isCreator).toBe(true)
|
||||
expect(result.member.isOnline).toBe(true)
|
||||
})
|
||||
|
||||
it('adds multiple members to room', async () => {
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId1,
|
||||
displayName: 'User 1',
|
||||
})
|
||||
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
displayName: 'User 2',
|
||||
})
|
||||
|
||||
const members = await db.query.roomMembers.findMany({
|
||||
where: eq(schema.roomMembers.roomId, testRoomId),
|
||||
})
|
||||
|
||||
expect(members).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('updates existing member instead of creating duplicate', async () => {
|
||||
// Add member first time
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId1,
|
||||
displayName: 'First Time',
|
||||
})
|
||||
|
||||
// Add same member again
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId1,
|
||||
displayName: 'Second Time',
|
||||
})
|
||||
|
||||
const members = await db.query.roomMembers.findMany({
|
||||
where: eq(schema.roomMembers.roomId, testRoomId),
|
||||
})
|
||||
|
||||
// Should still only have 1 member
|
||||
expect(members).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('removes member from room', async () => {
|
||||
const result = await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId1,
|
||||
displayName: 'Test User',
|
||||
})
|
||||
|
||||
await db.delete(schema.roomMembers).where(eq(schema.roomMembers.id, result.member.id))
|
||||
|
||||
const members = await db.query.roomMembers.findMany({
|
||||
where: eq(schema.roomMembers.roomId, testRoomId),
|
||||
})
|
||||
|
||||
expect(members).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('tracks online status', async () => {
|
||||
const result = await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId1,
|
||||
displayName: 'Test User',
|
||||
})
|
||||
|
||||
expect(result.member.isOnline).toBe(true)
|
||||
|
||||
// Set offline
|
||||
const [updated] = await db
|
||||
.update(schema.roomMembers)
|
||||
.set({ isOnline: false })
|
||||
.where(eq(schema.roomMembers.id, result.member.id))
|
||||
.returning()
|
||||
|
||||
expect(updated.isOnline).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Access Control', () => {
|
||||
beforeEach(async () => {
|
||||
const room = await createRoom({
|
||||
name: 'Access Test Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: 'Creator',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
})
|
||||
testRoomId = room.id
|
||||
})
|
||||
|
||||
it('identifies room creator correctly', async () => {
|
||||
const room = await db.query.arcadeRooms.findFirst({
|
||||
where: eq(schema.arcadeRooms.id, testRoomId),
|
||||
})
|
||||
|
||||
expect(room?.createdBy).toBe(testGuestId1)
|
||||
})
|
||||
|
||||
it('distinguishes creator from other users', async () => {
|
||||
const room = await db.query.arcadeRooms.findFirst({
|
||||
where: eq(schema.arcadeRooms.id, testRoomId),
|
||||
})
|
||||
|
||||
expect(room?.createdBy).not.toBe(testGuestId2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Room Listing', () => {
|
||||
beforeEach(async () => {
|
||||
// Create multiple test rooms
|
||||
const room1 = await createRoom({
|
||||
name: 'Matching Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: 'User 1',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
})
|
||||
|
||||
const room2 = await createRoom({
|
||||
name: 'Memory Quiz Room',
|
||||
createdBy: testGuestId2,
|
||||
creatorName: 'User 2',
|
||||
gameName: 'memory-quiz',
|
||||
gameConfig: {},
|
||||
})
|
||||
|
||||
testRoomId = room1.id
|
||||
|
||||
// Clean up room2 after test
|
||||
afterEach(async () => {
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room2.id))
|
||||
})
|
||||
})
|
||||
|
||||
it('lists all active rooms', async () => {
|
||||
const rooms = await db.query.arcadeRooms.findMany({
|
||||
where: eq(schema.arcadeRooms.status, 'lobby'),
|
||||
})
|
||||
|
||||
expect(rooms.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('excludes locked rooms from listing', async () => {
|
||||
// Lock one room
|
||||
await db
|
||||
.update(schema.arcadeRooms)
|
||||
.set({ isLocked: true })
|
||||
.where(eq(schema.arcadeRooms.id, testRoomId))
|
||||
|
||||
const unlockedRooms = await db.query.arcadeRooms.findMany({
|
||||
where: eq(schema.arcadeRooms.isLocked, false),
|
||||
})
|
||||
|
||||
expect(unlockedRooms.every((r) => !r.isLocked)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
500
apps/web/__tests__/api-players.e2e.test.ts
Normal file
500
apps/web/__tests__/api-players.e2e.test.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { db, schema } from '../src/db'
|
||||
|
||||
/**
|
||||
* API Players E2E Tests
|
||||
*
|
||||
* These tests verify the players API endpoints work correctly.
|
||||
* They use the actual database and test the full request/response cycle.
|
||||
*/
|
||||
|
||||
describe('Players API', () => {
|
||||
let testUserId: string
|
||||
let testGuestId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a test user with unique guest ID
|
||||
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning()
|
||||
testUserId = user.id
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up: delete test user (cascade deletes players)
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
||||
})
|
||||
|
||||
describe('POST /api/players', () => {
|
||||
it('creates a player with valid data', async () => {
|
||||
const playerData = {
|
||||
name: 'Test Player',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
// Simulate creating via DB (API would do this)
|
||||
const [player] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
...playerData,
|
||||
})
|
||||
.returning()
|
||||
|
||||
expect(player).toBeDefined()
|
||||
expect(player.name).toBe(playerData.name)
|
||||
expect(player.emoji).toBe(playerData.emoji)
|
||||
expect(player.color).toBe(playerData.color)
|
||||
expect(player.isActive).toBe(true)
|
||||
expect(player.userId).toBe(testUserId)
|
||||
})
|
||||
|
||||
it('sets isActive to false by default', async () => {
|
||||
const [player] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: 'Inactive Player',
|
||||
emoji: '😴',
|
||||
color: '#999999',
|
||||
})
|
||||
.returning()
|
||||
|
||||
expect(player.isActive).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/players', () => {
|
||||
it('returns all players for a user', async () => {
|
||||
// Create multiple players
|
||||
await db.insert(schema.players).values([
|
||||
{
|
||||
userId: testUserId,
|
||||
name: 'Player 1',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
userId: testUserId,
|
||||
name: 'Player 2',
|
||||
emoji: '😎',
|
||||
color: '#8b5cf6',
|
||||
},
|
||||
])
|
||||
|
||||
const players = await db.query.players.findMany({
|
||||
where: eq(schema.players.userId, testUserId),
|
||||
})
|
||||
|
||||
expect(players).toHaveLength(2)
|
||||
expect(players[0].name).toBe('Player 1')
|
||||
expect(players[1].name).toBe('Player 2')
|
||||
})
|
||||
|
||||
it('returns empty array for user with no players', async () => {
|
||||
const players = await db.query.players.findMany({
|
||||
where: eq(schema.players.userId, testUserId),
|
||||
})
|
||||
|
||||
expect(players).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/players/[id]', () => {
|
||||
it('updates player fields', async () => {
|
||||
const [player] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: 'Original Name',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
})
|
||||
.returning()
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.players)
|
||||
.set({
|
||||
name: 'Updated Name',
|
||||
emoji: '🎉',
|
||||
})
|
||||
.where(eq(schema.players.id, player.id))
|
||||
.returning()
|
||||
|
||||
expect(updated.name).toBe('Updated Name')
|
||||
expect(updated.emoji).toBe('🎉')
|
||||
expect(updated.color).toBe('#3b82f6') // unchanged
|
||||
})
|
||||
|
||||
it('toggles isActive status', async () => {
|
||||
const [player] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: 'Test Player',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
isActive: false,
|
||||
})
|
||||
.returning()
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.players)
|
||||
.set({ isActive: true })
|
||||
.where(eq(schema.players.id, player.id))
|
||||
.returning()
|
||||
|
||||
expect(updated.isActive).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/players/[id]', () => {
|
||||
it('deletes a player', async () => {
|
||||
const [player] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: 'To Delete',
|
||||
emoji: '👋',
|
||||
color: '#ef4444',
|
||||
})
|
||||
.returning()
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(schema.players)
|
||||
.where(eq(schema.players.id, player.id))
|
||||
.returning()
|
||||
|
||||
expect(deleted).toBeDefined()
|
||||
expect(deleted.id).toBe(player.id)
|
||||
|
||||
// Verify it's gone
|
||||
const found = await db.query.players.findFirst({
|
||||
where: eq(schema.players.id, player.id),
|
||||
})
|
||||
expect(found).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cascade delete behavior', () => {
|
||||
it('deletes players when user is deleted', async () => {
|
||||
// Create players
|
||||
await db.insert(schema.players).values([
|
||||
{
|
||||
userId: testUserId,
|
||||
name: 'Player 1',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
userId: testUserId,
|
||||
name: 'Player 2',
|
||||
emoji: '😎',
|
||||
color: '#8b5cf6',
|
||||
},
|
||||
])
|
||||
|
||||
// Verify players exist
|
||||
let players = await db.query.players.findMany({
|
||||
where: eq(schema.players.userId, testUserId),
|
||||
})
|
||||
expect(players).toHaveLength(2)
|
||||
|
||||
// Delete user
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
||||
|
||||
// Verify players are gone
|
||||
players = await db.query.players.findMany({
|
||||
where: eq(schema.players.userId, testUserId),
|
||||
})
|
||||
expect(players).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Arcade Session: isActive Modification Restrictions', () => {
|
||||
it('prevents isActive changes when user has an active arcade session', async () => {
|
||||
// Create a player
|
||||
const [player] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: 'Test Player',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
isActive: false,
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Create an active arcade session
|
||||
const now = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
userId: testGuestId,
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
gameState: JSON.stringify({}),
|
||||
activePlayers: JSON.stringify([player.id]),
|
||||
startedAt: now,
|
||||
lastActivityAt: now,
|
||||
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
|
||||
version: 1,
|
||||
})
|
||||
|
||||
// Attempt to update isActive should be prevented at API level
|
||||
// This test validates the logic that the API route implements
|
||||
const activeSession = await db.query.arcadeSessions.findFirst({
|
||||
where: eq(schema.arcadeSessions.userId, testGuestId),
|
||||
})
|
||||
|
||||
expect(activeSession).toBeDefined()
|
||||
expect(activeSession?.currentGame).toBe('matching')
|
||||
|
||||
// Clean up session
|
||||
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testGuestId))
|
||||
})
|
||||
|
||||
it('allows isActive changes when user has no active arcade session', async () => {
|
||||
// Create a player
|
||||
const [player] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: 'Test Player',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
isActive: false,
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Verify no active session
|
||||
const activeSession = await db.query.arcadeSessions.findFirst({
|
||||
where: eq(schema.arcadeSessions.userId, testGuestId),
|
||||
})
|
||||
|
||||
expect(activeSession).toBeUndefined()
|
||||
|
||||
// Should be able to update isActive
|
||||
const [updated] = await db
|
||||
.update(schema.players)
|
||||
.set({ isActive: true })
|
||||
.where(eq(schema.players.id, player.id))
|
||||
.returning()
|
||||
|
||||
expect(updated.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('allows non-isActive changes even with active session', async () => {
|
||||
// Create a player
|
||||
const [player] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: 'Test Player',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
isActive: true,
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Create an active arcade session
|
||||
const now = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
userId: testGuestId,
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
gameState: JSON.stringify({}),
|
||||
activePlayers: JSON.stringify([player.id]),
|
||||
startedAt: now,
|
||||
lastActivityAt: now,
|
||||
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
|
||||
version: 1,
|
||||
})
|
||||
|
||||
try {
|
||||
// Should be able to update name, emoji, color (non-isActive fields)
|
||||
const [updated] = await db
|
||||
.update(schema.players)
|
||||
.set({
|
||||
name: 'Updated Name',
|
||||
emoji: '🎉',
|
||||
color: '#ff0000',
|
||||
})
|
||||
.where(eq(schema.players.id, player.id))
|
||||
.returning()
|
||||
|
||||
expect(updated.name).toBe('Updated Name')
|
||||
expect(updated.emoji).toBe('🎉')
|
||||
expect(updated.color).toBe('#ff0000')
|
||||
expect(updated.isActive).toBe(true) // Unchanged
|
||||
} finally {
|
||||
// Clean up session
|
||||
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testGuestId))
|
||||
}
|
||||
})
|
||||
|
||||
it('session ends, then isActive changes are allowed again', async () => {
|
||||
// Create a player
|
||||
const [player] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: 'Test Player',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
isActive: true,
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Create an active arcade session
|
||||
const now = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
userId: testGuestId,
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
gameState: JSON.stringify({}),
|
||||
activePlayers: JSON.stringify([player.id]),
|
||||
startedAt: now,
|
||||
lastActivityAt: now,
|
||||
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
|
||||
version: 1,
|
||||
})
|
||||
|
||||
// Verify session exists
|
||||
let activeSession = await db.query.arcadeSessions.findFirst({
|
||||
where: eq(schema.arcadeSessions.userId, testGuestId),
|
||||
})
|
||||
expect(activeSession).toBeDefined()
|
||||
|
||||
// End the session
|
||||
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testGuestId))
|
||||
|
||||
// Verify session is gone
|
||||
activeSession = await db.query.arcadeSessions.findFirst({
|
||||
where: eq(schema.arcadeSessions.userId, testGuestId),
|
||||
})
|
||||
expect(activeSession).toBeUndefined()
|
||||
|
||||
// Now should be able to update isActive
|
||||
const [updated] = await db
|
||||
.update(schema.players)
|
||||
.set({ isActive: false })
|
||||
.where(eq(schema.players.id, player.id))
|
||||
.returning()
|
||||
|
||||
expect(updated.isActive).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Security: userId injection prevention', () => {
|
||||
it('rejects creating player with non-existent userId', async () => {
|
||||
// Attempt to create a player with a fake userId
|
||||
await expect(async () => {
|
||||
await db.insert(schema.players).values({
|
||||
userId: 'HACKER_ID_NON_EXISTENT',
|
||||
name: 'Hacker Player',
|
||||
emoji: '🦹',
|
||||
color: '#ff0000',
|
||||
})
|
||||
}).rejects.toThrow(/FOREIGN KEY constraint failed/)
|
||||
})
|
||||
|
||||
it("prevents modifying another user's player via userId injection (DB layer alone is insufficient)", async () => {
|
||||
// Create victim user and their player
|
||||
const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const [victimUser] = await db
|
||||
.insert(schema.users)
|
||||
.values({ guestId: victimGuestId })
|
||||
.returning()
|
||||
|
||||
try {
|
||||
// Create attacker's player
|
||||
const [attackerPlayer] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: 'Attacker Player',
|
||||
emoji: '😈',
|
||||
color: '#ff0000',
|
||||
})
|
||||
.returning()
|
||||
|
||||
const [_victimPlayer] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: victimUser.id,
|
||||
name: 'Victim Player',
|
||||
emoji: '👤',
|
||||
color: '#00ff00',
|
||||
isActive: true,
|
||||
})
|
||||
.returning()
|
||||
|
||||
// IMPORTANT: At the DB level, changing userId to another valid userId SUCCEEDS
|
||||
// This is why API layer MUST filter userId from request body!
|
||||
const [updated] = await db
|
||||
.update(schema.players)
|
||||
.set({
|
||||
userId: victimUser.id, // This WILL succeed at DB level!
|
||||
name: 'Stolen Player',
|
||||
})
|
||||
.where(eq(schema.players.id, attackerPlayer.id))
|
||||
.returning()
|
||||
|
||||
// The update succeeded - the player now belongs to victim!
|
||||
expect(updated.userId).toBe(victimUser.id)
|
||||
expect(updated.name).toBe('Stolen Player')
|
||||
|
||||
// This test demonstrates why the API route MUST:
|
||||
// 1. Strip userId from request body
|
||||
// 2. Derive userId from session cookie
|
||||
// 3. Use WHERE clause to scope updates to current user's data only
|
||||
} finally {
|
||||
await db.delete(schema.users).where(eq(schema.users.id, victimUser.id))
|
||||
}
|
||||
})
|
||||
|
||||
it('ensures players are isolated per user', async () => {
|
||||
// Create another user
|
||||
const user2GuestId = `user2-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const [user2] = await db.insert(schema.users).values({ guestId: user2GuestId }).returning()
|
||||
|
||||
try {
|
||||
// Create players for both users
|
||||
await db.insert(schema.players).values({
|
||||
userId: testUserId,
|
||||
name: 'User 1 Player',
|
||||
emoji: '🎮',
|
||||
color: '#0000ff',
|
||||
})
|
||||
|
||||
await db.insert(schema.players).values({
|
||||
userId: user2.id,
|
||||
name: 'User 2 Player',
|
||||
emoji: '🎯',
|
||||
color: '#ff00ff',
|
||||
})
|
||||
|
||||
// Verify each user only sees their own players
|
||||
const user1Players = await db.query.players.findMany({
|
||||
where: eq(schema.players.userId, testUserId),
|
||||
})
|
||||
const user2Players = await db.query.players.findMany({
|
||||
where: eq(schema.players.userId, user2.id),
|
||||
})
|
||||
|
||||
expect(user1Players).toHaveLength(1)
|
||||
expect(user1Players[0].name).toBe('User 1 Player')
|
||||
|
||||
expect(user2Players).toHaveLength(1)
|
||||
expect(user2Players[0].name).toBe('User 2 Player')
|
||||
} finally {
|
||||
await db.delete(schema.users).where(eq(schema.users.id, user2.id))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
186
apps/web/__tests__/api-user-stats.e2e.test.ts
Normal file
186
apps/web/__tests__/api-user-stats.e2e.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { db, schema } from '../src/db'
|
||||
|
||||
/**
|
||||
* API User Stats E2E Tests
|
||||
*
|
||||
* These tests verify the user-stats API endpoints work correctly.
|
||||
*/
|
||||
|
||||
describe('User Stats API', () => {
|
||||
let testUserId: string
|
||||
let testGuestId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a test user with unique guest ID
|
||||
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning()
|
||||
testUserId = user.id
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up: delete test user (cascade deletes stats)
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
||||
})
|
||||
|
||||
describe('GET /api/user-stats', () => {
|
||||
it('creates stats with defaults if none exist', async () => {
|
||||
const [stats] = await db.insert(schema.userStats).values({ userId: testUserId }).returning()
|
||||
|
||||
expect(stats).toBeDefined()
|
||||
expect(stats.gamesPlayed).toBe(0)
|
||||
expect(stats.totalWins).toBe(0)
|
||||
expect(stats.favoriteGameType).toBeNull()
|
||||
expect(stats.bestTime).toBeNull()
|
||||
expect(stats.highestAccuracy).toBe(0)
|
||||
})
|
||||
|
||||
it('returns existing stats', async () => {
|
||||
// Create stats
|
||||
await db.insert(schema.userStats).values({
|
||||
userId: testUserId,
|
||||
gamesPlayed: 10,
|
||||
totalWins: 7,
|
||||
favoriteGameType: 'abacus-numeral',
|
||||
bestTime: 5000,
|
||||
highestAccuracy: 0.95,
|
||||
})
|
||||
|
||||
const stats = await db.query.userStats.findFirst({
|
||||
where: eq(schema.userStats.userId, testUserId),
|
||||
})
|
||||
|
||||
expect(stats).toBeDefined()
|
||||
expect(stats?.gamesPlayed).toBe(10)
|
||||
expect(stats?.totalWins).toBe(7)
|
||||
expect(stats?.favoriteGameType).toBe('abacus-numeral')
|
||||
expect(stats?.bestTime).toBe(5000)
|
||||
expect(stats?.highestAccuracy).toBe(0.95)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/user-stats', () => {
|
||||
it('creates new stats if none exist', async () => {
|
||||
const [stats] = await db
|
||||
.insert(schema.userStats)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
gamesPlayed: 1,
|
||||
totalWins: 1,
|
||||
})
|
||||
.returning()
|
||||
|
||||
expect(stats).toBeDefined()
|
||||
expect(stats.gamesPlayed).toBe(1)
|
||||
expect(stats.totalWins).toBe(1)
|
||||
})
|
||||
|
||||
it('updates existing stats', async () => {
|
||||
// Create initial stats
|
||||
await db.insert(schema.userStats).values({
|
||||
userId: testUserId,
|
||||
gamesPlayed: 5,
|
||||
totalWins: 3,
|
||||
})
|
||||
|
||||
// Update
|
||||
const [updated] = await db
|
||||
.update(schema.userStats)
|
||||
.set({
|
||||
gamesPlayed: 6,
|
||||
totalWins: 4,
|
||||
favoriteGameType: 'complement-pairs',
|
||||
})
|
||||
.where(eq(schema.userStats.userId, testUserId))
|
||||
.returning()
|
||||
|
||||
expect(updated.gamesPlayed).toBe(6)
|
||||
expect(updated.totalWins).toBe(4)
|
||||
expect(updated.favoriteGameType).toBe('complement-pairs')
|
||||
})
|
||||
|
||||
it('updates only provided fields', async () => {
|
||||
// Create initial stats
|
||||
await db.insert(schema.userStats).values({
|
||||
userId: testUserId,
|
||||
gamesPlayed: 10,
|
||||
totalWins: 5,
|
||||
bestTime: 3000,
|
||||
})
|
||||
|
||||
// Update only gamesPlayed
|
||||
const [updated] = await db
|
||||
.update(schema.userStats)
|
||||
.set({ gamesPlayed: 11 })
|
||||
.where(eq(schema.userStats.userId, testUserId))
|
||||
.returning()
|
||||
|
||||
expect(updated.gamesPlayed).toBe(11)
|
||||
expect(updated.totalWins).toBe(5) // unchanged
|
||||
expect(updated.bestTime).toBe(3000) // unchanged
|
||||
})
|
||||
|
||||
it('allows setting favoriteGameType', async () => {
|
||||
await db.insert(schema.userStats).values({
|
||||
userId: testUserId,
|
||||
})
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.userStats)
|
||||
.set({ favoriteGameType: 'abacus-numeral' })
|
||||
.where(eq(schema.userStats.userId, testUserId))
|
||||
.returning()
|
||||
|
||||
expect(updated.favoriteGameType).toBe('abacus-numeral')
|
||||
})
|
||||
|
||||
it('allows setting bestTime and highestAccuracy', async () => {
|
||||
await db.insert(schema.userStats).values({
|
||||
userId: testUserId,
|
||||
})
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.userStats)
|
||||
.set({
|
||||
bestTime: 2500,
|
||||
highestAccuracy: 0.98,
|
||||
})
|
||||
.where(eq(schema.userStats.userId, testUserId))
|
||||
.returning()
|
||||
|
||||
expect(updated.bestTime).toBe(2500)
|
||||
expect(updated.highestAccuracy).toBe(0.98)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cascade delete behavior', () => {
|
||||
it('deletes stats when user is deleted', async () => {
|
||||
// Create stats
|
||||
await db.insert(schema.userStats).values({
|
||||
userId: testUserId,
|
||||
gamesPlayed: 10,
|
||||
totalWins: 5,
|
||||
})
|
||||
|
||||
// Verify stats exist
|
||||
let stats = await db.query.userStats.findFirst({
|
||||
where: eq(schema.userStats.userId, testUserId),
|
||||
})
|
||||
expect(stats).toBeDefined()
|
||||
|
||||
// Delete user
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
||||
|
||||
// Verify stats are gone
|
||||
stats = await db.query.userStats.findFirst({
|
||||
where: eq(schema.userStats.userId, testUserId),
|
||||
})
|
||||
expect(stats).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
136
apps/web/__tests__/middleware.e2e.test.ts
Normal file
136
apps/web/__tests__/middleware.e2e.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { GUEST_COOKIE_NAME, verifyGuestToken } from '../src/lib/guest-token'
|
||||
import { middleware } from '../src/middleware'
|
||||
|
||||
describe('Middleware E2E', () => {
|
||||
beforeEach(() => {
|
||||
process.env.AUTH_SECRET = 'test-secret-for-middleware'
|
||||
})
|
||||
|
||||
it('sets guest cookie on first request', async () => {
|
||||
const req = new NextRequest('http://localhost:3000/')
|
||||
const res = await middleware(req)
|
||||
|
||||
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
|
||||
|
||||
expect(cookie).toBeDefined()
|
||||
expect(cookie?.value).toBeDefined()
|
||||
expect(cookie?.httpOnly).toBe(true)
|
||||
expect(cookie?.sameSite).toBe('lax')
|
||||
expect(cookie?.path).toBe('/')
|
||||
})
|
||||
|
||||
it('creates valid guest token', async () => {
|
||||
const req = new NextRequest('http://localhost:3000/')
|
||||
const res = await middleware(req)
|
||||
|
||||
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
|
||||
expect(cookie).toBeDefined()
|
||||
|
||||
// Verify the token is valid
|
||||
const verified = await verifyGuestToken(cookie!.value)
|
||||
expect(verified.sid).toBeDefined()
|
||||
expect(typeof verified.sid).toBe('string')
|
||||
})
|
||||
|
||||
it('preserves existing guest cookie', async () => {
|
||||
// First request - creates cookie
|
||||
const req1 = new NextRequest('http://localhost:3000/')
|
||||
const res1 = await middleware(req1)
|
||||
const cookie1 = res1.cookies.get(GUEST_COOKIE_NAME)
|
||||
|
||||
// Second request - with existing cookie
|
||||
const req2 = new NextRequest('http://localhost:3000/')
|
||||
req2.cookies.set(GUEST_COOKIE_NAME, cookie1!.value)
|
||||
const res2 = await middleware(req2)
|
||||
|
||||
const cookie2 = res2.cookies.get(GUEST_COOKIE_NAME)
|
||||
|
||||
// Cookie should not be set again (preserves existing)
|
||||
expect(cookie2).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sets different guest IDs for different visitors', async () => {
|
||||
const req1 = new NextRequest('http://localhost:3000/')
|
||||
const req2 = new NextRequest('http://localhost:3000/')
|
||||
|
||||
const res1 = await middleware(req1)
|
||||
const res2 = await middleware(req2)
|
||||
|
||||
const cookie1 = res1.cookies.get(GUEST_COOKIE_NAME)
|
||||
const cookie2 = res2.cookies.get(GUEST_COOKIE_NAME)
|
||||
|
||||
const verified1 = await verifyGuestToken(cookie1!.value)
|
||||
const verified2 = await verifyGuestToken(cookie2!.value)
|
||||
|
||||
// Different visitors get different guest IDs
|
||||
expect(verified1.sid).not.toBe(verified2.sid)
|
||||
})
|
||||
|
||||
it('sets secure flag in production', async () => {
|
||||
const originalEnv = process.env.NODE_ENV
|
||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
||||
value: 'production',
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
||||
value: originalEnv,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not set secure flag in development', async () => {
|
||||
const originalEnv = process.env.NODE_ENV
|
||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
||||
value: 'development',
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
||||
value: originalEnv,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
203
apps/web/__tests__/orphaned-session.e2e.test.ts
Normal file
203
apps/web/__tests__/orphaned-session.e2e.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db, schema } from '../src/db'
|
||||
import { createArcadeSession, getArcadeSession } from '../src/lib/arcade/session-manager'
|
||||
import { cleanupExpiredRooms, createRoom } from '../src/lib/arcade/room-manager'
|
||||
|
||||
/**
|
||||
* E2E Test: Orphaned Session After Room TTL Deletion
|
||||
*
|
||||
* This test simulates the exact scenario reported by the user:
|
||||
* 1. User creates a game session in a room
|
||||
* 2. Room expires via TTL cleanup
|
||||
* 3. User navigates to /arcade
|
||||
* 4. System should NOT redirect to the orphaned game
|
||||
* 5. User should see the arcade lobby normally
|
||||
*/
|
||||
describe('E2E: Orphaned Session Cleanup on Navigation', () => {
|
||||
const testUserId = 'e2e-user-id'
|
||||
const testGuestId = 'e2e-guest-id'
|
||||
let testRoomId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test user (simulating new or returning visitor)
|
||||
await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
id: testUserId,
|
||||
guestId: testGuestId,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test data
|
||||
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testUserId))
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
||||
if (testRoomId) {
|
||||
try {
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, testRoomId))
|
||||
} catch {
|
||||
// Room may already be deleted
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('should not redirect user to orphaned game after room TTL cleanup', async () => {
|
||||
// === SETUP PHASE ===
|
||||
// User creates or joins a room
|
||||
const room = await createRoom({
|
||||
name: 'My Game Room',
|
||||
createdBy: testGuestId,
|
||||
creatorName: 'Test Player',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6, gameType: 'abacus-numeral', turnTimer: 30 },
|
||||
ttlMinutes: 1, // Short TTL for testing
|
||||
})
|
||||
testRoomId = room.id
|
||||
|
||||
// User starts a game session
|
||||
const session = await createArcadeSession({
|
||||
userId: testGuestId,
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: {
|
||||
gamePhase: 'playing',
|
||||
cards: [],
|
||||
gameCards: [],
|
||||
flippedCards: [],
|
||||
matchedPairs: 0,
|
||||
totalPairs: 6,
|
||||
currentPlayer: 'player-1',
|
||||
difficulty: 6,
|
||||
gameType: 'abacus-numeral',
|
||||
turnTimer: 30,
|
||||
},
|
||||
activePlayers: ['player-1'],
|
||||
roomId: room.id,
|
||||
})
|
||||
|
||||
// Verify session was created
|
||||
expect(session).toBeDefined()
|
||||
expect(session.roomId).toBe(room.id)
|
||||
|
||||
// === TTL EXPIRATION PHASE ===
|
||||
// Simulate time passing - room's TTL expires
|
||||
// Set lastActivity to past so cleanup detects it
|
||||
await db
|
||||
.update(schema.arcadeRooms)
|
||||
.set({
|
||||
lastActivity: new Date(Date.now() - 2 * 60 * 1000), // 2 minutes ago
|
||||
})
|
||||
.where(eq(schema.arcadeRooms.id, room.id))
|
||||
|
||||
// Run cleanup (simulating background cleanup job)
|
||||
const deletedCount = await cleanupExpiredRooms()
|
||||
expect(deletedCount).toBeGreaterThan(0) // Room should be deleted
|
||||
|
||||
// === USER NAVIGATION PHASE ===
|
||||
// User navigates to /arcade (arcade lobby)
|
||||
// The useArcadeRedirect hook calls getArcadeSession to check for active session
|
||||
const activeSession = await getArcadeSession(testGuestId)
|
||||
|
||||
// === ASSERTION PHASE ===
|
||||
// Expected behavior: NO active session returned
|
||||
// This prevents redirect to /arcade/matching which would be broken
|
||||
expect(activeSession).toBeUndefined()
|
||||
|
||||
// Verify the orphaned session was cleaned up from database
|
||||
const [orphanedSessionCheck] = await db
|
||||
.select()
|
||||
.from(schema.arcadeSessions)
|
||||
.where(eq(schema.arcadeSessions.userId, testUserId))
|
||||
.limit(1)
|
||||
|
||||
expect(orphanedSessionCheck).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should allow user to start new game after orphaned session cleanup', async () => {
|
||||
// === SETUP: Create and orphan a session ===
|
||||
const oldRoom = await createRoom({
|
||||
name: 'Old Room',
|
||||
createdBy: testGuestId,
|
||||
creatorName: 'Test Player',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6 },
|
||||
ttlMinutes: 1,
|
||||
})
|
||||
|
||||
await createArcadeSession({
|
||||
userId: testGuestId,
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: { gamePhase: 'setup' },
|
||||
activePlayers: ['player-1'],
|
||||
roomId: oldRoom.id,
|
||||
})
|
||||
|
||||
// Delete room (TTL cleanup)
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, oldRoom.id))
|
||||
|
||||
// === ACTION: User tries to access arcade ===
|
||||
const orphanedSession = await getArcadeSession(testGuestId)
|
||||
expect(orphanedSession).toBeUndefined() // Orphan cleaned up
|
||||
|
||||
// === ACTION: User creates new room and session ===
|
||||
const newRoom = await createRoom({
|
||||
name: 'New Room',
|
||||
createdBy: testGuestId,
|
||||
creatorName: 'Test Player',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 8 },
|
||||
ttlMinutes: 60,
|
||||
})
|
||||
testRoomId = newRoom.id
|
||||
|
||||
const newSession = await createArcadeSession({
|
||||
userId: testGuestId,
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: { gamePhase: 'setup' },
|
||||
activePlayers: ['player-1', 'player-2'],
|
||||
roomId: newRoom.id,
|
||||
})
|
||||
|
||||
// === ASSERTION: New session works correctly ===
|
||||
expect(newSession).toBeDefined()
|
||||
expect(newSession.roomId).toBe(newRoom.id)
|
||||
|
||||
const activeSession = await getArcadeSession(testGuestId)
|
||||
expect(activeSession).toBeDefined()
|
||||
expect(activeSession?.roomId).toBe(newRoom.id)
|
||||
})
|
||||
|
||||
it('should handle race condition: getArcadeSession called while room is being deleted', async () => {
|
||||
// Create room and session
|
||||
const room = await createRoom({
|
||||
name: 'Race Condition Room',
|
||||
createdBy: testGuestId,
|
||||
creatorName: 'Test Player',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6 },
|
||||
ttlMinutes: 60,
|
||||
})
|
||||
testRoomId = room.id
|
||||
|
||||
await createArcadeSession({
|
||||
userId: testGuestId,
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: { gamePhase: 'setup' },
|
||||
activePlayers: ['player-1'],
|
||||
roomId: room.id,
|
||||
})
|
||||
|
||||
// Simulate race: delete room while getArcadeSession is checking
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room.id))
|
||||
|
||||
// Should gracefully handle and return undefined
|
||||
const result = await getArcadeSession(testGuestId)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
371
apps/web/__tests__/room-realtime-updates.e2e.test.ts
Normal file
371
apps/web/__tests__/room-realtime-updates.e2e.test.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { createServer } from 'http'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { io as ioClient, type Socket } from 'socket.io-client'
|
||||
import { afterEach, beforeEach, describe, expect, it, afterAll, beforeAll } from 'vitest'
|
||||
import { db, schema } from '../src/db'
|
||||
import { createRoom } from '../src/lib/arcade/room-manager'
|
||||
import { addRoomMember } from '../src/lib/arcade/room-membership'
|
||||
import { initializeSocketServer } from '../socket-server'
|
||||
import type { Server as SocketIOServerType } from 'socket.io'
|
||||
|
||||
/**
|
||||
* Real-time Room Updates E2E Tests
|
||||
*
|
||||
* Tests that socket broadcasts work correctly when users join/leave rooms.
|
||||
* Simulates multiple connected users and verifies they receive real-time updates.
|
||||
*/
|
||||
|
||||
describe('Room Real-time Updates', () => {
|
||||
let testUserId1: string
|
||||
let testUserId2: string
|
||||
let testGuestId1: string
|
||||
let testGuestId2: string
|
||||
let testRoomId: string
|
||||
let socket1: Socket
|
||||
let httpServer: any
|
||||
let io: SocketIOServerType
|
||||
let serverPort: number
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create HTTP server and initialize Socket.IO for testing
|
||||
httpServer = createServer()
|
||||
io = initializeSocketServer(httpServer)
|
||||
|
||||
// Find an available port
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.listen(0, () => {
|
||||
serverPort = (httpServer.address() as any).port
|
||||
console.log(`Test socket server listening on port ${serverPort}`)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
// Close all socket connections
|
||||
if (io) {
|
||||
io.close()
|
||||
}
|
||||
if (httpServer) {
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.close(() => resolve())
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test users
|
||||
testGuestId1 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
testGuestId2 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
const [user1] = await db.insert(schema.users).values({ guestId: testGuestId1 }).returning()
|
||||
const [user2] = await db.insert(schema.users).values({ guestId: testGuestId2 }).returning()
|
||||
|
||||
testUserId1 = user1.id
|
||||
testUserId2 = user2.id
|
||||
|
||||
// Create a test room
|
||||
const room = await createRoom({
|
||||
name: 'Realtime Test Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: 'User 1',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6 },
|
||||
ttlMinutes: 60,
|
||||
})
|
||||
testRoomId = room.id
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Disconnect sockets
|
||||
if (socket1?.connected) {
|
||||
socket1.disconnect()
|
||||
}
|
||||
|
||||
// Clean up room members
|
||||
await db.delete(schema.roomMembers).where(eq(schema.roomMembers.roomId, testRoomId))
|
||||
|
||||
// Clean up rooms
|
||||
if (testRoomId) {
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, testRoomId))
|
||||
}
|
||||
|
||||
// Clean up users
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId1))
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId2))
|
||||
})
|
||||
|
||||
it('should broadcast member-joined when a user joins via API', async () => {
|
||||
// User 1 joins the room via API first (this is what happens when they click "Join Room")
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId1,
|
||||
displayName: 'User 1',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// User 1 connects to socket
|
||||
socket1 = ioClient(`http://localhost:${serverPort}`, {
|
||||
path: '/api/socket',
|
||||
transports: ['websocket'],
|
||||
})
|
||||
|
||||
// Wait for socket to connect
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket1.on('connect', () => resolve())
|
||||
socket1.on('connect_error', (err) => reject(err))
|
||||
setTimeout(() => reject(new Error('Connection timeout')), 2000)
|
||||
})
|
||||
|
||||
// Small delay to ensure event handlers are set up
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
// Set up listener for room-joined BEFORE emitting
|
||||
const roomJoinedPromise = new Promise<void>((resolve, reject) => {
|
||||
socket1.on('room-joined', () => resolve())
|
||||
socket1.on('room-error', (err) => reject(new Error(err.error)))
|
||||
setTimeout(() => reject(new Error('Room-joined timeout')), 3000)
|
||||
})
|
||||
|
||||
// Now emit the join-room event
|
||||
socket1.emit('join-room', { roomId: testRoomId, userId: testGuestId1 })
|
||||
|
||||
// Wait for confirmation
|
||||
await roomJoinedPromise
|
||||
|
||||
// Set up listener for member-joined event BEFORE User 2 joins
|
||||
const memberJoinedPromise = new Promise<any>((resolve, reject) => {
|
||||
socket1.on('member-joined', (data) => {
|
||||
resolve(data)
|
||||
})
|
||||
setTimeout(() => reject(new Error('Timeout waiting for member-joined event')), 3000)
|
||||
})
|
||||
|
||||
// User 2 joins the room via addRoomMember
|
||||
const { member: newMember } = await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
displayName: 'User 2',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// Manually trigger the broadcast (this is what the API route SHOULD do)
|
||||
const { getRoomMembers } = await import('../src/lib/arcade/room-membership')
|
||||
const { getRoomActivePlayers } = await import('../src/lib/arcade/player-manager')
|
||||
|
||||
const members = await getRoomMembers(testRoomId)
|
||||
const memberPlayers = await getRoomActivePlayers(testRoomId)
|
||||
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
io.to(`room:${testRoomId}`).emit('member-joined', {
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
// Wait for the socket broadcast with timeout
|
||||
const data = await memberJoinedPromise
|
||||
|
||||
// Verify the broadcast data
|
||||
expect(data).toBeDefined()
|
||||
expect(data.roomId).toBe(testRoomId)
|
||||
expect(data.userId).toBe(testGuestId2)
|
||||
expect(data.members).toBeDefined()
|
||||
expect(Array.isArray(data.members)).toBe(true)
|
||||
|
||||
// Verify both users are in the members list
|
||||
const memberUserIds = data.members.map((m: any) => m.userId)
|
||||
expect(memberUserIds).toContain(testGuestId1)
|
||||
expect(memberUserIds).toContain(testGuestId2)
|
||||
|
||||
// Verify the new member details
|
||||
const addedMember = data.members.find((m: any) => m.userId === testGuestId2)
|
||||
expect(addedMember).toBeDefined()
|
||||
expect(addedMember.displayName).toBe('User 2')
|
||||
expect(addedMember.roomId).toBe(testRoomId)
|
||||
})
|
||||
|
||||
it('should broadcast member-left when a user leaves via API', async () => {
|
||||
// User 1 joins the room first
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId1,
|
||||
displayName: 'User 1',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// User 2 joins the room
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
displayName: 'User 2',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// User 1 connects to socket
|
||||
socket1 = ioClient(`http://localhost:${serverPort}`, {
|
||||
path: '/api/socket',
|
||||
transports: ['websocket'],
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
socket1.on('connect', () => resolve())
|
||||
})
|
||||
|
||||
socket1.emit('join-room', { roomId: testRoomId, userId: testGuestId1 })
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
socket1.on('room-joined', () => resolve())
|
||||
})
|
||||
|
||||
// Set up listener for member-left event
|
||||
const memberLeftPromise = new Promise<any>((resolve) => {
|
||||
socket1.on('member-left', (data) => {
|
||||
resolve(data)
|
||||
})
|
||||
})
|
||||
|
||||
// User 2 leaves the room via API
|
||||
await db.delete(schema.roomMembers).where(eq(schema.roomMembers.userId, testGuestId2))
|
||||
|
||||
// Manually trigger the leave broadcast (simulating what the API does)
|
||||
const { getSocketIO } = await import('../src/lib/socket-io')
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
const { getRoomMembers } = await import('../src/lib/arcade/room-membership')
|
||||
const { getRoomActivePlayers } = await import('../src/lib/arcade/player-manager')
|
||||
|
||||
const members = await getRoomMembers(testRoomId)
|
||||
const memberPlayers = await getRoomActivePlayers(testRoomId)
|
||||
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
io.to(`room:${testRoomId}`).emit('member-left', {
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for the socket broadcast with timeout
|
||||
const data = await Promise.race([
|
||||
memberLeftPromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout waiting for member-left event')), 2000)
|
||||
),
|
||||
])
|
||||
|
||||
// Verify the broadcast data
|
||||
expect(data).toBeDefined()
|
||||
expect(data.roomId).toBe(testRoomId)
|
||||
expect(data.userId).toBe(testGuestId2)
|
||||
expect(data.members).toBeDefined()
|
||||
expect(Array.isArray(data.members)).toBe(true)
|
||||
|
||||
// Verify User 2 is no longer in the members list
|
||||
const memberUserIds = data.members.map((m: any) => m.userId)
|
||||
expect(memberUserIds).toContain(testGuestId1)
|
||||
expect(memberUserIds).not.toContain(testGuestId2)
|
||||
})
|
||||
|
||||
it('should update both members and players lists in member-joined broadcast', async () => {
|
||||
// Create an active player for User 2
|
||||
const [player2] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId2,
|
||||
name: 'Player 2',
|
||||
emoji: '🎮',
|
||||
color: '#3b82f6',
|
||||
isActive: true,
|
||||
})
|
||||
.returning()
|
||||
|
||||
// User 1 connects and joins room
|
||||
socket1 = ioClient(`http://localhost:${serverPort}`, {
|
||||
path: '/api/socket',
|
||||
transports: ['websocket'],
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
socket1.on('connect', () => resolve())
|
||||
})
|
||||
|
||||
socket1.emit('join-room', { roomId: testRoomId, userId: testGuestId1 })
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
socket1.on('room-joined', () => resolve())
|
||||
})
|
||||
|
||||
const memberJoinedPromise = new Promise<any>((resolve) => {
|
||||
socket1.on('member-joined', (data) => {
|
||||
resolve(data)
|
||||
})
|
||||
})
|
||||
|
||||
// User 2 joins via API
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
displayName: 'User 2',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// Manually trigger the broadcast (simulating what the API does)
|
||||
const { getRoomMembers: getRoomMembers3 } = await import('../src/lib/arcade/room-membership')
|
||||
const { getRoomActivePlayers: getRoomActivePlayers3 } = await import(
|
||||
'../src/lib/arcade/player-manager'
|
||||
)
|
||||
|
||||
const members2 = await getRoomMembers3(testRoomId)
|
||||
const memberPlayers2 = await getRoomActivePlayers3(testRoomId)
|
||||
|
||||
const memberPlayersObj2: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers2.entries()) {
|
||||
memberPlayersObj2[uid] = players
|
||||
}
|
||||
|
||||
io.to(`room:${testRoomId}`).emit('member-joined', {
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
members: members2,
|
||||
memberPlayers: memberPlayersObj2,
|
||||
})
|
||||
|
||||
const data = await Promise.race([
|
||||
memberJoinedPromise,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000)),
|
||||
])
|
||||
|
||||
// Verify members list is updated
|
||||
expect(data.members).toBeDefined()
|
||||
const memberUserIds = data.members.map((m: any) => m.userId)
|
||||
expect(memberUserIds).toContain(testGuestId2)
|
||||
|
||||
// Verify players list is updated
|
||||
expect(data.memberPlayers).toBeDefined()
|
||||
expect(data.memberPlayers[testGuestId2]).toBeDefined()
|
||||
expect(Array.isArray(data.memberPlayers[testGuestId2])).toBe(true)
|
||||
|
||||
// User 2's players should include the active player we created
|
||||
const user2Players = data.memberPlayers[testGuestId2]
|
||||
expect(user2Players.length).toBeGreaterThan(0)
|
||||
expect(user2Players.some((p: any) => p.id === player2.id)).toBe(true)
|
||||
|
||||
// Clean up player
|
||||
await db.delete(schema.players).where(eq(schema.players.id, player2.id))
|
||||
})
|
||||
})
|
||||
69
apps/web/biome.jsonc
Normal file
69
apps/web/biome.jsonc
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"a11y": {
|
||||
"useButtonType": "off",
|
||||
"noSvgWithoutTitle": "off",
|
||||
"noLabelWithoutControl": "off",
|
||||
"noStaticElementInteractions": "off",
|
||||
"useKeyWithClickEvents": "off",
|
||||
"useSemanticElements": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noArrayIndexKey": "off",
|
||||
"noImplicitAnyLet": "off",
|
||||
"noAssignInExpressions": "off",
|
||||
"useIterableCallbackReturn": "off"
|
||||
},
|
||||
"style": {
|
||||
"useNodejsImportProtocol": "off",
|
||||
"noNonNullAssertion": "off",
|
||||
"noDescendingSpecificity": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "off",
|
||||
"noUnusedFunctionParameters": "off",
|
||||
"useUniqueElementIds": "off",
|
||||
"noChildrenProp": "off",
|
||||
"useExhaustiveDependencies": "off",
|
||||
"noInvalidUseBeforeDeclaration": "off",
|
||||
"useHookAtTopLevel": "off",
|
||||
"noNestedComponentDefinitions": "off",
|
||||
"noUnreachable": "off"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "off"
|
||||
},
|
||||
"performance": {
|
||||
"noAccumulatingSpread": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true
|
||||
},
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true,
|
||||
"defaultBranch": "main"
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"jsxQuoteStyle": "double",
|
||||
"semicolons": "asNeeded",
|
||||
"trailingCommas": "es5"
|
||||
}
|
||||
}
|
||||
}
|
||||
169
apps/web/docs/FIXES-APPLIED.md
Normal file
169
apps/web/docs/FIXES-APPLIED.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# User/Player/Room Member Inconsistencies - FIXED ✅
|
||||
|
||||
All critical inconsistencies between users, players, and room members have been resolved.
|
||||
|
||||
## Summary of Fixes
|
||||
|
||||
### 1. ✅ Backend - Player Fetching
|
||||
|
||||
**Created**: `src/lib/arcade/player-manager.ts`
|
||||
- `getActivePlayers(userId)` - Get a user's active players
|
||||
- `getRoomActivePlayers(roomId)` - Get all active players for all members in a room
|
||||
- `getRoomPlayerIds(roomId)` - Get flat list of all player IDs in a room
|
||||
- `validatePlayerInRoom(playerId, roomId)` - Validate player belongs to room member
|
||||
- `getPlayer(playerId)` - Get single player by ID
|
||||
- `getPlayers(playerIds[])` - Get multiple players by IDs
|
||||
|
||||
### 2. ✅ API Endpoints Updated
|
||||
|
||||
**`/api/arcade/rooms/:roomId/join` (POST)**
|
||||
```typescript
|
||||
// Now returns:
|
||||
{
|
||||
member: RoomMember,
|
||||
room: Room,
|
||||
activePlayers: Player[], // USER's active players
|
||||
alreadyMember: boolean
|
||||
}
|
||||
```
|
||||
|
||||
**`/api/arcade/rooms/:roomId` (GET)**
|
||||
```typescript
|
||||
// Now returns:
|
||||
{
|
||||
room: Room,
|
||||
members: RoomMember[],
|
||||
memberPlayers: Record<userId, Player[]>, // Map of all members' players
|
||||
canModerate: boolean
|
||||
}
|
||||
```
|
||||
|
||||
**`/api/arcade/rooms` (GET)**
|
||||
```typescript
|
||||
// Now returns:
|
||||
{
|
||||
rooms: Array<{
|
||||
...roomData,
|
||||
memberCount: number, // Number of users in room
|
||||
playerCount: number // Total players across all users
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
### 3. ✅ Socket Events Updated
|
||||
|
||||
**`join-room` event**
|
||||
```typescript
|
||||
// Server emits:
|
||||
socket.emit('room-joined', {
|
||||
room,
|
||||
members,
|
||||
onlineMembers,
|
||||
memberPlayers: Record<userId, Player[]>, // All members' players
|
||||
activePlayers: Player[] // This user's active players
|
||||
})
|
||||
|
||||
socket.to(`room:${roomId}`).emit('member-joined', {
|
||||
member,
|
||||
activePlayers: Player[], // New member's active players
|
||||
onlineMembers,
|
||||
memberPlayers: Record<userId, Player[]>
|
||||
})
|
||||
```
|
||||
|
||||
**`room-game-move` event**
|
||||
```typescript
|
||||
// Now validates:
|
||||
1. User is a room member (userId check)
|
||||
2. Player belongs to a room member (playerId validation)
|
||||
|
||||
// Rejects move if playerId doesn't belong to any room member
|
||||
```
|
||||
|
||||
### 4. ✅ Frontend UI Updated
|
||||
|
||||
**Room Lobby (`/arcade/rooms/[roomId]/page.tsx`)**
|
||||
|
||||
Before:
|
||||
```
|
||||
Member: Jane
|
||||
Status: Online
|
||||
```
|
||||
|
||||
After:
|
||||
```
|
||||
Member: Jane
|
||||
Status: Online
|
||||
Players: 👧 Alice, 👦 Bob
|
||||
```
|
||||
|
||||
**Room Browser (`/arcade/rooms/page.tsx`)**
|
||||
|
||||
Before:
|
||||
```
|
||||
Room: Math Masters
|
||||
Host: Jane | Game: matching | Status: Waiting
|
||||
```
|
||||
|
||||
After:
|
||||
```
|
||||
Room: Math Masters
|
||||
Host: Jane | Game: matching | 👥 3 members | 🎯 7 players | Status: Waiting
|
||||
```
|
||||
|
||||
## Key Changes Summary
|
||||
|
||||
| Component | Change |
|
||||
|-----------|--------|
|
||||
| **Helper Functions** | Created `player-manager.ts` with 6 new functions |
|
||||
| **Join Endpoint** | Now fetches and returns user's active players |
|
||||
| **Room Detail Endpoint** | Returns player map for all members |
|
||||
| **Rooms List Endpoint** | Returns member and player counts |
|
||||
| **Socket join-room** | Broadcasts active players to room |
|
||||
| **Socket room-game-move** | Validates player IDs belong to members |
|
||||
| **Room Lobby UI** | Shows each member's players |
|
||||
| **Room Browser UI** | Shows total member and player counts |
|
||||
|
||||
## Validation Rules Enforced
|
||||
|
||||
1. ✅ **Room membership tracked by USER ID** - Correct
|
||||
2. ✅ **Game participation tracked by PLAYER IDs** - Fixed
|
||||
3. ✅ **When user joins room, their active players join game** - Implemented
|
||||
4. ✅ **Socket moves validate player belongs to room** - Added validation
|
||||
5. ✅ **UI shows both members and their players** - Updated
|
||||
|
||||
## TypeScript Validation
|
||||
|
||||
All changes pass TypeScript validation with 0 errors in modified files:
|
||||
- `src/lib/arcade/player-manager.ts` ✅
|
||||
- `src/app/api/arcade/rooms/route.ts` ✅
|
||||
- `src/app/api/arcade/rooms/[roomId]/route.ts` ✅
|
||||
- `src/app/api/arcade/rooms/[roomId]/join/route.ts` ✅
|
||||
- `src/app/arcade/rooms/page.tsx` ✅
|
||||
- `src/app/arcade/rooms/[roomId]/page.tsx` ✅
|
||||
- `socket-server.ts` ✅
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Create a user with multiple active players
|
||||
- [ ] Join a room and verify all active players are shown
|
||||
- [ ] Have multiple users join the same room
|
||||
- [ ] Verify each user's players are displayed correctly
|
||||
- [ ] Verify room browser shows correct member/player counts
|
||||
- [ ] Start a game and verify all player IDs are collected
|
||||
- [ ] Test that invalid player IDs are rejected in game moves
|
||||
|
||||
## Documentation Created
|
||||
|
||||
1. `docs/terminology-user-player-room.md` - Complete explanation
|
||||
2. `.claude/terminology.md` - Quick reference for AI
|
||||
3. `docs/INCONSISTENCIES.md` - Analysis of issues (pre-fix)
|
||||
4. `docs/FIXES-APPLIED.md` - This document
|
||||
|
||||
## Next Steps (Phase 4)
|
||||
|
||||
The system is now ready for full multiplayer game integration:
|
||||
1. When room game starts, collect all player IDs from all members
|
||||
2. Set `arcade_sessions.activePlayers` to all room player IDs
|
||||
3. Game state tracks scores/moves by PLAYER ID
|
||||
4. Broadcast game updates to all room members
|
||||
189
apps/web/docs/INCONSISTENCIES.md
Normal file
189
apps/web/docs/INCONSISTENCIES.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Current Implementation vs Correct Design - Inconsistencies
|
||||
|
||||
## ❌ Inconsistency 1: Room Join Doesn't Fetch Active Players
|
||||
|
||||
**Current Code** (`/api/arcade/rooms/:roomId/join`):
|
||||
```typescript
|
||||
// Only creates room_member record with userId
|
||||
const member = await addRoomMember({
|
||||
roomId,
|
||||
userId: viewerId, // ✅ Correct: USER ID
|
||||
displayName,
|
||||
isCreator: false,
|
||||
})
|
||||
// ❌ Missing: Does not fetch user's active players
|
||||
```
|
||||
|
||||
**Should Be**:
|
||||
```typescript
|
||||
// 1. Create room member
|
||||
const member = await addRoomMember({ ... })
|
||||
|
||||
// 2. Fetch user's active players
|
||||
const activePlayers = await db.query.players.findMany({
|
||||
where: and(
|
||||
eq(players.userId, viewerId),
|
||||
eq(players.isActive, true)
|
||||
)
|
||||
})
|
||||
|
||||
// 3. Return both member and their active players
|
||||
return { member, activePlayers }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❌ Inconsistency 2: Socket Events Use USER ID Instead of PLAYER ID
|
||||
|
||||
**Current Code** (`socket-server.ts`):
|
||||
```typescript
|
||||
socket.on('join-room', ({ roomId, userId }) => {
|
||||
// Uses USER ID for presence
|
||||
await setMemberOnline(roomId, userId, true)
|
||||
socket.emit('room-joined', { members })
|
||||
})
|
||||
|
||||
socket.on('room-game-move', ({ roomId, userId, move }) => {
|
||||
// ❌ Wrong: Uses USER ID for game moves
|
||||
// Should use PLAYER ID
|
||||
})
|
||||
```
|
||||
|
||||
**Should Be**:
|
||||
```typescript
|
||||
socket.on('join-room', ({ roomId, userId }) => {
|
||||
// ✅ Correct: Use USER ID for room presence
|
||||
await setMemberOnline(roomId, userId, true)
|
||||
|
||||
// ❌ Missing: Should also fetch and broadcast active players
|
||||
const activePlayers = await getActivePlayers(userId)
|
||||
socket.emit('room-joined', { members, activePlayers })
|
||||
})
|
||||
|
||||
socket.on('room-game-move', ({ roomId, playerId, move }) => {
|
||||
// ✅ Correct: Use PLAYER ID for game actions
|
||||
// Validate that playerId belongs to a member in this room
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❌ Inconsistency 3: Room Member Interface Missing Player Association
|
||||
|
||||
**Current Code** (`room_members` table):
|
||||
```typescript
|
||||
interface RoomMember {
|
||||
id: string
|
||||
roomId: string
|
||||
userId: string // ✅ Correct: USER ID
|
||||
displayName: string
|
||||
isCreator: boolean
|
||||
// ❌ Missing: No link to user's players
|
||||
}
|
||||
```
|
||||
|
||||
**Need to Add** (runtime association, not DB schema):
|
||||
```typescript
|
||||
interface RoomMemberWithPlayers {
|
||||
member: RoomMember
|
||||
activePlayers: Player[] // The user's active players
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❌ Inconsistency 4: Client UI Shows Room Members, Not Players
|
||||
|
||||
**Current Code** (`/arcade/rooms/[roomId]/page.tsx`):
|
||||
```typescript
|
||||
// Shows room members (users)
|
||||
{members.map((member) => (
|
||||
<div key={member.id}>
|
||||
{member.displayName} {/* USER's display name */}
|
||||
</div>
|
||||
))}
|
||||
|
||||
// ❌ Missing: Should show the PLAYERS that will participate
|
||||
```
|
||||
|
||||
**Should Show**:
|
||||
```typescript
|
||||
{members.map((member) => (
|
||||
<div key={member.id}>
|
||||
<div>{member.displayName} (Room Member)</div>
|
||||
<div>Players:
|
||||
{member.activePlayers.map(player => (
|
||||
<span key={player.id}>{player.emoji} {player.name}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary of Required Changes
|
||||
|
||||
### Phase 1: Backend - Player Fetching
|
||||
1. ✅ `room_members` table correctly uses USER ID (no change needed)
|
||||
2. ❌ `/api/arcade/rooms/:roomId/join` - Fetch and return active players
|
||||
3. ❌ `/api/arcade/rooms/:roomId` GET - Include active players in response
|
||||
4. ❌ Create helper: `getActivePlayers(userId) => Player[]`
|
||||
|
||||
### Phase 2: Socket Layer - Player Association
|
||||
1. ❌ `join-room` event - Broadcast active players to room
|
||||
2. ❌ `room-game-move` event - Accept PLAYER ID, not USER ID
|
||||
3. ❌ Validate PLAYER ID belongs to a room member
|
||||
|
||||
### Phase 3: Frontend - Player Display
|
||||
1. ❌ Room lobby - Show each member's active players
|
||||
2. ❌ Game setup - Use PLAYER IDs for `activePlayers` array
|
||||
3. ❌ Move/action events - Send PLAYER ID
|
||||
|
||||
### Phase 4: Game Integration
|
||||
1. ❌ When room game starts, collect all PLAYER IDs from all members
|
||||
2. ❌ Arcade session `activePlayers` should contain all room PLAYER IDs
|
||||
3. ❌ Game state tracks scores/moves by PLAYER ID, not USER ID
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### Scenario 1: Single Player Per User
|
||||
```
|
||||
USER Jane (guest_123)
|
||||
└─ PLAYER Alice (active)
|
||||
|
||||
Joins room → Room shows "Jane: Alice 👧"
|
||||
Game starts → activePlayers: ["alice_id"]
|
||||
```
|
||||
|
||||
### Scenario 2: Multiple Players Per User
|
||||
```
|
||||
USER Jane (guest_123)
|
||||
├─ PLAYER Alice (active)
|
||||
└─ PLAYER Bob (active)
|
||||
|
||||
Joins room → Room shows "Jane: Alice 👧, Bob 👦"
|
||||
Game starts → activePlayers: ["alice_id", "bob_id"]
|
||||
```
|
||||
|
||||
### Scenario 3: Multi-User Room
|
||||
```
|
||||
USER Jane
|
||||
└─ PLAYER Alice, Bob (active)
|
||||
|
||||
USER Mark
|
||||
└─ PLAYER Mario (active)
|
||||
|
||||
USER Sara
|
||||
└─ PLAYER Luna, Nova, Star (active)
|
||||
|
||||
Room shows:
|
||||
- Jane: Alice 👧, Bob 👦
|
||||
- Mark: Mario 🍄
|
||||
- Sara: Luna 🌙, Nova ✨, Star ⭐
|
||||
|
||||
Game starts → activePlayers: [alice, bob, mario, luna, nova, star]
|
||||
Total: 6 players across 3 users
|
||||
```
|
||||
490
apps/web/docs/MULTIPLAYER_SYNC_ARCHITECTURE.md
Normal file
490
apps/web/docs/MULTIPLAYER_SYNC_ARCHITECTURE.md
Normal file
@@ -0,0 +1,490 @@
|
||||
# Multiplayer Synchronization Architecture
|
||||
|
||||
## Current State: Single-User Multi-Tab Sync
|
||||
|
||||
### How it Works
|
||||
|
||||
**Client-Side Flow:**
|
||||
|
||||
1. User opens game in Tab A and Tab B
|
||||
2. Both tabs create WebSocket connections via `useArcadeSocket()`
|
||||
3. Both emit `join-arcade-session` with `userId`
|
||||
4. Server adds both sockets to `arcade:${userId}` room
|
||||
|
||||
**When User Makes a Move (from Tab A):**
|
||||
|
||||
```typescript
|
||||
// Client (Tab A)
|
||||
sendMove({ type: 'FLIP_CARD', playerId: 'player-1', data: { cardId: 'card-5' } })
|
||||
|
||||
// Optimistic update applied locally
|
||||
state = applyMoveOptimistically(state, move)
|
||||
|
||||
// Socket emits to server
|
||||
socket.emit('game-move', { userId, move })
|
||||
```
|
||||
|
||||
**Server Processing:**
|
||||
|
||||
```typescript
|
||||
// socket-server.ts line 71
|
||||
socket.on('game-move', async (data) => {
|
||||
// Validate move
|
||||
const result = await applyGameMove(data.userId, data.move)
|
||||
|
||||
if (result.success) {
|
||||
// ✅ Broadcast to ALL tabs of this user
|
||||
io.to(`arcade:${data.userId}`).emit('move-accepted', {
|
||||
gameState: result.session.gameState,
|
||||
version: result.session.version,
|
||||
move: data.move
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Both Tabs Receive Update:**
|
||||
|
||||
```typescript
|
||||
// Client (Tab A and Tab B)
|
||||
socket.on('move-accepted', (data) => {
|
||||
// Update server state
|
||||
optimistic.handleMoveAccepted(data.gameState, data.version, data.move)
|
||||
|
||||
// Tab A: Remove from pending queue (was optimistic)
|
||||
// Tab B: Just sync with server state (wasn't expecting it)
|
||||
})
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **`useOptimisticGameState`** - Manages optimistic updates
|
||||
- Keeps `serverState` (last confirmed by server)
|
||||
- Keeps `pendingMoves[]` (not yet confirmed)
|
||||
- Current state = serverState + all pending moves applied
|
||||
|
||||
2. **`useArcadeSession`** - Combines socket + optimistic state
|
||||
- Connects socket
|
||||
- Applies moves optimistically
|
||||
- Sends moves to server
|
||||
- Handles server responses
|
||||
|
||||
3. **Socket Rooms** - Server-side broadcast channels
|
||||
- `arcade:${userId}` - All tabs of one user
|
||||
- Each socket can be in multiple rooms
|
||||
- `io.to(room).emit()` broadcasts to all sockets in that room
|
||||
|
||||
4. **Session Storage** - Database
|
||||
- One session per user (userId is unique key)
|
||||
- Contains `gameState`, `version`, `roomId`
|
||||
- Optimistic locking via version number
|
||||
|
||||
---
|
||||
|
||||
## Required: Room-Based Multi-User Sync
|
||||
|
||||
### The Goal
|
||||
|
||||
Multiple users in the same room at `/arcade/room` should all see synchronized game state:
|
||||
|
||||
- User A (2 tabs): Tab A1, Tab A2
|
||||
- User B (1 tab): Tab B1
|
||||
- User C (2 tabs): Tab C1, Tab C2
|
||||
|
||||
When User A makes a move in Tab A1:
|
||||
- **All of User A's tabs** see the move (Tab A1, Tab A2)
|
||||
- **All of User B's tabs** see the move (Tab B1)
|
||||
- **All of User C's tabs** see the move (Tab C1, Tab C2)
|
||||
|
||||
### The Challenge
|
||||
|
||||
Current architecture only broadcasts within one user:
|
||||
```typescript
|
||||
// ❌ Only reaches User A's tabs
|
||||
io.to(`arcade:${userA}`).emit('move-accepted', ...)
|
||||
```
|
||||
|
||||
We need to broadcast to the entire room:
|
||||
```typescript
|
||||
// ✅ Reaches all users in the room
|
||||
io.to(`game:${roomId}`).emit('move-accepted', ...)
|
||||
```
|
||||
|
||||
### The Solution
|
||||
|
||||
#### 1. Add Room-Based Game Socket Room
|
||||
|
||||
When a user joins `/arcade/room`, they join TWO socket rooms:
|
||||
|
||||
```typescript
|
||||
// socket-server.ts - extend join-arcade-session
|
||||
socket.on('join-arcade-session', async ({ userId, roomId }) => {
|
||||
// Join user's personal room (for multi-tab sync)
|
||||
socket.join(`arcade:${userId}`)
|
||||
|
||||
// If this session is part of a room, also join the game room
|
||||
if (roomId) {
|
||||
socket.join(`game:${roomId}`)
|
||||
console.log(`🎮 User ${userId} joined game room ${roomId}`)
|
||||
}
|
||||
|
||||
// Send current session state...
|
||||
})
|
||||
```
|
||||
|
||||
#### 2. Broadcast to Both Rooms
|
||||
|
||||
When processing moves for room-based sessions:
|
||||
|
||||
```typescript
|
||||
// socket-server.ts - modify game-move handler
|
||||
socket.on('game-move', async (data) => {
|
||||
const result = await applyGameMove(data.userId, data.move)
|
||||
|
||||
if (result.success && result.session) {
|
||||
const moveAcceptedData = {
|
||||
gameState: result.session.gameState,
|
||||
version: result.session.version,
|
||||
move: data.move,
|
||||
}
|
||||
|
||||
// Broadcast to user's own tabs (for optimistic update reconciliation)
|
||||
io.to(`arcade:${data.userId}`).emit('move-accepted', moveAcceptedData)
|
||||
|
||||
// If this is a room-based session, ALSO broadcast to all room members
|
||||
if (result.session.roomId) {
|
||||
io.to(`game:${result.session.roomId}`).emit('move-accepted', moveAcceptedData)
|
||||
console.log(`📢 Broadcasted move to room ${result.session.roomId}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Why broadcast to both?**
|
||||
- `arcade:${userId}` - So the acting user's tabs can reconcile their optimistic updates
|
||||
- `game:${roomId}` - So all other users in the room receive the update
|
||||
|
||||
#### 3. Client Handles Own vs. Other Moves
|
||||
|
||||
The client already handles this correctly via optimistic updates:
|
||||
|
||||
```typescript
|
||||
// User A (Tab A1) - Makes move
|
||||
sendMove({ type: 'FLIP_CARD', ... })
|
||||
// → Applies optimistically immediately
|
||||
// → Sends to server
|
||||
// → Receives move-accepted
|
||||
// → Reconciles: removes from pending queue
|
||||
|
||||
// User B (Tab B1) - Sees move from User A
|
||||
// → Receives move-accepted (unexpected)
|
||||
// → Reconciles: clears pending queue, syncs with server state
|
||||
// → Result: sees User A's move immediately
|
||||
```
|
||||
|
||||
The beauty is that `handleMoveAccepted()` already handles both cases:
|
||||
- **Own move**: Remove from pending queue
|
||||
- **Other's move**: Clear pending queue (since server state is now ahead)
|
||||
|
||||
#### 4. Pass roomId in join-arcade-session
|
||||
|
||||
Client needs to send roomId when joining:
|
||||
|
||||
```typescript
|
||||
// hooks/useArcadeSocket.ts
|
||||
const joinSession = useCallback((userId: string, roomId?: string) => {
|
||||
if (!socket) return
|
||||
socket.emit('join-arcade-session', { userId, roomId })
|
||||
}, [socket])
|
||||
|
||||
// hooks/useArcadeSession.ts
|
||||
useEffect(() => {
|
||||
if (connected && autoJoin && userId) {
|
||||
// Get roomId from session or room context
|
||||
const roomId = getRoomId() // Need to provide this
|
||||
joinSession(userId, roomId)
|
||||
}
|
||||
}, [connected, autoJoin, userId, joinSession])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Server-Side Changes
|
||||
|
||||
**File: `socket-server.ts`**
|
||||
|
||||
1. ✅ Accept `roomId` in `join-arcade-session` event
|
||||
```typescript
|
||||
socket.on('join-arcade-session', async ({ userId, roomId }) => {
|
||||
socket.join(`arcade:${userId}`)
|
||||
|
||||
// Join game room if session is room-based
|
||||
if (roomId) {
|
||||
socket.join(`game:${roomId}`)
|
||||
}
|
||||
|
||||
// Rest of logic...
|
||||
})
|
||||
```
|
||||
|
||||
2. ✅ Broadcast to room in `game-move` handler
|
||||
```typescript
|
||||
if (result.success && result.session) {
|
||||
const moveData = {
|
||||
gameState: result.session.gameState,
|
||||
version: result.session.version,
|
||||
move: data.move,
|
||||
}
|
||||
|
||||
// Broadcast to user's tabs
|
||||
io.to(`arcade:${data.userId}`).emit('move-accepted', moveData)
|
||||
|
||||
// ALSO broadcast to room if room-based session
|
||||
if (result.session.roomId) {
|
||||
io.to(`game:${result.session.roomId}`).emit('move-accepted', moveData)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. ✅ Handle room disconnects
|
||||
```typescript
|
||||
socket.on('disconnect', () => {
|
||||
// Leave all rooms (handled automatically by socket.io)
|
||||
// But log for debugging
|
||||
if (currentUserId && currentRoomId) {
|
||||
console.log(`User ${currentUserId} left game room ${currentRoomId}`)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Phase 2: Client-Side Changes
|
||||
|
||||
**File: `hooks/useArcadeSocket.ts`**
|
||||
|
||||
1. ✅ Add roomId parameter to joinSession
|
||||
```typescript
|
||||
export interface UseArcadeSocketReturn {
|
||||
// ... existing
|
||||
joinSession: (userId: string, roomId?: string) => void
|
||||
}
|
||||
|
||||
const joinSession = useCallback((userId: string, roomId?: string) => {
|
||||
if (!socket) return
|
||||
socket.emit('join-arcade-session', { userId, roomId })
|
||||
}, [socket])
|
||||
```
|
||||
|
||||
**File: `hooks/useArcadeSession.ts`**
|
||||
|
||||
2. ✅ Accept roomId in options
|
||||
```typescript
|
||||
export interface UseArcadeSessionOptions<TState> {
|
||||
userId: string
|
||||
roomId?: string // NEW
|
||||
initialState: TState
|
||||
applyMove: (state: TState, move: GameMove) => TState
|
||||
// ... rest
|
||||
}
|
||||
|
||||
export function useArcadeSession<TState>(options: UseArcadeSessionOptions<TState>) {
|
||||
const { userId, roomId, ...optimisticOptions } = options
|
||||
|
||||
// Auto-join with roomId
|
||||
useEffect(() => {
|
||||
if (connected && autoJoin && userId) {
|
||||
joinSession(userId, roomId)
|
||||
}
|
||||
}, [connected, autoJoin, userId, roomId, joinSession])
|
||||
|
||||
// ... rest
|
||||
}
|
||||
```
|
||||
|
||||
**File: `app/arcade/matching/context/ArcadeMemoryPairsContext.tsx`**
|
||||
|
||||
3. ✅ Get roomId from room data and pass to session
|
||||
```typescript
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
|
||||
export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
|
||||
// Arcade session integration
|
||||
const { state, sendMove, ... } = useArcadeSession<MemoryPairsState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // NEW - pass room ID
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// ... rest stays the same
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Testing
|
||||
|
||||
1. **Multi-Tab Test (Single User)**
|
||||
- Open `/arcade/room` in 2 tabs as User A
|
||||
- Make move in Tab 1
|
||||
- Verify Tab 2 updates immediately
|
||||
|
||||
2. **Multi-User Test (Different Users)**
|
||||
- User A opens `/arcade/room` in 1 tab
|
||||
- User B opens `/arcade/room` in 1 tab (same room)
|
||||
- User A makes move
|
||||
- Verify User B sees move immediately
|
||||
|
||||
3. **Multi-User Multi-Tab Test**
|
||||
- User A: 2 tabs (Tab A1, Tab A2)
|
||||
- User B: 2 tabs (Tab B1, Tab B2)
|
||||
- User A makes move in Tab A1
|
||||
- Verify all 4 tabs update
|
||||
|
||||
4. **Rapid Move Test**
|
||||
- User A and User B both make moves rapidly
|
||||
- Verify no conflicts
|
||||
- Verify all moves are processed in order
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases to Handle
|
||||
|
||||
### 1. User Leaves Room Mid-Game
|
||||
|
||||
**Current behavior:** Session persists, user can rejoin
|
||||
|
||||
**Required behavior:**
|
||||
- If user leaves room (HTTP POST to `/api/arcade/rooms/[roomId]/leave`):
|
||||
- Delete their session
|
||||
- Emit `session-ended` to their tabs
|
||||
- Other users continue playing
|
||||
|
||||
### 2. Version Conflicts
|
||||
|
||||
**Already handled** by optimistic locking:
|
||||
- Each move increments version
|
||||
- Client tracks server version
|
||||
- If conflict detected, reconciliation happens automatically
|
||||
|
||||
### 3. Session Without Room
|
||||
|
||||
**Already handled** by session-manager.ts:
|
||||
- Sessions without `roomId` are considered orphaned
|
||||
- They're cleaned up on next access (lines 111-115)
|
||||
|
||||
### 4. Multiple Users Same Move
|
||||
|
||||
**Handled by server validation:**
|
||||
- Server processes moves sequentially
|
||||
- First valid move wins
|
||||
- Second move gets rejected if it's now invalid
|
||||
- Client rolls back rejected move
|
||||
|
||||
---
|
||||
|
||||
## Benefits of This Architecture
|
||||
|
||||
1. **Reuses existing optimistic update system**
|
||||
- No changes needed to client-side optimistic logic
|
||||
- Already handles own vs. others' moves
|
||||
|
||||
2. **Minimal changes required**
|
||||
- Add `roomId` parameter (3 places)
|
||||
- Add one `io.to()` broadcast (1 place)
|
||||
- Wire up roomId from context (1 place)
|
||||
|
||||
3. **Backward compatible**
|
||||
- Non-room sessions still work (roomId is optional)
|
||||
- Solo play unaffected
|
||||
|
||||
4. **Scalable**
|
||||
- Socket.io handles multiple rooms efficiently
|
||||
- No N² broadcasting (room-based is O(N))
|
||||
|
||||
5. **Already tested pattern**
|
||||
- Multi-tab sync proves the broadcast pattern works
|
||||
- Just extending to more sockets (different users)
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### 1. Validate Room Membership
|
||||
|
||||
Before processing moves, verify user is in the room:
|
||||
|
||||
```typescript
|
||||
// session-manager.ts - in applyGameMove()
|
||||
const session = await getArcadeSession(userId)
|
||||
|
||||
if (session.roomId) {
|
||||
// Verify user is a member of this room
|
||||
const membership = await getRoomMember(session.roomId, userId)
|
||||
if (!membership) {
|
||||
return { success: false, error: 'User not in room' }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Verify Player Ownership
|
||||
|
||||
Ensure users can only make moves for their own players:
|
||||
|
||||
```typescript
|
||||
// Already handled in validator
|
||||
// move.playerId must be in session.activePlayers
|
||||
// activePlayers are owned by the userId making the move
|
||||
```
|
||||
|
||||
This is already enforced by how activePlayers are set up in the room.
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### 1. Broadcasting Overhead
|
||||
|
||||
- **Current**: 1 user × N tabs = N broadcasts per move
|
||||
- **New**: M users × N tabs each = (M×N) broadcasts per move
|
||||
- **Impact**: Linear with room size, not quadratic
|
||||
- **Acceptable**: Socket.io is optimized for this
|
||||
|
||||
### 2. Database Queries
|
||||
|
||||
- No change: Still 1 database write per move
|
||||
- Session is stored per-user, not per-room
|
||||
- Room data is separate (cached, not updated per move)
|
||||
|
||||
### 3. Memory
|
||||
|
||||
- Each socket joins 2 rooms instead of 1
|
||||
- Negligible: Socket.io uses efficient room data structures
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- [ ] `useArcadeSocket` accepts and passes roomId
|
||||
- [ ] `useArcadeSession` accepts and passes roomId
|
||||
- [ ] Server joins `game:${roomId}` room when roomId provided
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- [ ] Single user, 2 tabs: both tabs sync
|
||||
- [ ] 2 users, 1 tab each: both users sync
|
||||
- [ ] 2 users, 2 tabs each: all 4 tabs sync
|
||||
- [ ] User leaves room: session deleted, others continue
|
||||
- [ ] Rapid concurrent moves: all processed correctly
|
||||
|
||||
### Manual Tests
|
||||
|
||||
- [ ] Open room in 2 browsers (different users)
|
||||
- [ ] Play full game to completion
|
||||
- [ ] Verify scores sync correctly
|
||||
- [ ] Verify turn changes sync correctly
|
||||
- [ ] Verify game completion syncs correctly
|
||||
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! 🚀**
|
||||
153
apps/web/docs/terminology-user-player-room.md
Normal file
153
apps/web/docs/terminology-user-player-room.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# User vs Player vs Room Member - Terminology Guide
|
||||
|
||||
**Critical Distinction**: Users, Players, and Room Members are three different concepts in the system.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. **USER** (Identity Layer)
|
||||
- **Table**: `users`
|
||||
- **Purpose**: Identity - guest or authenticated account
|
||||
- **Identified by**: `guestId` (HttpOnly cookie)
|
||||
- **Retrieved via**: `useViewerId()` hook
|
||||
- **Scope**: One per browser/account
|
||||
- **Example**: A person visiting the site
|
||||
|
||||
### 2. **PLAYER** (Game Avatar Layer)
|
||||
- **Table**: `players`
|
||||
- **Purpose**: Game profiles/avatars that represent a participant in the game
|
||||
- **Belongs to**: USER (via `userId` FK)
|
||||
- **Properties**: name, emoji, color, `isActive`
|
||||
- **Scope**: A USER can have MULTIPLE players (e.g., "Alice 👧", "Bob 👦", "Charlie 🧒")
|
||||
- **Used in**: All game contexts - both local and online multiplayer
|
||||
- **Active Players**: Players where `isActive = true` are the ones currently participating
|
||||
|
||||
### 3. **ROOM MEMBER** (Room Participation Layer)
|
||||
- **Table**: `room_members`
|
||||
- **Purpose**: Tracks a USER's participation in a multiplayer room
|
||||
- **Identified by**: `userId` (references the guest/user)
|
||||
- **Properties**: `displayName`, `isCreator`, `isOnline`, `joinedAt`
|
||||
- **Scope**: One record per USER per room
|
||||
|
||||
## How They Work Together
|
||||
|
||||
### When a USER joins a room:
|
||||
|
||||
1. **Room Member Created**: A `room_members` record is created with the USER's ID
|
||||
2. **Active Players Join**: The USER's ACTIVE PLAYERS (where `isActive = true`) participate in the game
|
||||
3. **Arcade Session**: The `arcade_sessions.activePlayers` field contains the PLAYER IDs (from `players` table)
|
||||
|
||||
### Example Flow:
|
||||
|
||||
```
|
||||
USER: guest_abc123 (Jane)
|
||||
├─ PLAYER: player_001 (name: "Alice 👧", isActive: true)
|
||||
├─ PLAYER: player_002 (name: "Bob 👦", isActive: true)
|
||||
└─ PLAYER: player_003 (name: "Charlie 🧒", isActive: false)
|
||||
|
||||
When USER joins ROOM "Math Masters":
|
||||
→ ROOM_MEMBER created: {userId: "guest_abc123", displayName: "Jane", roomId: "room_xyz"}
|
||||
→ PLAYERS joining game: ["player_001", "player_002"] (only active ones)
|
||||
→ ARCADE_SESSION.activePlayers: ["player_001", "player_002"]
|
||||
```
|
||||
|
||||
### Multi-User Room Example:
|
||||
|
||||
```
|
||||
ROOM "Math Masters" (room_xyz):
|
||||
|
||||
ROOM_MEMBER 1:
|
||||
userId: guest_abc123 (Jane)
|
||||
└─ PLAYERS in game: ["player_001" (Alice), "player_002" (Bob)]
|
||||
|
||||
ROOM_MEMBER 2:
|
||||
userId: guest_def456 (Mark)
|
||||
└─ PLAYERS in game: ["player_003" (Mario)]
|
||||
|
||||
ROOM_MEMBER 3:
|
||||
userId: guest_ghi789 (Sara)
|
||||
└─ PLAYERS in game: ["player_004" (Luna), "player_005" (Nova), "player_006" (Star)]
|
||||
|
||||
Total PLAYERS in this game: 6 players across 3 users
|
||||
```
|
||||
|
||||
## Database Schema Relationships
|
||||
|
||||
```
|
||||
users (1) ──< (many) players
|
||||
│
|
||||
└──< (many) room_members
|
||||
│
|
||||
└──< belongs to arcade_rooms
|
||||
|
||||
arcade_sessions:
|
||||
- userId: references users.id
|
||||
- activePlayers: JSON array of player.id values
|
||||
- roomId: references arcade_rooms.id (null for solo play)
|
||||
```
|
||||
|
||||
## Implementation Rules
|
||||
|
||||
### ✅ Correct Usage
|
||||
|
||||
- **Room membership**: Track by USER ID
|
||||
- **Game participation**: Track by PLAYER IDs
|
||||
- **Presence/online status**: Track by USER ID (room member)
|
||||
- **Scores/moves**: Track by PLAYER ID
|
||||
- **Room creator**: Track by USER ID
|
||||
|
||||
### ❌ Common Mistakes
|
||||
|
||||
- ❌ Using USER ID where PLAYER ID is needed
|
||||
- ❌ Assuming one USER = one PLAYER
|
||||
- ❌ Tracking scores by USER instead of PLAYER
|
||||
- ❌ Mixing room_members.displayName with players.name
|
||||
|
||||
## API Design Patterns
|
||||
|
||||
### When a USER joins a room:
|
||||
|
||||
```typescript
|
||||
// 1. Add user as room member
|
||||
POST /api/arcade/rooms/:roomId/join
|
||||
Body: {
|
||||
userId: string // USER ID (from useViewerId)
|
||||
displayName: string // Room member display name
|
||||
}
|
||||
|
||||
// 2. System retrieves user's active players
|
||||
const activePlayers = await db.query.players.findMany({
|
||||
where: and(
|
||||
eq(players.userId, userId),
|
||||
eq(players.isActive, true)
|
||||
)
|
||||
})
|
||||
|
||||
// 3. Game starts with those player IDs
|
||||
const session = {
|
||||
userId,
|
||||
activePlayers: activePlayers.map(p => p.id), // PLAYER IDs
|
||||
roomId
|
||||
}
|
||||
```
|
||||
|
||||
### Socket Events
|
||||
|
||||
```typescript
|
||||
// User joins room (presence)
|
||||
socket.emit('join-room', { roomId, userId })
|
||||
|
||||
// Player makes a move (game action)
|
||||
socket.emit('game-move', {
|
||||
roomId,
|
||||
playerId, // PLAYER ID, not USER ID
|
||||
move
|
||||
})
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
- **USER** = Identity/account (one per person)
|
||||
- **PLAYER** = Game avatar/profile (multiple per user)
|
||||
- **ROOM MEMBER** = USER's participation in a room
|
||||
- **When USER joins room** → Their ACTIVE PLAYERS join the game
|
||||
- **`activePlayers` field** → Array of PLAYER IDs from `players` table
|
||||
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);
|
||||
15
apps/web/drizzle/0004_shiny_madelyne_pryor.sql
Normal file
15
apps/web/drizzle/0004_shiny_madelyne_pryor.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Step 1: Clean up any duplicate room memberships
|
||||
-- Keep only the most recent membership for each user (by last_seen timestamp)
|
||||
DELETE FROM `room_members`
|
||||
WHERE `id` NOT IN (
|
||||
SELECT `id` FROM (
|
||||
SELECT `id`, ROW_NUMBER() OVER (
|
||||
PARTITION BY `user_id`
|
||||
ORDER BY `last_seen` DESC, `joined_at` DESC
|
||||
) as rn
|
||||
FROM `room_members`
|
||||
) WHERE rn = 1
|
||||
);--> statement-breakpoint
|
||||
|
||||
-- Step 2: Add unique constraint to enforce one room per user
|
||||
CREATE UNIQUE INDEX `idx_room_members_user_id_unique` ON `room_members` (`user_id`);
|
||||
222
apps/web/drizzle/meta/0000_snapshot.json
Normal file
222
apps/web/drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,222 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "949424bf-1933-497c-af2d-cab6ee81083d",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"guest_id": {
|
||||
"name": "guest_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"upgraded_at": {
|
||||
"name": "upgraded_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_guest_id_unique": {
|
||||
"name": "users_guest_id_unique",
|
||||
"columns": ["guest_id"],
|
||||
"isUnique": true
|
||||
},
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"columns": ["email"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"players": {
|
||||
"name": "players",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"emoji": {
|
||||
"name": "emoji",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"players_user_id_idx": {
|
||||
"name": "players_user_id_idx",
|
||||
"columns": ["user_id"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"players_user_id_users_id_fk": {
|
||||
"name": "players_user_id_users_id_fk",
|
||||
"tableFrom": "players",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"user_stats": {
|
||||
"name": "user_stats",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"games_played": {
|
||||
"name": "games_played",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"total_wins": {
|
||||
"name": "total_wins",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"favorite_game_type": {
|
||||
"name": "favorite_game_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"best_time": {
|
||||
"name": "best_time",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"highest_accuracy": {
|
||||
"name": "highest_accuracy",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_stats_user_id_users_id_fk": {
|
||||
"name": "user_stats_user_id_users_id_fk",
|
||||
"tableFrom": "user_stats",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
345
apps/web/drizzle/meta/0001_snapshot.json
Normal file
345
apps/web/drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,345 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "25d98633-0bae-4f6e-845b-ed87f53fc233",
|
||||
"prevId": "949424bf-1933-497c-af2d-cab6ee81083d",
|
||||
"tables": {
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"guest_id": {
|
||||
"name": "guest_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"upgraded_at": {
|
||||
"name": "upgraded_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_guest_id_unique": {
|
||||
"name": "users_guest_id_unique",
|
||||
"columns": ["guest_id"],
|
||||
"isUnique": true
|
||||
},
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"columns": ["email"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"players": {
|
||||
"name": "players",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"emoji": {
|
||||
"name": "emoji",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"players_user_id_idx": {
|
||||
"name": "players_user_id_idx",
|
||||
"columns": ["user_id"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"players_user_id_users_id_fk": {
|
||||
"name": "players_user_id_users_id_fk",
|
||||
"tableFrom": "players",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"user_stats": {
|
||||
"name": "user_stats",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"games_played": {
|
||||
"name": "games_played",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"total_wins": {
|
||||
"name": "total_wins",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"favorite_game_type": {
|
||||
"name": "favorite_game_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"best_time": {
|
||||
"name": "best_time",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"highest_accuracy": {
|
||||
"name": "highest_accuracy",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_stats_user_id_users_id_fk": {
|
||||
"name": "user_stats_user_id_users_id_fk",
|
||||
"tableFrom": "user_stats",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"abacus_settings": {
|
||||
"name": "abacus_settings",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"color_scheme": {
|
||||
"name": "color_scheme",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'place-value'"
|
||||
},
|
||||
"bead_shape": {
|
||||
"name": "bead_shape",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'diamond'"
|
||||
},
|
||||
"color_palette": {
|
||||
"name": "color_palette",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'default'"
|
||||
},
|
||||
"hide_inactive_beads": {
|
||||
"name": "hide_inactive_beads",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"colored_numerals": {
|
||||
"name": "colored_numerals",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"scale_factor": {
|
||||
"name": "scale_factor",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
},
|
||||
"show_numbers": {
|
||||
"name": "show_numbers",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"animated": {
|
||||
"name": "animated",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"interactive": {
|
||||
"name": "interactive",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"gestures": {
|
||||
"name": "gestures",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"sound_enabled": {
|
||||
"name": "sound_enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"sound_volume": {
|
||||
"name": "sound_volume",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0.8
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"abacus_settings_user_id_users_id_fk": {
|
||||
"name": "abacus_settings_user_id_users_id_fk",
|
||||
"tableFrom": "abacus_settings",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
437
apps/web/drizzle/meta/0002_snapshot.json
Normal file
437
apps/web/drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,437 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "194ccc68-7173-44c9-879a-55d20cf3ae1f",
|
||||
"prevId": "25d98633-0bae-4f6e-845b-ed87f53fc233",
|
||||
"tables": {
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"guest_id": {
|
||||
"name": "guest_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"upgraded_at": {
|
||||
"name": "upgraded_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_guest_id_unique": {
|
||||
"name": "users_guest_id_unique",
|
||||
"columns": ["guest_id"],
|
||||
"isUnique": true
|
||||
},
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"columns": ["email"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"players": {
|
||||
"name": "players",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"emoji": {
|
||||
"name": "emoji",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"players_user_id_idx": {
|
||||
"name": "players_user_id_idx",
|
||||
"columns": ["user_id"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"players_user_id_users_id_fk": {
|
||||
"name": "players_user_id_users_id_fk",
|
||||
"tableFrom": "players",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"user_stats": {
|
||||
"name": "user_stats",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"games_played": {
|
||||
"name": "games_played",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"total_wins": {
|
||||
"name": "total_wins",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"favorite_game_type": {
|
||||
"name": "favorite_game_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"best_time": {
|
||||
"name": "best_time",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"highest_accuracy": {
|
||||
"name": "highest_accuracy",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_stats_user_id_users_id_fk": {
|
||||
"name": "user_stats_user_id_users_id_fk",
|
||||
"tableFrom": "user_stats",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"abacus_settings": {
|
||||
"name": "abacus_settings",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"color_scheme": {
|
||||
"name": "color_scheme",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'place-value'"
|
||||
},
|
||||
"bead_shape": {
|
||||
"name": "bead_shape",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'diamond'"
|
||||
},
|
||||
"color_palette": {
|
||||
"name": "color_palette",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'default'"
|
||||
},
|
||||
"hide_inactive_beads": {
|
||||
"name": "hide_inactive_beads",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"colored_numerals": {
|
||||
"name": "colored_numerals",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"scale_factor": {
|
||||
"name": "scale_factor",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
},
|
||||
"show_numbers": {
|
||||
"name": "show_numbers",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"animated": {
|
||||
"name": "animated",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"interactive": {
|
||||
"name": "interactive",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"gestures": {
|
||||
"name": "gestures",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"sound_enabled": {
|
||||
"name": "sound_enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"sound_volume": {
|
||||
"name": "sound_volume",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0.8
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"abacus_settings_user_id_users_id_fk": {
|
||||
"name": "abacus_settings_user_id_users_id_fk",
|
||||
"tableFrom": "abacus_settings",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"arcade_sessions": {
|
||||
"name": "arcade_sessions",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"current_game": {
|
||||
"name": "current_game",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"game_url": {
|
||||
"name": "game_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"game_state": {
|
||||
"name": "game_state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active_players": {
|
||||
"name": "active_players",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_activity_at": {
|
||||
"name": "last_activity_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"arcade_sessions_user_id_users_id_fk": {
|
||||
"name": "arcade_sessions_user_id_users_id_fk",
|
||||
"tableFrom": "arcade_sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
649
apps/web/drizzle/meta/0003_snapshot.json
Normal file
649
apps/web/drizzle/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,649 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "68cc273f-0d84-4a46-ae41-124a3e06096b",
|
||||
"prevId": "194ccc68-7173-44c9-879a-55d20cf3ae1f",
|
||||
"tables": {
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"guest_id": {
|
||||
"name": "guest_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"upgraded_at": {
|
||||
"name": "upgraded_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_guest_id_unique": {
|
||||
"name": "users_guest_id_unique",
|
||||
"columns": ["guest_id"],
|
||||
"isUnique": true
|
||||
},
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"columns": ["email"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"players": {
|
||||
"name": "players",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"emoji": {
|
||||
"name": "emoji",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"players_user_id_idx": {
|
||||
"name": "players_user_id_idx",
|
||||
"columns": ["user_id"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"players_user_id_users_id_fk": {
|
||||
"name": "players_user_id_users_id_fk",
|
||||
"tableFrom": "players",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"user_stats": {
|
||||
"name": "user_stats",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"games_played": {
|
||||
"name": "games_played",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"total_wins": {
|
||||
"name": "total_wins",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"favorite_game_type": {
|
||||
"name": "favorite_game_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"best_time": {
|
||||
"name": "best_time",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"highest_accuracy": {
|
||||
"name": "highest_accuracy",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_stats_user_id_users_id_fk": {
|
||||
"name": "user_stats_user_id_users_id_fk",
|
||||
"tableFrom": "user_stats",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"abacus_settings": {
|
||||
"name": "abacus_settings",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"color_scheme": {
|
||||
"name": "color_scheme",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'place-value'"
|
||||
},
|
||||
"bead_shape": {
|
||||
"name": "bead_shape",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'diamond'"
|
||||
},
|
||||
"color_palette": {
|
||||
"name": "color_palette",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'default'"
|
||||
},
|
||||
"hide_inactive_beads": {
|
||||
"name": "hide_inactive_beads",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"colored_numerals": {
|
||||
"name": "colored_numerals",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"scale_factor": {
|
||||
"name": "scale_factor",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
},
|
||||
"show_numbers": {
|
||||
"name": "show_numbers",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"animated": {
|
||||
"name": "animated",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"interactive": {
|
||||
"name": "interactive",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"gestures": {
|
||||
"name": "gestures",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"sound_enabled": {
|
||||
"name": "sound_enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"sound_volume": {
|
||||
"name": "sound_volume",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0.8
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"abacus_settings_user_id_users_id_fk": {
|
||||
"name": "abacus_settings_user_id_users_id_fk",
|
||||
"tableFrom": "abacus_settings",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"arcade_rooms": {
|
||||
"name": "arcade_rooms",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"code": {
|
||||
"name": "code",
|
||||
"type": "text(6)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_by": {
|
||||
"name": "created_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"creator_name": {
|
||||
"name": "creator_name",
|
||||
"type": "text(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_activity": {
|
||||
"name": "last_activity",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ttl_minutes": {
|
||||
"name": "ttl_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 60
|
||||
},
|
||||
"is_locked": {
|
||||
"name": "is_locked",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"game_name": {
|
||||
"name": "game_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"game_config": {
|
||||
"name": "game_config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'lobby'"
|
||||
},
|
||||
"current_session_id": {
|
||||
"name": "current_session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"total_games_played": {
|
||||
"name": "total_games_played",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"arcade_rooms_code_unique": {
|
||||
"name": "arcade_rooms_code_unique",
|
||||
"columns": ["code"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"room_members": {
|
||||
"name": "room_members",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"room_id": {
|
||||
"name": "room_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_creator": {
|
||||
"name": "is_creator",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"joined_at": {
|
||||
"name": "joined_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_seen": {
|
||||
"name": "last_seen",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_online": {
|
||||
"name": "is_online",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"room_members_room_id_arcade_rooms_id_fk": {
|
||||
"name": "room_members_room_id_arcade_rooms_id_fk",
|
||||
"tableFrom": "room_members",
|
||||
"tableTo": "arcade_rooms",
|
||||
"columnsFrom": ["room_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"arcade_sessions": {
|
||||
"name": "arcade_sessions",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"current_game": {
|
||||
"name": "current_game",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"game_url": {
|
||||
"name": "game_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"game_state": {
|
||||
"name": "game_state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active_players": {
|
||||
"name": "active_players",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"room_id": {
|
||||
"name": "room_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_activity_at": {
|
||||
"name": "last_activity_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"arcade_sessions_user_id_users_id_fk": {
|
||||
"name": "arcade_sessions_user_id_users_id_fk",
|
||||
"tableFrom": "arcade_sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"arcade_sessions_room_id_arcade_rooms_id_fk": {
|
||||
"name": "arcade_sessions_room_id_arcade_rooms_id_fk",
|
||||
"tableFrom": "arcade_sessions",
|
||||
"tableTo": "arcade_rooms",
|
||||
"columnsFrom": ["room_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
660
apps/web/drizzle/meta/0004_snapshot.json
Normal file
660
apps/web/drizzle/meta/0004_snapshot.json
Normal file
@@ -0,0 +1,660 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "cbd94d51-1454-467c-a471-ccbfca886a1a",
|
||||
"prevId": "68cc273f-0d84-4a46-ae41-124a3e06096b",
|
||||
"tables": {
|
||||
"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": {}
|
||||
},
|
||||
"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": {}
|
||||
},
|
||||
"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": {}
|
||||
},
|
||||
"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": {
|
||||
"room_members_user_id_unique": {
|
||||
"name": "room_members_user_id_unique",
|
||||
"columns": ["user_id"],
|
||||
"isUnique": true
|
||||
},
|
||||
"idx_room_members_user_id_unique": {
|
||||
"name": "idx_room_members_user_id_unique",
|
||||
"columns": ["user_id"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"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": {}
|
||||
},
|
||||
"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": {}
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
41
apps/web/drizzle/meta/_journal.json
Normal file
41
apps/web/drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1759930182541,
|
||||
"tag": "0004_shiny_madelyne_pryor",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
338
apps/web/e2e/arcade-modal-session.spec.ts
Normal file
338
apps/web/e2e/arcade-modal-session.spec.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Arcade Modal Session E2E Tests
|
||||
*
|
||||
* These tests verify that the arcade modal session system works correctly:
|
||||
* - Users are locked into games once they start
|
||||
* - Automatic redirects to active games
|
||||
* - Player modification is blocked during games
|
||||
* - "Return to Arcade" button properly ends sessions
|
||||
*/
|
||||
|
||||
test.describe('Arcade Modal Session - Redirects', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Clear arcade session before each test
|
||||
await page.goto('/arcade')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Click "Return to Arcade" button if it exists (to clear any existing session)
|
||||
const returnButton = page.locator('button:has-text("Return to Arcade")')
|
||||
if (await returnButton.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await returnButton.click()
|
||||
await page.waitForLoadState('networkidle')
|
||||
}
|
||||
})
|
||||
|
||||
test('should stay on arcade lobby when no active session', async ({ page }) => {
|
||||
await page.goto('/arcade')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Should see "Champion Arena" title
|
||||
const title = page.locator('h1:has-text("Champion Arena")')
|
||||
await expect(title).toBeVisible()
|
||||
|
||||
// Should be able to select players
|
||||
const playerSection = page.locator('text=/Player|Select|Add/i')
|
||||
await expect(playerSection.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('should redirect from arcade to active game when session exists', async ({ page }) => {
|
||||
// Start a game to create a session
|
||||
await page.goto('/arcade')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Find and click a player card to activate
|
||||
const playerCard = page.locator('[data-testid="player-card"]').first()
|
||||
if (await playerCard.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await playerCard.click()
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
// Navigate to matching game to create session
|
||||
await page.goto('/arcade/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Start the game (click Start button if visible)
|
||||
const startButton = page.locator('button:has-text("Start")')
|
||||
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await startButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
// Try to navigate back to arcade lobby
|
||||
await page.goto('/arcade')
|
||||
await page.waitForTimeout(2000) // Give time for redirect
|
||||
|
||||
// Should be redirected back to the game
|
||||
await expect(page).toHaveURL(/\/arcade\/matching/)
|
||||
const gameTitle = page.locator('h1:has-text("Memory Pairs")')
|
||||
await expect(gameTitle).toBeVisible()
|
||||
})
|
||||
|
||||
test('should redirect to correct game when navigating to wrong game', async ({ page }) => {
|
||||
// Create a session with matching game
|
||||
await page.goto('/arcade')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Activate a player
|
||||
const addPlayerButton = page.locator('button:has-text("Add Player"), button:has-text("+")')
|
||||
if (
|
||||
await addPlayerButton
|
||||
.first()
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false)
|
||||
) {
|
||||
await addPlayerButton.first().click()
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
// Go to matching game
|
||||
await page.goto('/arcade/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Start game if needed
|
||||
const startButton = page.locator('button:has-text("Start")')
|
||||
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await startButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
// Try to navigate to a different game
|
||||
await page.goto('/arcade/memory-quiz')
|
||||
await page.waitForTimeout(2000) // Give time for redirect
|
||||
|
||||
// Should be redirected back to matching
|
||||
await expect(page).toHaveURL(/\/arcade\/matching/)
|
||||
})
|
||||
|
||||
test('should NOT redirect when on correct game page', async ({ page }) => {
|
||||
// Navigate to matching game
|
||||
await page.goto('/arcade/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Should stay on matching page
|
||||
await expect(page).toHaveURL(/\/arcade\/matching/)
|
||||
const gameTitle = page.locator('h1:has-text("Memory Pairs")')
|
||||
await expect(gameTitle).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Arcade Modal Session - Player Modification Blocking', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Clear session
|
||||
await page.goto('/arcade')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const returnButton = page.locator('button:has-text("Return to Arcade")')
|
||||
if (await returnButton.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await returnButton.click()
|
||||
await page.waitForLoadState('networkidle')
|
||||
}
|
||||
})
|
||||
|
||||
test('should allow player modification in arcade lobby with no session', async ({ page }) => {
|
||||
await page.goto('/arcade')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Look for add player button (should be enabled)
|
||||
const addPlayerButton = page.locator('button:has-text("Add Player"), button:has-text("+")')
|
||||
const firstButton = addPlayerButton.first()
|
||||
|
||||
if (await firstButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
// Should be clickable
|
||||
await expect(firstButton).toBeEnabled()
|
||||
|
||||
// Try to click it
|
||||
await firstButton.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Should see player added
|
||||
const activePlayer = page.locator('[data-testid="active-player"]')
|
||||
await expect(activePlayer.first()).toBeVisible({ timeout: 3000 })
|
||||
}
|
||||
})
|
||||
|
||||
test('should block player modification during active game', async ({ page }) => {
|
||||
// Start a game
|
||||
await page.goto('/arcade/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Start game
|
||||
const startButton = page.locator('button:has-text("Start")')
|
||||
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await startButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
// Look for player modification controls
|
||||
// They should be disabled or have reduced opacity
|
||||
const playerControls = page.locator('[data-testid="player-controls"], .player-list')
|
||||
if (await playerControls.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
// Check if controls have pointer-events: none or low opacity
|
||||
const opacity = await playerControls.evaluate((el) => {
|
||||
return window.getComputedStyle(el).opacity
|
||||
})
|
||||
|
||||
// If controls are visible, they should be dimmed (opacity < 1)
|
||||
if (parseFloat(opacity) < 1) {
|
||||
expect(parseFloat(opacity)).toBeLessThan(1)
|
||||
}
|
||||
}
|
||||
|
||||
// "Add Player" button should not be visible during game
|
||||
const addPlayerButton = page.locator('button:has-text("Add Player")')
|
||||
if (await addPlayerButton.isVisible({ timeout: 500 }).catch(() => false)) {
|
||||
// If visible, should be disabled
|
||||
const isDisabled = await addPlayerButton.isDisabled()
|
||||
expect(isDisabled).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('should show "Return to Arcade" button during game', async ({ page }) => {
|
||||
// Start a game
|
||||
await page.goto('/arcade/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Look for "Return to Arcade" button
|
||||
const returnButton = page.locator('button:has-text("Return to Arcade")')
|
||||
|
||||
// During game setup, might see "Setup" button instead
|
||||
const setupButton = page.locator('button:has-text("Setup")')
|
||||
|
||||
// One of these should be visible
|
||||
const hasReturnButton = await returnButton.isVisible({ timeout: 2000 }).catch(() => false)
|
||||
const hasSetupButton = await setupButton.isVisible({ timeout: 2000 }).catch(() => false)
|
||||
|
||||
expect(hasReturnButton || hasSetupButton).toBe(true)
|
||||
})
|
||||
|
||||
test('should NOT show "Setup" button in arcade lobby with no session', async ({ page }) => {
|
||||
await page.goto('/arcade')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Should NOT see "Return to Arcade" or "Setup" button in lobby
|
||||
const returnButton = page.locator('button:has-text("Return to Arcade")')
|
||||
const setupButton = page.locator('button:has-text("Setup")')
|
||||
|
||||
const hasReturnButton = await returnButton.isVisible({ timeout: 1000 }).catch(() => false)
|
||||
const hasSetupButton = await setupButton.isVisible({ timeout: 1000 }).catch(() => false)
|
||||
|
||||
// Neither should be visible in empty lobby
|
||||
expect(hasReturnButton).toBe(false)
|
||||
expect(hasSetupButton).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Arcade Modal Session - Return to Arcade Button', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Clear session
|
||||
await page.goto('/arcade')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('should end session and return to arcade when clicking "Return to Arcade"', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Start a game
|
||||
await page.goto('/arcade/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Start game if needed
|
||||
const startButton = page.locator('button:has-text("Start")')
|
||||
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await startButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
// Find and click "Return to Arcade" button
|
||||
const returnButton = page.locator('button:has-text("Return to Arcade")')
|
||||
if (await returnButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await returnButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Should be redirected to arcade lobby
|
||||
await expect(page).toHaveURL(/\/arcade\/?$/)
|
||||
|
||||
// Should see arcade lobby title
|
||||
const title = page.locator('h1:has-text("Champion Arena")')
|
||||
await expect(title).toBeVisible()
|
||||
|
||||
// Now should be able to modify players again
|
||||
const addPlayerButton = page.locator('button:has-text("Add Player"), button:has-text("+")')
|
||||
if (
|
||||
await addPlayerButton
|
||||
.first()
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false)
|
||||
) {
|
||||
await expect(addPlayerButton.first()).toBeEnabled()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('should allow navigating to different game after returning to arcade', async ({ page }) => {
|
||||
// Start matching game
|
||||
await page.goto('/arcade/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Return to arcade
|
||||
const returnButton = page.locator(
|
||||
'button:has-text("Return to Arcade"), button:has-text("Setup")'
|
||||
)
|
||||
if (
|
||||
await returnButton
|
||||
.first()
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false)
|
||||
) {
|
||||
await returnButton.first().click()
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
// Should be in arcade lobby
|
||||
await expect(page).toHaveURL(/\/arcade\/?$/)
|
||||
|
||||
// Now navigate to different game - should NOT redirect back to matching
|
||||
await page.goto('/arcade/memory-quiz')
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Should stay on memory-quiz (not redirect back to matching)
|
||||
await expect(page).toHaveURL(/\/arcade\/memory-quiz/)
|
||||
|
||||
// Should see memory quiz title
|
||||
const title = page.locator('h1:has-text("Memory Lightning")')
|
||||
await expect(title).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Arcade Modal Session - Session Persistence', () => {
|
||||
test('should maintain active session across page reloads', async ({ page }) => {
|
||||
// Start a game
|
||||
await page.goto('/arcade/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Start game
|
||||
const startButton = page.locator('button:has-text("Start")')
|
||||
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await startButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
// Reload the page
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Should still be on matching game
|
||||
await expect(page).toHaveURL(/\/arcade\/matching/)
|
||||
const gameTitle = page.locator('h1:has-text("Memory Pairs")')
|
||||
await expect(gameTitle).toBeVisible()
|
||||
|
||||
// Try to navigate to arcade
|
||||
await page.goto('/arcade')
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Should be redirected back to matching
|
||||
await expect(page).toHaveURL(/\/arcade\/matching/)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test.describe('Mini Navigation Game Name Persistence', () => {
|
||||
test('should not show game name when navigating back to games page from a specific game', async ({ page }) => {
|
||||
test('should not show game name when navigating back to games page from a specific game', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Override baseURL for this test to match running dev server
|
||||
const baseURL = 'http://localhost:3000'
|
||||
|
||||
@@ -73,7 +75,9 @@ test.describe('Mini Navigation Game Name Persistence', () => {
|
||||
await expect(page.locator('text=🧠 Memory Lightning')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should not persist game name when navigating through intermediate pages', async ({ page }) => {
|
||||
test('should not persist game name when navigating through intermediate pages', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Override baseURL for this test to match running dev server
|
||||
const baseURL = 'http://localhost:3000'
|
||||
|
||||
@@ -108,4 +112,4 @@ test.describe('Mini Navigation Game Name Persistence', () => {
|
||||
await expect(memoryLightningName).not.toBeVisible()
|
||||
await expect(memoryPairsName).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test.describe('Game navigation slots', () => {
|
||||
test('should show Memory Pairs game name in nav when navigating to matching game', async ({ page }) => {
|
||||
test('should show Memory Pairs game name in nav when navigating to matching game', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/games/matching')
|
||||
|
||||
// Wait for the page to load
|
||||
@@ -13,7 +15,9 @@ test.describe('Game navigation slots', () => {
|
||||
await expect(gameNav).toContainText('Memory Pairs')
|
||||
})
|
||||
|
||||
test('should show Memory Lightning game name in nav when navigating to memory quiz', async ({ page }) => {
|
||||
test('should show Memory Lightning game name in nav when navigating to memory quiz', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/games/memory-quiz')
|
||||
|
||||
// Wait for the page to load
|
||||
@@ -70,4 +74,4 @@ test.describe('Game navigation slots', () => {
|
||||
const gameNavs = page.locator('h1:has-text("Memory Pairs"), h1:has-text("Memory Lightning")')
|
||||
await expect(gameNavs).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test.describe('Sound Settings Persistence', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -14,13 +14,13 @@ test.describe('Sound Settings Persistence', () => {
|
||||
await page.getByRole('button', { name: /style/i }).click()
|
||||
|
||||
// Find and toggle the sound switch (should be off by default)
|
||||
const soundSwitch = page.locator('[role="switch"]').filter({ hasText: /sound/i }).or(
|
||||
page.locator('input[type="checkbox"]').filter({ hasText: /sound/i })
|
||||
).or(
|
||||
page.getByLabel(/sound/i)
|
||||
).or(
|
||||
page.locator('button').filter({ hasText: /sound/i })
|
||||
).first()
|
||||
const soundSwitch = page
|
||||
.locator('[role="switch"]')
|
||||
.filter({ hasText: /sound/i })
|
||||
.or(page.locator('input[type="checkbox"]').filter({ hasText: /sound/i }))
|
||||
.or(page.getByLabel(/sound/i))
|
||||
.or(page.locator('button').filter({ hasText: /sound/i }))
|
||||
.first()
|
||||
|
||||
await soundSwitch.click()
|
||||
|
||||
@@ -37,13 +37,13 @@ test.describe('Sound Settings Persistence', () => {
|
||||
await page.reload()
|
||||
await page.getByRole('button', { name: /style/i }).click()
|
||||
|
||||
const soundSwitchAfterReload = page.locator('[role="switch"]').filter({ hasText: /sound/i }).or(
|
||||
page.locator('input[type="checkbox"]').filter({ hasText: /sound/i })
|
||||
).or(
|
||||
page.getByLabel(/sound/i)
|
||||
).or(
|
||||
page.locator('button').filter({ hasText: /sound/i })
|
||||
).first()
|
||||
const soundSwitchAfterReload = page
|
||||
.locator('[role="switch"]')
|
||||
.filter({ hasText: /sound/i })
|
||||
.or(page.locator('input[type="checkbox"]').filter({ hasText: /sound/i }))
|
||||
.or(page.getByLabel(/sound/i))
|
||||
.or(page.locator('button').filter({ hasText: /sound/i }))
|
||||
.first()
|
||||
|
||||
await expect(soundSwitchAfterReload).toBeChecked()
|
||||
})
|
||||
@@ -55,9 +55,10 @@ test.describe('Sound Settings Persistence', () => {
|
||||
await page.getByRole('button', { name: /style/i }).click()
|
||||
|
||||
// Find volume slider
|
||||
const volumeSlider = page.locator('input[type="range"]').or(
|
||||
page.locator('[role="slider"]')
|
||||
).first()
|
||||
const volumeSlider = page
|
||||
.locator('input[type="range"]')
|
||||
.or(page.locator('[role="slider"]'))
|
||||
.first()
|
||||
|
||||
// Set volume to a specific value (e.g., 0.6)
|
||||
await volumeSlider.fill('60') // Assuming 0-100 range
|
||||
@@ -75,9 +76,10 @@ test.describe('Sound Settings Persistence', () => {
|
||||
await page.reload()
|
||||
await page.getByRole('button', { name: /style/i }).click()
|
||||
|
||||
const volumeSliderAfterReload = page.locator('input[type="range"]').or(
|
||||
page.locator('[role="slider"]')
|
||||
).first()
|
||||
const volumeSliderAfterReload = page
|
||||
.locator('input[type="range"]')
|
||||
.or(page.locator('[role="slider"]'))
|
||||
.first()
|
||||
|
||||
const volumeValue = await volumeSliderAfterReload.inputValue()
|
||||
expect(parseFloat(volumeValue)).toBeCloseTo(60, 0) // Allow for some variance
|
||||
@@ -116,4 +118,4 @@ test.describe('Sound Settings Persistence', () => {
|
||||
expect(storedConfig.soundEnabled).toBe(true)
|
||||
expect(storedConfig.soundVolume).toBe(0.8)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
42
apps/web/eslint.config.js
Normal file
42
apps/web/eslint.config.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// Minimal ESLint flat config ONLY for react-hooks rules
|
||||
|
||||
import tsParser from '@typescript-eslint/parser'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
|
||||
const config = [
|
||||
{ ignores: ['dist', '.next', 'coverage', 'node_modules', 'styled-system', 'storybook-static'] },
|
||||
{
|
||||
files: ['**/*.tsx', '**/*.ts', '**/*.jsx', '**/*.js'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
React: 'readonly',
|
||||
JSX: 'readonly',
|
||||
console: 'readonly',
|
||||
process: 'readonly',
|
||||
module: 'readonly',
|
||||
require: 'readonly',
|
||||
window: 'readonly',
|
||||
document: 'readonly',
|
||||
localStorage: 'readonly',
|
||||
sessionStorage: 'readonly',
|
||||
fetch: 'readonly',
|
||||
global: 'readonly',
|
||||
Buffer: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
rules: {
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export default config
|
||||
@@ -1,6 +1,5 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
@@ -64,4 +63,4 @@ const nextConfig = {
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
module.exports = nextConfig
|
||||
|
||||
@@ -3,27 +3,34 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently \"next dev\" \"npx @pandacss/dev --watch\"",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"dev": "tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && concurrently \"node server.js\" \"npx @pandacss/dev --watch\"",
|
||||
"build": "node scripts/generate-build-info.js && tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && next build",
|
||||
"start": "NODE_ENV=production node server.js",
|
||||
"lint": "npx @biomejs/biome lint . && npx eslint .",
|
||||
"lint:fix": "npx @biomejs/biome lint . --write && npx eslint . --fix",
|
||||
"format": "npx @biomejs/biome format . --write",
|
||||
"format:check": "npx @biomejs/biome format .",
|
||||
"check": "npx @biomejs/biome check .",
|
||||
"pre-commit": "npm run type-check && npm run format && npm run lint:fix && npm run 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,15 +52,23 @@
|
||||
"@soroban/core": "workspace:*",
|
||||
"@soroban/templates": "workspace:*",
|
||||
"@tanstack/react-form": "^0.19.0",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"drizzle-orm": "^0.44.6",
|
||||
"emojibase-data": "^16.0.3",
|
||||
"jose": "^6.1.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"make-plural": "^7.4.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"next": "^14.2.32",
|
||||
"next-auth": "5.0.0-beta.29",
|
||||
"python-bridge": "^1.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-resizable-layout": "^0.7.3"
|
||||
"react-resizable-layout": "^0.7.3",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.1",
|
||||
@@ -62,17 +77,21 @@
|
||||
"@storybook/nextjs": "^9.1.7",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"concurrently": "^8.0.0",
|
||||
"drizzle-kit": "^0.31.5",
|
||||
"eslint": "^8.0.0",
|
||||
"eslint-config-next": "^14.0.0",
|
||||
"eslint-plugin-storybook": "^9.1.7",
|
||||
"happy-dom": "^18.0.1",
|
||||
"jsdom": "^27.0.0",
|
||||
"storybook": "^9.1.7",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^1.0.0"
|
||||
},
|
||||
|
||||
@@ -36,19 +36,81 @@ export default defineConfig({
|
||||
wood: { value: '#8B4513' },
|
||||
bead: { value: '#2C1810' },
|
||||
inactive: { value: '#D3D3D3' },
|
||||
bar: { value: '#654321' }
|
||||
}
|
||||
bar: { value: '#654321' },
|
||||
},
|
||||
},
|
||||
fonts: {
|
||||
body: { value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' },
|
||||
heading: { value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' },
|
||||
mono: { value: 'Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace' }
|
||||
body: {
|
||||
value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
},
|
||||
heading: {
|
||||
value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
},
|
||||
mono: {
|
||||
value: 'Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace',
|
||||
},
|
||||
},
|
||||
shadows: {
|
||||
card: { value: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)' },
|
||||
modal: { value: '0 25px 50px -12px rgba(0, 0, 0, 0.25)' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
modal: { value: '0 25px 50px -12px rgba(0, 0, 0, 0.25)' },
|
||||
},
|
||||
animations: {
|
||||
// Shake animation for errors (web_generator.py line 3419)
|
||||
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)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -24,4 +24,4 @@ export default defineConfig({
|
||||
url: 'http://localhost:3002',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
445
apps/web/pnpm-lock.yaml
generated
Normal file
445
apps/web/pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,445 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.2.8
|
||||
version: 1.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@soroban/abacus-react':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/abacus-react
|
||||
'@soroban/client':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/core/client/typescript
|
||||
'@soroban/core':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/core/client/node
|
||||
'@soroban/templates':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/templates
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1(react@18.3.1)
|
||||
|
||||
packages:
|
||||
|
||||
'@floating-ui/core@1.7.3':
|
||||
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
|
||||
|
||||
'@floating-ui/dom@1.7.4':
|
||||
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
|
||||
|
||||
'@floating-ui/react-dom@2.1.6':
|
||||
resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@floating-ui/utils@0.2.10':
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
|
||||
'@radix-ui/primitive@1.1.3':
|
||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7':
|
||||
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.2':
|
||||
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-context@1.1.2':
|
||||
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.11':
|
||||
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-id@1.1.1':
|
||||
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popper@1.2.8':
|
||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-portal@1.1.9':
|
||||
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-presence@1.1.5':
|
||||
resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-primitive@2.1.3':
|
||||
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-slot@1.2.3':
|
||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.8':
|
||||
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1':
|
||||
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-controllable-state@1.2.2':
|
||||
resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-effect-event@0.0.2':
|
||||
resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-escape-keydown@1.1.1':
|
||||
resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.1':
|
||||
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-rect@1.1.1':
|
||||
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-size@1.1.1':
|
||||
resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-visually-hidden@1.2.3':
|
||||
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/rect@1.1.1':
|
||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
react-dom@18.3.1:
|
||||
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
|
||||
peerDependencies:
|
||||
react: ^18.3.1
|
||||
|
||||
react@18.3.1:
|
||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
scheduler@0.23.2:
|
||||
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@floating-ui/core@1.7.3':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/dom@1.7.4':
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.3
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/react-dom@2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.4
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@floating-ui/utils@0.2.10': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.2(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-context@1.1.2(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(react@18.3.1)
|
||||
'@radix-ui/react-use-escape-keydown': 1.1.1(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@radix-ui/react-id@1.1.1(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-popper@1.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-arrow': 1.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
|
||||
'@radix-ui/react-context': 1.1.2(react@18.3.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(react@18.3.1)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
|
||||
'@radix-ui/react-use-rect': 1.1.1(react@18.3.1)
|
||||
'@radix-ui/react-use-size': 1.1.1(react@18.3.1)
|
||||
'@radix-ui/rect': 1.1.1
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@radix-ui/react-portal@1.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@radix-ui/react-presence@1.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@radix-ui/react-primitive@2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-slot': 1.2.3(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@radix-ui/react-slot@1.2.3(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
|
||||
'@radix-ui/react-context': 1.1.2(react@18.3.1)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-id': 1.1.1(react@18.3.1)
|
||||
'@radix-ui/react-popper': 1.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-portal': 1.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-presence': 1.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-slot': 1.2.3(react@18.3.1)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(react@18.3.1)
|
||||
'@radix-ui/react-visually-hidden': 1.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-use-controllable-state@1.2.2(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-effect-event': 0.0.2(react@18.3.1)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-use-effect-event@0.0.2(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-use-escape-keydown@1.1.1(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(react@18.3.1)
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.1(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-use-rect@1.1.1(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/rect': 1.1.1
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-use-size@1.1.1(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
|
||||
react: 18.3.1
|
||||
|
||||
'@radix-ui/react-visually-hidden@1.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@radix-ui/rect@1.1.1': {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
react-dom@18.3.1(react@18.3.1):
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
react: 18.3.1
|
||||
scheduler: 0.23.2
|
||||
|
||||
react@18.3.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
scheduler@0.23.2:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
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))
|
||||
49
apps/web/server.js
Normal file
49
apps/web/server.js
Normal file
@@ -0,0 +1,49 @@
|
||||
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...')
|
||||
const { migrate } = require('drizzle-orm/better-sqlite3/migrator')
|
||||
const { db } = require('./src/db/index.js')
|
||||
|
||||
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
|
||||
const { initializeSocketServer } = require('./socket-server.js')
|
||||
initializeSocketServer(server)
|
||||
|
||||
server
|
||||
.once('error', (err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
.listen(port, () => {
|
||||
console.log(`> Ready on http://${hostname}:${port}`)
|
||||
})
|
||||
})
|
||||
319
apps/web/socket-server.js
Normal file
319
apps/web/socket-server.js
Normal file
@@ -0,0 +1,319 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.getSocketIO = getSocketIO;
|
||||
exports.initializeSocketServer = initializeSocketServer;
|
||||
const socket_io_1 = require("socket.io");
|
||||
const session_manager_1 = require("./src/lib/arcade/session-manager");
|
||||
const room_manager_1 = require("./src/lib/arcade/room-manager");
|
||||
const room_membership_1 = require("./src/lib/arcade/room-membership");
|
||||
const player_manager_1 = require("./src/lib/arcade/player-manager");
|
||||
const MatchingGameValidator_1 = require("./src/lib/arcade/validation/MatchingGameValidator");
|
||||
/**
|
||||
* Get the socket.io server instance
|
||||
* Returns null if not initialized
|
||||
*/
|
||||
function getSocketIO() {
|
||||
return globalThis.__socketIO || null;
|
||||
}
|
||||
function initializeSocketServer(httpServer) {
|
||||
const io = new socket_io_1.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);
|
||||
let currentUserId = null;
|
||||
// Join arcade session room
|
||||
socket.on('join-arcade-session', async ({ userId, roomId }) => {
|
||||
currentUserId = userId;
|
||||
socket.join(`arcade:${userId}`);
|
||||
console.log(`👤 User ${userId} joined arcade room`);
|
||||
// If this session is part of a room, also join the game room for multi-user sync
|
||||
if (roomId) {
|
||||
socket.join(`game:${roomId}`);
|
||||
console.log(`🎮 User ${userId} joined game room ${roomId}`);
|
||||
}
|
||||
// Send current session state if exists
|
||||
// For room-based games, look up shared room session
|
||||
try {
|
||||
const session = roomId
|
||||
? await (0, session_manager_1.getArcadeSessionByRoom)(roomId)
|
||||
: await (0, session_manager_1.getArcadeSession)(userId);
|
||||
if (session) {
|
||||
console.log('[join-arcade-session] Found session:', {
|
||||
userId,
|
||||
roomId,
|
||||
version: session.version,
|
||||
sessionUserId: session.userId,
|
||||
});
|
||||
socket.emit('session-state', {
|
||||
gameState: session.gameState,
|
||||
currentGame: session.currentGame,
|
||||
gameUrl: session.gameUrl,
|
||||
activePlayers: session.activePlayers,
|
||||
version: session.version,
|
||||
});
|
||||
}
|
||||
else {
|
||||
console.log('[join-arcade-session] No active session found for:', {
|
||||
userId,
|
||||
roomId,
|
||||
});
|
||||
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) => {
|
||||
console.log('🎮 Game move received:', {
|
||||
userId: data.userId,
|
||||
moveType: data.move.type,
|
||||
playerId: data.move.playerId,
|
||||
timestamp: data.move.timestamp,
|
||||
roomId: data.roomId,
|
||||
fullMove: JSON.stringify(data.move, null, 2),
|
||||
});
|
||||
try {
|
||||
// Special handling for START_GAME - create session if it doesn't exist
|
||||
if (data.move.type === 'START_GAME') {
|
||||
// For room-based games, check if room session exists
|
||||
const existingSession = data.roomId
|
||||
? await (0, session_manager_1.getArcadeSessionByRoom)(data.roomId)
|
||||
: await (0, session_manager_1.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?.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_1.matchingGameValidator.getInitialState({
|
||||
difficulty: 6,
|
||||
gameType: 'abacus-numeral',
|
||||
turnTimer: 30,
|
||||
});
|
||||
// Check if user is already in a room for this game
|
||||
const userRoomIds = await (0, room_membership_1.getUserRooms)(data.userId);
|
||||
let room = null;
|
||||
// Look for an existing active room for this game
|
||||
for (const roomId of userRoomIds) {
|
||||
const existingRoom = await (0, room_manager_1.getRoomById)(roomId);
|
||||
if (existingRoom &&
|
||||
existingRoom.gameName === 'matching' &&
|
||||
existingRoom.status !== 'finished') {
|
||||
room = existingRoom;
|
||||
console.log('🏠 Using existing room:', room.code);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If no suitable room exists, create a new one
|
||||
if (!room) {
|
||||
room = await (0, room_manager_1.createRoom)({
|
||||
name: 'Auto-generated Room',
|
||||
createdBy: data.userId,
|
||||
creatorName: 'Player',
|
||||
gameName: 'matching',
|
||||
gameConfig: {
|
||||
difficulty: 6,
|
||||
gameType: 'abacus-numeral',
|
||||
turnTimer: 30,
|
||||
},
|
||||
ttlMinutes: 60,
|
||||
});
|
||||
console.log('🏠 Created new room:', room.code);
|
||||
}
|
||||
// Now create the session linked to the room
|
||||
await (0, session_manager_1.createArcadeSession)({
|
||||
userId: data.userId,
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/room', // Room-based sessions use /arcade/room
|
||||
initialState,
|
||||
activePlayers,
|
||||
roomId: room.id,
|
||||
});
|
||||
console.log('✅ Session created successfully with room association');
|
||||
// Notify all connected clients about the new session
|
||||
const newSession = await (0, session_manager_1.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');
|
||||
}
|
||||
}
|
||||
}
|
||||
// Apply game move - use roomId for room-based games to access shared session
|
||||
const result = await (0, session_manager_1.applyGameMove)(data.userId, data.move, data.roomId);
|
||||
if (result.success && result.session) {
|
||||
const moveAcceptedData = {
|
||||
gameState: result.session.gameState,
|
||||
version: result.session.version,
|
||||
move: data.move,
|
||||
};
|
||||
// Broadcast the updated state to all devices for this user
|
||||
io.to(`arcade:${data.userId}`).emit('move-accepted', moveAcceptedData);
|
||||
// If this is a room-based session, ALSO broadcast to all users in the room
|
||||
if (result.session.roomId) {
|
||||
io.to(`game:${result.session.roomId}`).emit('move-accepted', moveAcceptedData);
|
||||
console.log(`📢 Broadcasted move to game room ${result.session.roomId}`);
|
||||
}
|
||||
// Update activity timestamp
|
||||
await (0, session_manager_1.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 }) => {
|
||||
console.log('🚪 User exiting arcade session:', userId);
|
||||
try {
|
||||
await (0, session_manager_1.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 }) => {
|
||||
try {
|
||||
await (0, session_manager_1.updateSessionActivity)(userId);
|
||||
socket.emit('pong-session');
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error updating activity:', error);
|
||||
}
|
||||
});
|
||||
// Room: Join
|
||||
socket.on('join-room', async ({ roomId, userId }) => {
|
||||
console.log(`🏠 User ${userId} joining room ${roomId}`);
|
||||
try {
|
||||
// Join the socket room
|
||||
socket.join(`room:${roomId}`);
|
||||
// Mark member as online
|
||||
await (0, room_membership_1.setMemberOnline)(roomId, userId, true);
|
||||
// Get room data
|
||||
const members = await (0, room_membership_1.getRoomMembers)(roomId);
|
||||
const memberPlayers = await (0, player_manager_1.getRoomActivePlayers)(roomId);
|
||||
// Convert memberPlayers Map to object for JSON serialization
|
||||
const memberPlayersObj = {};
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players;
|
||||
}
|
||||
// Send current room state to the joining user
|
||||
socket.emit('room-joined', {
|
||||
roomId,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
});
|
||||
// Notify all other members in the room
|
||||
socket.to(`room:${roomId}`).emit('member-joined', {
|
||||
roomId,
|
||||
userId,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
});
|
||||
console.log(`✅ User ${userId} joined room ${roomId}`);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error joining room:', error);
|
||||
socket.emit('room-error', { error: 'Failed to join room' });
|
||||
}
|
||||
});
|
||||
// Room: Leave
|
||||
socket.on('leave-room', async ({ roomId, userId }) => {
|
||||
console.log(`🚪 User ${userId} leaving room ${roomId}`);
|
||||
try {
|
||||
// Leave the socket room
|
||||
socket.leave(`room:${roomId}`);
|
||||
// Mark member as offline
|
||||
await (0, room_membership_1.setMemberOnline)(roomId, userId, false);
|
||||
// Get updated members
|
||||
const members = await (0, room_membership_1.getRoomMembers)(roomId);
|
||||
const memberPlayers = await (0, player_manager_1.getRoomActivePlayers)(roomId);
|
||||
// Convert memberPlayers Map to object
|
||||
const memberPlayersObj = {};
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players;
|
||||
}
|
||||
// Notify remaining members
|
||||
io.to(`room:${roomId}`).emit('member-left', {
|
||||
roomId,
|
||||
userId,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
});
|
||||
console.log(`✅ User ${userId} left room ${roomId}`);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error leaving room:', error);
|
||||
}
|
||||
});
|
||||
// Room: Players updated
|
||||
socket.on('players-updated', async ({ roomId, userId }) => {
|
||||
console.log(`🎯 Players updated for user ${userId} in room ${roomId}`);
|
||||
try {
|
||||
// Get updated player data
|
||||
const memberPlayers = await (0, player_manager_1.getRoomActivePlayers)(roomId);
|
||||
// Convert memberPlayers Map to object
|
||||
const memberPlayersObj = {};
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players;
|
||||
}
|
||||
// Broadcast to all members in the room (including sender)
|
||||
io.to(`room:${roomId}`).emit('room-players-updated', {
|
||||
roomId,
|
||||
memberPlayers: memberPlayersObj,
|
||||
});
|
||||
console.log(`✅ Broadcasted player updates for room ${roomId}`);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error updating room players:', error);
|
||||
socket.emit('room-error', { error: 'Failed to update players' });
|
||||
}
|
||||
});
|
||||
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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
// Store in globalThis to make accessible across module boundaries
|
||||
globalThis.__socketIO = io;
|
||||
console.log('✅ Socket.IO initialized on /api/socket');
|
||||
return io;
|
||||
}
|
||||
375
apps/web/socket-server.ts
Normal file
375
apps/web/socket-server.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import type { Server as HTTPServer } from 'http'
|
||||
import { Server as SocketIOServer } from 'socket.io'
|
||||
import type { Server as SocketIOServerType } from 'socket.io'
|
||||
import {
|
||||
applyGameMove,
|
||||
createArcadeSession,
|
||||
deleteArcadeSession,
|
||||
getArcadeSession,
|
||||
getArcadeSessionByRoom,
|
||||
updateSessionActivity,
|
||||
} from './src/lib/arcade/session-manager'
|
||||
import { createRoom, getRoomById } from './src/lib/arcade/room-manager'
|
||||
import { getRoomMembers, getUserRooms, setMemberOnline } from './src/lib/arcade/room-membership'
|
||||
import { getRoomActivePlayers } from './src/lib/arcade/player-manager'
|
||||
import type { GameMove, GameName } from './src/lib/arcade/validation'
|
||||
import { matchingGameValidator } from './src/lib/arcade/validation/MatchingGameValidator'
|
||||
|
||||
// Use globalThis to store socket.io instance to avoid module isolation issues
|
||||
// This ensures the same instance is accessible across dynamic imports
|
||||
declare global {
|
||||
var __socketIO: SocketIOServerType | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the socket.io server instance
|
||||
* Returns null if not initialized
|
||||
*/
|
||||
export function getSocketIO(): SocketIOServerType | null {
|
||||
return globalThis.__socketIO || null
|
||||
}
|
||||
|
||||
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, roomId }: { userId: string; roomId?: string }) => {
|
||||
currentUserId = userId
|
||||
socket.join(`arcade:${userId}`)
|
||||
console.log(`👤 User ${userId} joined arcade room`)
|
||||
|
||||
// If this session is part of a room, also join the game room for multi-user sync
|
||||
if (roomId) {
|
||||
socket.join(`game:${roomId}`)
|
||||
console.log(`🎮 User ${userId} joined game room ${roomId}`)
|
||||
}
|
||||
|
||||
// Send current session state if exists
|
||||
// For room-based games, look up shared room session
|
||||
try {
|
||||
const session = roomId
|
||||
? await getArcadeSessionByRoom(roomId)
|
||||
: await getArcadeSession(userId)
|
||||
|
||||
if (session) {
|
||||
console.log('[join-arcade-session] Found session:', {
|
||||
userId,
|
||||
roomId,
|
||||
version: session.version,
|
||||
sessionUserId: session.userId,
|
||||
})
|
||||
socket.emit('session-state', {
|
||||
gameState: session.gameState,
|
||||
currentGame: session.currentGame,
|
||||
gameUrl: session.gameUrl,
|
||||
activePlayers: session.activePlayers,
|
||||
version: session.version,
|
||||
})
|
||||
} else {
|
||||
console.log('[join-arcade-session] No active session found for:', {
|
||||
userId,
|
||||
roomId,
|
||||
})
|
||||
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; roomId?: string }) => {
|
||||
console.log('🎮 Game move received:', {
|
||||
userId: data.userId,
|
||||
moveType: data.move.type,
|
||||
playerId: data.move.playerId,
|
||||
timestamp: data.move.timestamp,
|
||||
roomId: data.roomId,
|
||||
fullMove: JSON.stringify(data.move, null, 2),
|
||||
})
|
||||
|
||||
try {
|
||||
// Special handling for START_GAME - create session if it doesn't exist
|
||||
if (data.move.type === 'START_GAME') {
|
||||
// For room-based games, check if room session exists
|
||||
const existingSession = data.roomId
|
||||
? await getArcadeSessionByRoom(data.roomId)
|
||||
: 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,
|
||||
})
|
||||
|
||||
// Check if user is already in a room for this game
|
||||
const userRoomIds = await getUserRooms(data.userId)
|
||||
let room = null
|
||||
|
||||
// Look for an existing active room for this game
|
||||
for (const roomId of userRoomIds) {
|
||||
const existingRoom = await getRoomById(roomId)
|
||||
if (
|
||||
existingRoom &&
|
||||
existingRoom.gameName === 'matching' &&
|
||||
existingRoom.status !== 'finished'
|
||||
) {
|
||||
room = existingRoom
|
||||
console.log('🏠 Using existing room:', room.code)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no suitable room exists, create a new one
|
||||
if (!room) {
|
||||
room = await createRoom({
|
||||
name: 'Auto-generated Room',
|
||||
createdBy: data.userId,
|
||||
creatorName: 'Player',
|
||||
gameName: 'matching' as GameName,
|
||||
gameConfig: {
|
||||
difficulty: 6,
|
||||
gameType: 'abacus-numeral',
|
||||
turnTimer: 30,
|
||||
},
|
||||
ttlMinutes: 60,
|
||||
})
|
||||
console.log('🏠 Created new room:', room.code)
|
||||
}
|
||||
|
||||
// Now create the session linked to the room
|
||||
await createArcadeSession({
|
||||
userId: data.userId,
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/room', // Room-based sessions use /arcade/room
|
||||
initialState,
|
||||
activePlayers,
|
||||
roomId: room.id,
|
||||
})
|
||||
|
||||
console.log('✅ Session created successfully with room association')
|
||||
|
||||
// Notify all connected clients about the new session
|
||||
const newSession = await getArcadeSession(data.userId)
|
||||
if (newSession) {
|
||||
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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply game move - use roomId for room-based games to access shared session
|
||||
const result = await applyGameMove(data.userId, data.move, data.roomId)
|
||||
|
||||
if (result.success && result.session) {
|
||||
const moveAcceptedData = {
|
||||
gameState: result.session.gameState,
|
||||
version: result.session.version,
|
||||
move: data.move,
|
||||
}
|
||||
|
||||
// Broadcast the updated state to all devices for this user
|
||||
io!.to(`arcade:${data.userId}`).emit('move-accepted', moveAcceptedData)
|
||||
|
||||
// If this is a room-based session, ALSO broadcast to all users in the room
|
||||
if (result.session.roomId) {
|
||||
io!.to(`game:${result.session.roomId}`).emit('move-accepted', moveAcceptedData)
|
||||
console.log(`📢 Broadcasted move to game room ${result.session.roomId}`)
|
||||
}
|
||||
|
||||
// Update activity timestamp
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
// Room: Join
|
||||
socket.on('join-room', async ({ roomId, userId }: { roomId: string; userId: string }) => {
|
||||
console.log(`🏠 User ${userId} joining room ${roomId}`)
|
||||
|
||||
try {
|
||||
// Join the socket room
|
||||
socket.join(`room:${roomId}`)
|
||||
|
||||
// Mark member as online
|
||||
await setMemberOnline(roomId, userId, true)
|
||||
|
||||
// Get room data
|
||||
const members = await getRoomMembers(roomId)
|
||||
const memberPlayers = await getRoomActivePlayers(roomId)
|
||||
|
||||
// Convert memberPlayers Map to object for JSON serialization
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
// Send current room state to the joining user
|
||||
socket.emit('room-joined', {
|
||||
roomId,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
// Notify all other members in the room
|
||||
socket.to(`room:${roomId}`).emit('member-joined', {
|
||||
roomId,
|
||||
userId,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
console.log(`✅ User ${userId} joined room ${roomId}`)
|
||||
} catch (error) {
|
||||
console.error('Error joining room:', error)
|
||||
socket.emit('room-error', { error: 'Failed to join room' })
|
||||
}
|
||||
})
|
||||
|
||||
// Room: Leave
|
||||
socket.on('leave-room', async ({ roomId, userId }: { roomId: string; userId: string }) => {
|
||||
console.log(`🚪 User ${userId} leaving room ${roomId}`)
|
||||
|
||||
try {
|
||||
// Leave the socket room
|
||||
socket.leave(`room:${roomId}`)
|
||||
|
||||
// Mark member as offline
|
||||
await setMemberOnline(roomId, userId, false)
|
||||
|
||||
// Get updated members
|
||||
const members = await getRoomMembers(roomId)
|
||||
const memberPlayers = await getRoomActivePlayers(roomId)
|
||||
|
||||
// Convert memberPlayers Map to object
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
// Notify remaining members
|
||||
io!.to(`room:${roomId}`).emit('member-left', {
|
||||
roomId,
|
||||
userId,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
console.log(`✅ User ${userId} left room ${roomId}`)
|
||||
} catch (error) {
|
||||
console.error('Error leaving room:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// Room: Players updated
|
||||
socket.on('players-updated', async ({ roomId, userId }: { roomId: string; userId: string }) => {
|
||||
console.log(`🎯 Players updated for user ${userId} in room ${roomId}`)
|
||||
|
||||
try {
|
||||
// Get updated player data
|
||||
const memberPlayers = await getRoomActivePlayers(roomId)
|
||||
|
||||
// Convert memberPlayers Map to object
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
// Broadcast to all members in the room (including sender)
|
||||
io!.to(`room:${roomId}`).emit('room-players-updated', {
|
||||
roomId,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
console.log(`✅ Broadcasted player updates for room ${roomId}`)
|
||||
} catch (error) {
|
||||
console.error('Error updating room players:', error)
|
||||
socket.emit('room-error', { error: 'Failed to update players' })
|
||||
}
|
||||
})
|
||||
|
||||
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`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Store in globalThis to make accessible across module boundaries
|
||||
globalThis.__socketIO = io
|
||||
console.log('✅ Socket.IO initialized on /api/socket')
|
||||
return io
|
||||
}
|
||||
@@ -1,62 +1,33 @@
|
||||
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 ClientProviders
|
||||
vi.mock('../../components/ClientProviders', () => ({
|
||||
ClientProviders: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="client-providers">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// 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>
|
||||
describe('RootLayout', () => {
|
||||
it('renders children with ClientProviders', () => {
|
||||
const pageContent = <div>Page content</div>
|
||||
|
||||
render(
|
||||
<RootLayout nav={navContent}>
|
||||
{pageContent}
|
||||
</RootLayout>
|
||||
)
|
||||
render(<RootLayout>{pageContent}</RootLayout>)
|
||||
|
||||
expect(screen.getByTestId('app-nav-bar')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('nav-slot-content')).toBeInTheDocument()
|
||||
expect(screen.getByText('Memory Lightning')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('client-providers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Page content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('works without nav slot', () => {
|
||||
const pageContent = <div>Page content</div>
|
||||
it('renders html and body tags', () => {
|
||||
const pageContent = <div>Test content</div>
|
||||
|
||||
render(
|
||||
<RootLayout nav={null}>
|
||||
{pageContent}
|
||||
</RootLayout>
|
||||
)
|
||||
const { container } = render(<RootLayout>{pageContent}</RootLayout>)
|
||||
|
||||
expect(screen.getByTestId('app-nav-bar')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('nav-slot-content')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Page content')).toBeInTheDocument()
|
||||
const html = container.querySelector('html')
|
||||
const body = container.querySelector('body')
|
||||
|
||||
expect(html).toBeInTheDocument()
|
||||
expect(html).toHaveAttribute('lang', 'en')
|
||||
expect(body).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { useState } from 'react'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
export default function AbacusTestPage() {
|
||||
const [value, setValue] = useState(0)
|
||||
@@ -15,32 +15,36 @@ export default function AbacusTestPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
bg: 'gray.50',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '4'
|
||||
})}>
|
||||
<div
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
bg: 'gray.50',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '4',
|
||||
})}
|
||||
>
|
||||
{/* Debug info */}
|
||||
<div className={css({
|
||||
position: 'absolute',
|
||||
top: '4',
|
||||
left: '4',
|
||||
bg: 'white',
|
||||
p: '3',
|
||||
rounded: 'md',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
fontSize: 'sm',
|
||||
fontFamily: 'mono'
|
||||
})}>
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '4',
|
||||
left: '4',
|
||||
bg: 'white',
|
||||
p: '3',
|
||||
rounded: 'md',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
fontSize: 'sm',
|
||||
fontFamily: 'mono',
|
||||
})}
|
||||
>
|
||||
<div>Current Value: {value}</div>
|
||||
<div>{debugInfo}</div>
|
||||
<button
|
||||
@@ -53,7 +57,7 @@ export default function AbacusTestPage() {
|
||||
color: 'white',
|
||||
rounded: 'sm',
|
||||
fontSize: 'xs',
|
||||
cursor: 'pointer'
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
Reset to 0
|
||||
@@ -68,20 +72,22 @@ export default function AbacusTestPage() {
|
||||
color: 'white',
|
||||
rounded: 'sm',
|
||||
fontSize: 'xs',
|
||||
cursor: 'pointer'
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
Set to 12345
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AbacusReact
|
||||
value={value}
|
||||
columns={5}
|
||||
@@ -97,4 +103,4 @@ export default function AbacusTestPage() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
101
apps/web/src/app/api/abacus-settings/route.ts
Normal file
101
apps/web/src/app/api/abacus-settings/route.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db } from '@/db'
|
||||
import * as schema from '@/db/schema'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* GET /api/abacus-settings
|
||||
* Fetch abacus display settings for the current user
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const viewerId = await getViewerId()
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
|
||||
// Find or create abacus settings
|
||||
let settings = await db.query.abacusSettings.findFirst({
|
||||
where: eq(schema.abacusSettings.userId, user.id),
|
||||
})
|
||||
|
||||
// If no settings exist, create with defaults
|
||||
if (!settings) {
|
||||
const [newSettings] = await db
|
||||
.insert(schema.abacusSettings)
|
||||
.values({ userId: user.id })
|
||||
.returning()
|
||||
settings = newSettings
|
||||
}
|
||||
|
||||
return NextResponse.json({ settings })
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch abacus settings:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch abacus settings' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/abacus-settings
|
||||
* Update abacus display settings for the current user
|
||||
*/
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Security: Strip userId from request body - it must come from session only
|
||||
const { userId: _, ...updates } = body
|
||||
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
|
||||
// Ensure settings exist
|
||||
const existingSettings = await db.query.abacusSettings.findFirst({
|
||||
where: eq(schema.abacusSettings.userId, user.id),
|
||||
})
|
||||
|
||||
if (!existingSettings) {
|
||||
// Create new settings with updates
|
||||
const [newSettings] = await db
|
||||
.insert(schema.abacusSettings)
|
||||
.values({ userId: user.id, ...updates })
|
||||
.returning()
|
||||
return NextResponse.json({ settings: newSettings })
|
||||
}
|
||||
|
||||
// Update existing settings
|
||||
const [updatedSettings] = await db
|
||||
.update(schema.abacusSettings)
|
||||
.set(updates)
|
||||
.where(eq(schema.abacusSettings.userId, user.id))
|
||||
.returning()
|
||||
|
||||
return NextResponse.json({ settings: updatedSettings })
|
||||
} catch (error) {
|
||||
console.error('Failed to update abacus settings:', error)
|
||||
return NextResponse.json({ error: 'Failed to update abacus settings' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a user record for the given viewer ID (guest or user)
|
||||
*/
|
||||
async function getOrCreateUser(viewerId: string) {
|
||||
// Try to find existing user by guest ID
|
||||
let user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
})
|
||||
|
||||
// If no user exists, create one
|
||||
if (!user) {
|
||||
const [newUser] = await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
guestId: viewerId,
|
||||
})
|
||||
.returning()
|
||||
|
||||
user = newUser
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
176
apps/web/src/app/api/arcade-session/__tests__/route.test.ts
Normal file
176
apps/web/src/app/api/arcade-session/__tests__/route.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { db, schema } from '@/db'
|
||||
import { deleteArcadeSession } from '@/lib/arcade/session-manager'
|
||||
import { DELETE, GET, POST } from '../route'
|
||||
|
||||
describe('Arcade Session API Routes', () => {
|
||||
const testUserId = 'test-user-for-api-routes'
|
||||
const testGuestId = 'test-guest-id-api-routes'
|
||||
const baseUrl = 'http://localhost:3000'
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test user
|
||||
await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
id: testUserId,
|
||||
guestId: testGuestId,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up
|
||||
await deleteArcadeSession(testUserId)
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
||||
})
|
||||
|
||||
describe('POST /api/arcade-session', () => {
|
||||
it('should create a new session', async () => {
|
||||
const request = new NextRequest(`${baseUrl}/api/arcade-session`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
userId: testUserId,
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: { test: 'state' },
|
||||
activePlayers: [1],
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.session).toBeDefined()
|
||||
expect(data.session.currentGame).toBe('matching')
|
||||
expect(data.session.version).toBe(1)
|
||||
})
|
||||
|
||||
it('should return 400 for missing fields', async () => {
|
||||
const request = new NextRequest(`${baseUrl}/api/arcade-session`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
userId: testUserId,
|
||||
// Missing required fields
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.error).toBe('Missing required fields')
|
||||
})
|
||||
|
||||
it('should return 500 for non-existent user (foreign key constraint)', async () => {
|
||||
const request = new NextRequest(`${baseUrl}/api/arcade-session`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
userId: 'non-existent-user',
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: {},
|
||||
activePlayers: [1],
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/arcade-session', () => {
|
||||
it('should retrieve an existing session', async () => {
|
||||
// Create session first
|
||||
const createRequest = new NextRequest(`${baseUrl}/api/arcade-session`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
userId: testUserId,
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: { test: 'state' },
|
||||
activePlayers: [1],
|
||||
}),
|
||||
})
|
||||
await POST(createRequest)
|
||||
|
||||
// Now retrieve it
|
||||
const request = new NextRequest(`${baseUrl}/api/arcade-session?userId=${testUserId}`)
|
||||
|
||||
const response = await GET(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.session).toBeDefined()
|
||||
expect(data.session.currentGame).toBe('matching')
|
||||
})
|
||||
|
||||
it('should return 404 for non-existent session', async () => {
|
||||
const request = new NextRequest(`${baseUrl}/api/arcade-session?userId=non-existent`)
|
||||
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('should return 400 for missing userId', async () => {
|
||||
const request = new NextRequest(`${baseUrl}/api/arcade-session`)
|
||||
|
||||
const response = await GET(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.error).toBe('userId required')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/arcade-session', () => {
|
||||
it('should delete an existing session', async () => {
|
||||
// Create session first
|
||||
const createRequest = new NextRequest(`${baseUrl}/api/arcade-session`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
userId: testUserId,
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: {},
|
||||
activePlayers: [1],
|
||||
}),
|
||||
})
|
||||
await POST(createRequest)
|
||||
|
||||
// Now delete it
|
||||
const request = new NextRequest(`${baseUrl}/api/arcade-session?userId=${testUserId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const response = await DELETE(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
|
||||
// Verify it's deleted
|
||||
const getRequest = new NextRequest(`${baseUrl}/api/arcade-session?userId=${testUserId}`)
|
||||
const getResponse = await GET(getRequest)
|
||||
expect(getResponse.status).toBe(404)
|
||||
})
|
||||
|
||||
it('should return 400 for missing userId', async () => {
|
||||
const request = new NextRequest(`${baseUrl}/api/arcade-session`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const response = await DELETE(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.error).toBe('userId required')
|
||||
})
|
||||
})
|
||||
})
|
||||
106
apps/web/src/app/api/arcade-session/route.ts
Normal file
106
apps/web/src/app/api/arcade-session/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
createArcadeSession,
|
||||
deleteArcadeSession,
|
||||
getArcadeSession,
|
||||
} from '@/lib/arcade/session-manager'
|
||||
import type { GameName } from '@/lib/arcade/validation'
|
||||
|
||||
/**
|
||||
* GET /api/arcade-session?userId=xxx
|
||||
* Get the active arcade session for a user
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const userId = request.nextUrl.searchParams.get('userId')
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'userId required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const session = await getArcadeSession(userId)
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'No active session' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
session: {
|
||||
currentGame: session.currentGame,
|
||||
gameUrl: session.gameUrl,
|
||||
gameState: session.gameState,
|
||||
activePlayers: session.activePlayers,
|
||||
version: session.version,
|
||||
expiresAt: session.expiresAt,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching arcade session:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/arcade-session
|
||||
* Create a new arcade session
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { userId, gameName, gameUrl, initialState, activePlayers, roomId } = body
|
||||
|
||||
if (!userId || !gameName || !gameUrl || !initialState || !activePlayers || !roomId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Missing required fields (userId, gameName, gameUrl, initialState, activePlayers, roomId)',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const session = await createArcadeSession({
|
||||
userId,
|
||||
gameName: gameName as GameName,
|
||||
gameUrl,
|
||||
initialState,
|
||||
activePlayers,
|
||||
roomId,
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
125
apps/web/src/app/api/arcade/rooms/[roomId]/join/route.ts
Normal file
125
apps/web/src/app/api/arcade/rooms/[roomId]/join/route.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getRoomById, touchRoom } from '@/lib/arcade/room-manager'
|
||||
import { addRoomMember, getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getActivePlayers, getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/arcade/rooms/:roomId/join
|
||||
* Join a room
|
||||
* Body:
|
||||
* - displayName?: string (optional, will generate from viewerId if not provided)
|
||||
*/
|
||||
export async function POST(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json().catch(() => ({}))
|
||||
|
||||
// Get room
|
||||
const room = await getRoomById(roomId)
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if room is locked
|
||||
if (room.isLocked) {
|
||||
return NextResponse.json({ error: 'Room is locked' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get or generate display name
|
||||
const displayName = body.displayName || `Guest ${viewerId.slice(-4)}`
|
||||
|
||||
// Validate display name length
|
||||
if (displayName.length > 50) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Display name too long (max 50 characters)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Add member (with auto-leave logic for modal room enforcement)
|
||||
const { member, autoLeaveResult } = await addRoomMember({
|
||||
roomId,
|
||||
userId: viewerId,
|
||||
displayName,
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// Fetch user's active players (these will participate in the game)
|
||||
const activePlayers = await getActivePlayers(viewerId)
|
||||
|
||||
// Update room activity to refresh TTL
|
||||
await touchRoom(roomId)
|
||||
|
||||
// Broadcast to all users in the room via socket
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
const members = await getRoomMembers(roomId)
|
||||
const memberPlayers = await getRoomActivePlayers(roomId)
|
||||
|
||||
// Convert memberPlayers Map to object for JSON serialization
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
// Broadcast to all users in this room
|
||||
io.to(`room:${roomId}`).emit('member-joined', {
|
||||
roomId,
|
||||
userId: viewerId,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
console.log(`[Join API] Broadcasted member-joined for user ${viewerId} in room ${roomId}`)
|
||||
} catch (socketError) {
|
||||
// Log but don't fail the request if socket broadcast fails
|
||||
console.error('[Join API] Failed to broadcast member-joined:', socketError)
|
||||
}
|
||||
}
|
||||
|
||||
// Build response with auto-leave info if applicable
|
||||
return NextResponse.json(
|
||||
{
|
||||
member,
|
||||
room,
|
||||
activePlayers, // The user's active players that will join the game
|
||||
autoLeave: autoLeaveResult
|
||||
? {
|
||||
leftRooms: autoLeaveResult.leftRooms,
|
||||
roomCount: autoLeaveResult.leftRooms.length,
|
||||
message: `You were automatically removed from ${autoLeaveResult.leftRooms.length} other room(s)`,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to join room:', error)
|
||||
|
||||
// Handle specific constraint violation error
|
||||
if (error.message?.includes('ROOM_MEMBERSHIP_CONFLICT')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'You are already in another room',
|
||||
code: 'ROOM_MEMBERSHIP_CONFLICT',
|
||||
message:
|
||||
'You can only be in one room at a time. Please leave your current room before joining a new one.',
|
||||
userMessage:
|
||||
'⚠️ Already in Another Room\n\nYou can only be in one room at a time. Please refresh the page and try again.',
|
||||
},
|
||||
{ status: 409 } // 409 Conflict
|
||||
)
|
||||
}
|
||||
|
||||
// Generic error
|
||||
return NextResponse.json({ error: 'Failed to join room' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
69
apps/web/src/app/api/arcade/rooms/[roomId]/leave/route.ts
Normal file
69
apps/web/src/app/api/arcade/rooms/[roomId]/leave/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getRoomById } from '@/lib/arcade/room-manager'
|
||||
import { getRoomMembers, isMember, removeMember } from '@/lib/arcade/room-membership'
|
||||
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/arcade/rooms/:roomId/leave
|
||||
* Leave a room
|
||||
*/
|
||||
export async function POST(_req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Get room
|
||||
const room = await getRoomById(roomId)
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if member
|
||||
const isMemberOfRoom = await isMember(roomId, viewerId)
|
||||
if (!isMemberOfRoom) {
|
||||
return NextResponse.json({ error: 'Not a member of this room' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Remove member
|
||||
await removeMember(roomId, viewerId)
|
||||
|
||||
// Broadcast to all remaining users in the room via socket
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
const members = await getRoomMembers(roomId)
|
||||
const memberPlayers = await getRoomActivePlayers(roomId)
|
||||
|
||||
// Convert memberPlayers Map to object for JSON serialization
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
// Broadcast to all users in this room
|
||||
io.to(`room:${roomId}`).emit('member-left', {
|
||||
roomId,
|
||||
userId: viewerId,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
console.log(`[Leave API] Broadcasted member-left for user ${viewerId} in room ${roomId}`)
|
||||
} catch (socketError) {
|
||||
// Log but don't fail the request if socket broadcast fails
|
||||
console.error('[Leave API] Failed to broadcast member-left:', socketError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to leave room:', error)
|
||||
return NextResponse.json({ error: 'Failed to leave room' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getRoomById, isRoomCreator } from '@/lib/arcade/room-manager'
|
||||
import { isMember, removeMember } from '@/lib/arcade/room-membership'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string; userId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/arcade/rooms/:roomId/members/:userId
|
||||
* Kick a member from room (creator only)
|
||||
*/
|
||||
export async function DELETE(_req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId, userId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Get room
|
||||
const room = await getRoomById(roomId)
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if requester is room creator
|
||||
const isCreator = await isRoomCreator(roomId, viewerId)
|
||||
if (!isCreator) {
|
||||
return NextResponse.json({ error: 'Only room creator can kick members' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Cannot kick self
|
||||
if (userId === viewerId) {
|
||||
return NextResponse.json({ error: 'Cannot kick yourself' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if target user is a member
|
||||
const isTargetMember = await isMember(roomId, userId)
|
||||
if (!isTargetMember) {
|
||||
return NextResponse.json({ error: 'User is not a member of this room' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Remove member
|
||||
await removeMember(roomId, userId)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to kick member:', error)
|
||||
return NextResponse.json({ error: 'Failed to kick member' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
35
apps/web/src/app/api/arcade/rooms/[roomId]/members/route.ts
Normal file
35
apps/web/src/app/api/arcade/rooms/[roomId]/members/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getRoomById } from '@/lib/arcade/room-manager'
|
||||
import { getOnlineMemberCount, getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/arcade/rooms/:roomId/members
|
||||
* Get all members in a room
|
||||
*/
|
||||
export async function GET(_req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params
|
||||
|
||||
// Get room
|
||||
const room = await getRoomById(roomId)
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get members
|
||||
const members = await getRoomMembers(roomId)
|
||||
const onlineCount = await getOnlineMemberCount(roomId)
|
||||
|
||||
return NextResponse.json({
|
||||
members,
|
||||
onlineCount,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch members:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch members' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
132
apps/web/src/app/api/arcade/rooms/[roomId]/route.ts
Normal file
132
apps/web/src/app/api/arcade/rooms/[roomId]/route.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
deleteRoom,
|
||||
getRoomById,
|
||||
isRoomCreator,
|
||||
touchRoom,
|
||||
updateRoom,
|
||||
} from '@/lib/arcade/room-manager'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/arcade/rooms/:roomId
|
||||
* Get room details including members
|
||||
*/
|
||||
export async function GET(_req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
const room = await getRoomById(roomId)
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const members = await getRoomMembers(roomId)
|
||||
const canModerate = await isRoomCreator(roomId, viewerId)
|
||||
|
||||
// Fetch active players for each member
|
||||
// This creates a map of userId -> Player[]
|
||||
const memberPlayers: Record<string, any[]> = {}
|
||||
for (const member of members) {
|
||||
const activePlayers = await getActivePlayers(member.userId)
|
||||
memberPlayers[member.userId] = activePlayers
|
||||
}
|
||||
|
||||
// Update room activity when viewing (keeps active rooms fresh)
|
||||
await touchRoom(roomId)
|
||||
|
||||
return NextResponse.json({
|
||||
room,
|
||||
members,
|
||||
memberPlayers, // Map of userId -> active Player[] for each member
|
||||
canModerate,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch room:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch room' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/arcade/rooms/:roomId
|
||||
* Update room (creator only)
|
||||
* Body:
|
||||
* - name?: string
|
||||
* - isLocked?: boolean
|
||||
* - status?: 'lobby' | 'playing' | 'finished'
|
||||
*/
|
||||
export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Check if user is room creator
|
||||
const isCreator = await isRoomCreator(roomId, viewerId)
|
||||
if (!isCreator) {
|
||||
return NextResponse.json({ error: 'Only room creator can update room' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Validate name length if provided
|
||||
if (body.name && body.name.length > 50) {
|
||||
return NextResponse.json({ error: 'Room name too long (max 50 characters)' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate status if provided
|
||||
if (body.status && !['lobby', 'playing', 'finished'].includes(body.status)) {
|
||||
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updates: {
|
||||
name?: string
|
||||
isLocked?: boolean
|
||||
status?: 'lobby' | 'playing' | 'finished'
|
||||
} = {}
|
||||
|
||||
if (body.name !== undefined) updates.name = body.name
|
||||
if (body.isLocked !== undefined) updates.isLocked = body.isLocked
|
||||
if (body.status !== undefined) updates.status = body.status
|
||||
|
||||
const room = await updateRoom(roomId, updates)
|
||||
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ room })
|
||||
} catch (error) {
|
||||
console.error('Failed to update room:', error)
|
||||
return NextResponse.json({ error: 'Failed to update room' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/arcade/rooms/:roomId
|
||||
* Delete room (creator only)
|
||||
*/
|
||||
export async function DELETE(_req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Check if user is room creator
|
||||
const isCreator = await isRoomCreator(roomId, viewerId)
|
||||
if (!isCreator) {
|
||||
return NextResponse.json({ error: 'Only room creator can delete room' }, { status: 403 })
|
||||
}
|
||||
|
||||
await deleteRoom(roomId)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to delete room:', error)
|
||||
return NextResponse.json({ error: 'Failed to delete room' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
39
apps/web/src/app/api/arcade/rooms/code/[code]/route.ts
Normal file
39
apps/web/src/app/api/arcade/rooms/code/[code]/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getRoomByCode } from '@/lib/arcade/room-manager'
|
||||
import { normalizeRoomCode } from '@/lib/arcade/room-code'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ code: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/arcade/rooms/code/:code
|
||||
* Get room by join code (for resolving codes to room IDs)
|
||||
*/
|
||||
export async function GET(_req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { code } = await context.params
|
||||
|
||||
// Normalize the code (uppercase, remove spaces/dashes)
|
||||
const normalizedCode = normalizeRoomCode(code)
|
||||
|
||||
// Get room
|
||||
const room = await getRoomByCode(normalizedCode)
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Generate redirect URL
|
||||
const baseUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:3000'
|
||||
const redirectUrl = `${baseUrl}/arcade/rooms/${room.id}`
|
||||
|
||||
return NextResponse.json({
|
||||
roomId: room.id,
|
||||
redirectUrl,
|
||||
room,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to find room by code:', error)
|
||||
return NextResponse.json({ error: 'Failed to find room by code' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
52
apps/web/src/app/api/arcade/rooms/current/route.ts
Normal file
52
apps/web/src/app/api/arcade/rooms/current/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getUserRooms } from '@/lib/arcade/room-membership'
|
||||
import { getRoomById } from '@/lib/arcade/room-manager'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* GET /api/arcade/rooms/current
|
||||
* Returns the user's current room (if any)
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const userId = await getViewerId()
|
||||
|
||||
// Get all rooms user is in (should be at most 1 due to modal room enforcement)
|
||||
const roomIds = await getUserRooms(userId)
|
||||
|
||||
if (roomIds.length === 0) {
|
||||
return NextResponse.json({ room: null }, { status: 200 })
|
||||
}
|
||||
|
||||
const roomId = roomIds[0]
|
||||
|
||||
// Get room data
|
||||
const room = await getRoomById(roomId)
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get members
|
||||
const members = await getRoomMembers(roomId)
|
||||
|
||||
// Get active players for all members
|
||||
const memberPlayers = await getRoomActivePlayers(roomId)
|
||||
|
||||
// Convert Map to object for JSON serialization
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
room,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Current Room API] Error:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch current room' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
126
apps/web/src/app/api/arcade/rooms/route.ts
Normal file
126
apps/web/src/app/api/arcade/rooms/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createRoom, listActiveRooms } from '@/lib/arcade/room-manager'
|
||||
import { addRoomMember, getRoomMembers, isMember } from '@/lib/arcade/room-membership'
|
||||
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import type { GameName } from '@/lib/arcade/validation'
|
||||
|
||||
/**
|
||||
* GET /api/arcade/rooms
|
||||
* List all active public rooms (lobby view)
|
||||
* Query params:
|
||||
* - gameName?: string - Filter by game
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const gameName = searchParams.get('gameName') as GameName | null
|
||||
|
||||
const viewerId = await getViewerId()
|
||||
const rooms = await listActiveRooms(gameName || undefined)
|
||||
|
||||
// Enrich with member counts, player counts, and membership status
|
||||
const roomsWithCounts = await Promise.all(
|
||||
rooms.map(async (room) => {
|
||||
const members = await getRoomMembers(room.id)
|
||||
const playerMap = await getRoomActivePlayers(room.id)
|
||||
const userIsMember = await isMember(room.id, viewerId)
|
||||
|
||||
let totalPlayers = 0
|
||||
for (const players of playerMap.values()) {
|
||||
totalPlayers += players.length
|
||||
}
|
||||
|
||||
return {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
code: room.code,
|
||||
gameName: room.gameName,
|
||||
status: room.status,
|
||||
createdAt: room.createdAt,
|
||||
creatorName: room.creatorName,
|
||||
isLocked: room.isLocked,
|
||||
memberCount: members.length,
|
||||
playerCount: totalPlayers,
|
||||
isMember: userIsMember,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({ rooms: roomsWithCounts })
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch rooms:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch rooms' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/arcade/rooms
|
||||
* Create a new room
|
||||
* Body:
|
||||
* - name: string
|
||||
* - gameName: string
|
||||
* - gameConfig?: object
|
||||
* - ttlMinutes?: number
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.name || !body.gameName) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: name, gameName' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate game name
|
||||
const validGames: GameName[] = ['matching', 'memory-quiz', 'complement-race']
|
||||
if (!validGames.includes(body.gameName)) {
|
||||
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate name length
|
||||
if (body.name.length > 50) {
|
||||
return NextResponse.json({ error: 'Room name too long (max 50 characters)' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get display name from body or generate from viewerId
|
||||
const displayName = body.creatorName || `Guest ${viewerId.slice(-4)}`
|
||||
|
||||
// Create room
|
||||
const room = await createRoom({
|
||||
name: body.name,
|
||||
createdBy: viewerId,
|
||||
creatorName: displayName,
|
||||
gameName: body.gameName,
|
||||
gameConfig: body.gameConfig || {},
|
||||
ttlMinutes: body.ttlMinutes,
|
||||
})
|
||||
|
||||
// Add creator as first member
|
||||
await addRoomMember({
|
||||
roomId: room.id,
|
||||
userId: viewerId,
|
||||
displayName,
|
||||
isCreator: true,
|
||||
})
|
||||
|
||||
// Generate join URL
|
||||
const baseUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:3000'
|
||||
const joinUrl = `${baseUrl}/arcade/rooms/${room.id}`
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
room,
|
||||
joinUrl,
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to create room:', error)
|
||||
return NextResponse.json({ error: 'Failed to create room' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
57
apps/web/src/app/api/debug/active-players/route.ts
Normal file
57
apps/web/src/app/api/debug/active-players/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { db, schema } from '@/db'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
/**
|
||||
* GET /api/debug/active-players
|
||||
* Debug endpoint to check active players for current user
|
||||
*/
|
||||
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) {
|
||||
return NextResponse.json({ error: 'User not found', viewerId }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get ALL players for this user
|
||||
const allPlayers = await db.query.players.findMany({
|
||||
where: eq(schema.players.userId, user.id),
|
||||
})
|
||||
|
||||
// Get active players using the helper
|
||||
const activePlayers = await getActivePlayers(viewerId)
|
||||
|
||||
return NextResponse.json({
|
||||
viewerId,
|
||||
userId: user.id,
|
||||
allPlayers: allPlayers.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
emoji: p.emoji,
|
||||
isActive: p.isActive,
|
||||
})),
|
||||
activePlayers: activePlayers.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
emoji: p.emoji,
|
||||
isActive: p.isActive,
|
||||
})),
|
||||
activeCount: activePlayers.length,
|
||||
totalCount: allPlayers.length,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch active players:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch active players', details: String(error) },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { assetStore } from '@/lib/asset-store'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
export async function GET(_request: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const { id } = params
|
||||
|
||||
@@ -15,30 +12,35 @@ export async function GET(
|
||||
const asset = await assetStore.get(id)
|
||||
if (!asset) {
|
||||
console.log('❌ Asset not found in store')
|
||||
return NextResponse.json({
|
||||
error: 'Asset not found or expired'
|
||||
}, { status: 404 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Asset not found or expired',
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log('✅ Asset found, serving download')
|
||||
|
||||
// Return file with appropriate headers
|
||||
return new NextResponse(asset.data, {
|
||||
return new NextResponse(new Uint8Array(asset.data), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': asset.mimeType,
|
||||
'Content-Disposition': `attachment; filename="${asset.filename}"`,
|
||||
'Content-Length': asset.data.length.toString(),
|
||||
'Cache-Control': 'private, no-cache, no-store, must-revalidate',
|
||||
'Expires': '0',
|
||||
'Pragma': 'no-cache'
|
||||
}
|
||||
Expires: '0',
|
||||
Pragma: 'no-cache',
|
||||
},
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Download failed:', error)
|
||||
return NextResponse.json({
|
||||
error: 'Failed to download file'
|
||||
}, { status: 500 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to download file',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { assetStore } from '@/lib/asset-store'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
export async function GET(_request: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const { id } = params
|
||||
|
||||
const asset = await assetStore.get(id)
|
||||
if (!asset) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Asset not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
return NextResponse.json({ error: 'Asset not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Set appropriate headers for download
|
||||
@@ -23,16 +17,12 @@ export async function GET(
|
||||
headers.set('Content-Length', asset.data.length.toString())
|
||||
headers.set('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||
|
||||
return new NextResponse(asset.data, {
|
||||
return new NextResponse(new Uint8Array(asset.data), {
|
||||
status: 200,
|
||||
headers
|
||||
headers,
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Asset download error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to download asset' },
|
||||
{ status: 500 }
|
||||
)
|
||||
return NextResponse.json({ error: 'Failed to download asset' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { SorobanGenerator } from '@soroban/core'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import path from 'path'
|
||||
|
||||
// Global generator instance for better performance
|
||||
@@ -36,14 +36,17 @@ export async function POST(request: NextRequest) {
|
||||
// Check dependencies before generating
|
||||
const deps = await gen.checkDependencies?.()
|
||||
if (deps && (!deps.python || !deps.typst)) {
|
||||
return NextResponse.json({
|
||||
error: 'Missing system dependencies',
|
||||
details: {
|
||||
python: deps.python ? '✅ Available' : '❌ Missing Python 3',
|
||||
typst: deps.typst ? '✅ Available' : '❌ Missing Typst',
|
||||
qpdf: deps.qpdf ? '✅ Available' : '⚠️ Missing qpdf (optional)'
|
||||
}
|
||||
}, { status: 500 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Missing system dependencies',
|
||||
details: {
|
||||
python: deps.python ? '✅ Available' : '❌ Missing Python 3',
|
||||
typst: deps.typst ? '✅ Available' : '❌ Missing Typst',
|
||||
qpdf: deps.qpdf ? '✅ Available' : '⚠️ Missing qpdf (optional)',
|
||||
},
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate flashcards using Python via TypeScript bindings
|
||||
@@ -52,36 +55,38 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// SorobanGenerator.generate() returns PDF data directly as Buffer
|
||||
if (!Buffer.isBuffer(result)) {
|
||||
throw new Error('Expected PDF Buffer from generator, got: ' + typeof result)
|
||||
throw new Error(`Expected PDF Buffer from generator, got: ${typeof result}`)
|
||||
}
|
||||
const pdfBuffer = result
|
||||
// Create filename for download
|
||||
const filename = `soroban-flashcards-${config.range || 'cards'}.pdf`
|
||||
|
||||
// Return PDF directly as download
|
||||
return new NextResponse(pdfBuffer, {
|
||||
return new NextResponse(new Uint8Array(pdfBuffer), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Content-Length': pdfBuffer.length.toString()
|
||||
}
|
||||
'Content-Length': pdfBuffer.length.toString(),
|
||||
},
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Generation failed:', error)
|
||||
|
||||
return NextResponse.json({
|
||||
error: 'Failed to generate flashcards',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
success: false
|
||||
}, { status: 500 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to generate flashcards',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions to calculate metadata
|
||||
function calculateCardCount(range: string, step: number): number {
|
||||
function _calculateCardCount(range: string, step: number): number {
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map(n => parseInt(n) || 0)
|
||||
const [start, end] = range.split('-').map((n) => parseInt(n, 10) || 0)
|
||||
return Math.floor((end - start + 1) / step)
|
||||
}
|
||||
|
||||
@@ -92,9 +97,9 @@ function calculateCardCount(range: string, step: number): number {
|
||||
return 1
|
||||
}
|
||||
|
||||
function generateNumbersFromRange(range: string, step: number): number[] {
|
||||
function _generateNumbersFromRange(range: string, step: number): number[] {
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map(n => parseInt(n) || 0)
|
||||
const [start, end] = range.split('-').map((n) => parseInt(n, 10) || 0)
|
||||
const numbers: number[] = []
|
||||
for (let i = start; i <= end; i += step) {
|
||||
numbers.push(i)
|
||||
@@ -104,26 +109,29 @@ function generateNumbersFromRange(range: string, step: number): number[] {
|
||||
}
|
||||
|
||||
if (range.includes(',')) {
|
||||
return range.split(',').map(n => parseInt(n.trim()) || 0)
|
||||
return range.split(',').map((n) => parseInt(n.trim(), 10) || 0)
|
||||
}
|
||||
|
||||
return [parseInt(range) || 0]
|
||||
return [parseInt(range, 10) || 0]
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
export async function GET() {
|
||||
try {
|
||||
const gen = await getGenerator()
|
||||
const deps = await gen.checkDependencies?.() || { python: true, typst: true, qpdf: true }
|
||||
const deps = (await gen.checkDependencies?.()) || { python: true, typst: true, qpdf: true }
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'healthy',
|
||||
dependencies: deps
|
||||
dependencies: deps,
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
status: 'unhealthy',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, { status: 500 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'unhealthy',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
100
apps/web/src/app/api/players/[id]/route.ts
Normal file
100
apps/web/src/app/api/players/[id]/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* PATCH /api/players/[id]
|
||||
* Update a player (only if it belongs to the current viewer)
|
||||
*/
|
||||
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Get user record (must exist if player exists)
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if user has an active arcade session
|
||||
// If so, prevent changing isActive status (players are locked during games)
|
||||
if (body.isActive !== undefined) {
|
||||
const activeSession = await db.query.arcadeSessions.findFirst({
|
||||
where: eq(schema.arcadeSessions.userId, viewerId),
|
||||
})
|
||||
|
||||
if (activeSession) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Cannot modify active players during an active game session',
|
||||
activeGame: activeSession.currentGame,
|
||||
gameUrl: activeSession.gameUrl,
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Security: Only allow updating specific fields (excludes userId)
|
||||
// Update player (only if it belongs to this user)
|
||||
const [updatedPlayer] = await db
|
||||
.update(schema.players)
|
||||
.set({
|
||||
...(body.name !== undefined && { name: body.name }),
|
||||
...(body.emoji !== undefined && { emoji: body.emoji }),
|
||||
...(body.color !== undefined && { color: body.color }),
|
||||
...(body.isActive !== undefined && { isActive: body.isActive }),
|
||||
// userId is explicitly NOT included - it comes from session
|
||||
})
|
||||
.where(and(eq(schema.players.id, params.id), eq(schema.players.userId, user.id)))
|
||||
.returning()
|
||||
|
||||
if (!updatedPlayer) {
|
||||
return NextResponse.json({ error: 'Player not found or unauthorized' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ player: updatedPlayer })
|
||||
} catch (error) {
|
||||
console.error('Failed to update player:', error)
|
||||
return NextResponse.json({ error: 'Failed to update player' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/players/[id]
|
||||
* Delete a player (only if it belongs to the current viewer)
|
||||
*/
|
||||
export async function DELETE(_req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Get user record (must exist if player exists)
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Delete player (only if it belongs to this user)
|
||||
const [deletedPlayer] = await db
|
||||
.delete(schema.players)
|
||||
.where(and(eq(schema.players.id, params.id), eq(schema.players.userId, user.id)))
|
||||
.returning()
|
||||
|
||||
if (!deletedPlayer) {
|
||||
return NextResponse.json({ error: 'Player not found or unauthorized' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, player: deletedPlayer })
|
||||
} catch (error) {
|
||||
console.error('Failed to delete player:', error)
|
||||
return NextResponse.json({ error: 'Failed to delete player' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { db, schema } from '../../../../db'
|
||||
import { PATCH } from '../[id]/route'
|
||||
|
||||
/**
|
||||
* Arcade Session Validation E2E Tests
|
||||
*
|
||||
* These tests verify that the PATCH /api/players/[id] endpoint
|
||||
* correctly prevents isActive changes when user has an active arcade session.
|
||||
*/
|
||||
|
||||
describe('PATCH /api/players/[id] - Arcade Session Validation', () => {
|
||||
let testUserId: string
|
||||
let testGuestId: string
|
||||
let testPlayerId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a test user with unique guest ID
|
||||
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning()
|
||||
testUserId = user.id
|
||||
|
||||
// Create a test player
|
||||
const [player] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: 'Test Player',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
isActive: false,
|
||||
})
|
||||
.returning()
|
||||
testPlayerId = player.id
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up: delete test arcade session (if exists)
|
||||
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testGuestId))
|
||||
// Clean up: delete test user (cascade deletes players)
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
||||
})
|
||||
|
||||
it('should return 403 when trying to change isActive with active arcade session', async () => {
|
||||
// Create an active arcade session
|
||||
const now = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
userId: testGuestId,
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
gameState: JSON.stringify({}),
|
||||
activePlayers: JSON.stringify([testPlayerId]),
|
||||
startedAt: now,
|
||||
lastActivityAt: now,
|
||||
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
|
||||
version: 1,
|
||||
})
|
||||
|
||||
// Mock request to change isActive
|
||||
const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: `guest_id=${testGuestId}`,
|
||||
},
|
||||
body: JSON.stringify({ isActive: true }),
|
||||
})
|
||||
|
||||
// Mock getViewerId by setting cookie
|
||||
const response = await PATCH(mockRequest, { params: { id: testPlayerId } })
|
||||
const data = await response.json()
|
||||
|
||||
// Should be rejected with 403
|
||||
expect(response.status).toBe(403)
|
||||
expect(data.error).toContain('Cannot modify active players during an active game session')
|
||||
expect(data.activeGame).toBe('matching')
|
||||
expect(data.gameUrl).toBe('/arcade/matching')
|
||||
|
||||
// Verify player isActive was NOT changed
|
||||
const player = await db.query.players.findFirst({
|
||||
where: eq(schema.players.id, testPlayerId),
|
||||
})
|
||||
expect(player?.isActive).toBe(false) // Still false
|
||||
})
|
||||
|
||||
it('should allow isActive change when no active arcade session', async () => {
|
||||
// No arcade session created
|
||||
|
||||
// Mock request to change isActive
|
||||
const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: `guest_id=${testGuestId}`,
|
||||
},
|
||||
body: JSON.stringify({ isActive: true }),
|
||||
})
|
||||
|
||||
const response = await PATCH(mockRequest, { params: { id: testPlayerId } })
|
||||
const data = await response.json()
|
||||
|
||||
// Should succeed
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.player.isActive).toBe(true)
|
||||
|
||||
// Verify player isActive was changed
|
||||
const player = await db.query.players.findFirst({
|
||||
where: eq(schema.players.id, testPlayerId),
|
||||
})
|
||||
expect(player?.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow non-isActive changes even with active arcade session', async () => {
|
||||
// Create an active arcade session
|
||||
const now = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
userId: testGuestId,
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
gameState: JSON.stringify({}),
|
||||
activePlayers: JSON.stringify([testPlayerId]),
|
||||
startedAt: now,
|
||||
lastActivityAt: now,
|
||||
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
|
||||
version: 1,
|
||||
})
|
||||
|
||||
// Mock request to change name/emoji/color (NOT isActive)
|
||||
const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: `guest_id=${testGuestId}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: 'Updated Name',
|
||||
emoji: '🎉',
|
||||
color: '#ff0000',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await PATCH(mockRequest, { params: { id: testPlayerId } })
|
||||
const data = await response.json()
|
||||
|
||||
// Should succeed
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.player.name).toBe('Updated Name')
|
||||
expect(data.player.emoji).toBe('🎉')
|
||||
expect(data.player.color).toBe('#ff0000')
|
||||
|
||||
// Verify changes were applied
|
||||
const player = await db.query.players.findFirst({
|
||||
where: eq(schema.players.id, testPlayerId),
|
||||
})
|
||||
expect(player?.name).toBe('Updated Name')
|
||||
expect(player?.emoji).toBe('🎉')
|
||||
expect(player?.color).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('should allow isActive change after arcade session ends', async () => {
|
||||
// Create an active arcade session
|
||||
const now = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
userId: testGuestId,
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
gameState: JSON.stringify({}),
|
||||
activePlayers: JSON.stringify([testPlayerId]),
|
||||
startedAt: now,
|
||||
lastActivityAt: now,
|
||||
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
|
||||
version: 1,
|
||||
})
|
||||
|
||||
// End the session
|
||||
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testGuestId))
|
||||
|
||||
// Mock request to change isActive
|
||||
const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: `guest_id=${testGuestId}`,
|
||||
},
|
||||
body: JSON.stringify({ isActive: true }),
|
||||
})
|
||||
|
||||
const response = await PATCH(mockRequest, { params: { id: testPlayerId } })
|
||||
const data = await response.json()
|
||||
|
||||
// Should succeed
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.player.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle multiple players with different isActive states', async () => {
|
||||
// Create additional players
|
||||
const [player2] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: 'Player 2',
|
||||
emoji: '😎',
|
||||
color: '#8b5cf6',
|
||||
isActive: true,
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Create arcade session
|
||||
const now2 = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
userId: testGuestId,
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
gameState: JSON.stringify({}),
|
||||
activePlayers: JSON.stringify([testPlayerId, player2.id]),
|
||||
startedAt: now2,
|
||||
lastActivityAt: now2,
|
||||
expiresAt: new Date(now2.getTime() + 3600000), // 1 hour from now
|
||||
version: 1,
|
||||
})
|
||||
|
||||
// Try to toggle player1 (inactive -> active) - should fail
|
||||
const request1 = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: `guest_id=${testGuestId}`,
|
||||
},
|
||||
body: JSON.stringify({ isActive: true }),
|
||||
})
|
||||
|
||||
const response1 = await PATCH(request1, { params: { id: testPlayerId } })
|
||||
expect(response1.status).toBe(403)
|
||||
|
||||
// Try to toggle player2 (active -> inactive) - should also fail
|
||||
const request2 = new NextRequest(`http://localhost:3000/api/players/${player2.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: `guest_id=${testGuestId}`,
|
||||
},
|
||||
body: JSON.stringify({ isActive: false }),
|
||||
})
|
||||
|
||||
const response2 = await PATCH(request2, { params: { id: player2.id } })
|
||||
expect(response2.status).toBe(403)
|
||||
})
|
||||
})
|
||||
91
apps/web/src/app/api/players/route.ts
Normal file
91
apps/web/src/app/api/players/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* GET /api/players
|
||||
* List all players for the current viewer (guest or user)
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Get or create user record
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
|
||||
// Get all players for this user
|
||||
const players = await db.query.players.findMany({
|
||||
where: eq(schema.players.userId, user.id),
|
||||
orderBy: (players, { desc }) => [desc(players.createdAt)],
|
||||
})
|
||||
|
||||
return NextResponse.json({ players })
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch players:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch players' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/players
|
||||
* Create a new player for the current viewer
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.name || !body.emoji || !body.color) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: name, emoji, color' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get or create user record
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
|
||||
// Create player
|
||||
const [player] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: user.id,
|
||||
name: body.name,
|
||||
emoji: body.emoji,
|
||||
color: body.color,
|
||||
isActive: body.isActive ?? false,
|
||||
})
|
||||
.returning()
|
||||
|
||||
return NextResponse.json({ player }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Failed to create player:', error)
|
||||
return NextResponse.json({ error: 'Failed to create player' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a user record for the given viewer ID (guest or user)
|
||||
*/
|
||||
async function getOrCreateUser(viewerId: string) {
|
||||
// Try to find existing user by guest ID
|
||||
let user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
})
|
||||
|
||||
// If no user exists, create one
|
||||
if (!user) {
|
||||
const [newUser] = await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
guestId: viewerId,
|
||||
})
|
||||
.returning()
|
||||
|
||||
user = newUser
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { generateSorobanSVG } from '@/lib/typst-soroban'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const config = await request.json()
|
||||
|
||||
// Debug: log the received config
|
||||
console.log('🔍 Preview config:', JSON.stringify(config, null, 2))
|
||||
|
||||
// Ensure range is set with a default
|
||||
if (!config.range) {
|
||||
config.range = '0-9'
|
||||
}
|
||||
|
||||
// For preview, limit to a few numbers and use SVG format for fast rendering
|
||||
const previewConfig = {
|
||||
...config,
|
||||
range: getPreviewRange(config.range),
|
||||
format: 'svg', // Use SVG format for preview
|
||||
cardsPerPage: 6 // Standard card layout
|
||||
}
|
||||
|
||||
console.log('🔍 Processed preview config:', JSON.stringify(previewConfig, null, 2))
|
||||
|
||||
// Generate real SVG preview using typst.ts
|
||||
console.log('🚀 Generating soroban SVG preview via typst.ts')
|
||||
|
||||
try {
|
||||
// Parse the numbers from the range for individual cards
|
||||
const numbers = parseNumbersFromRange(getPreviewRange(config.range))
|
||||
console.log('🔍 Generating individual SVGs for numbers:', numbers)
|
||||
|
||||
// Generate individual SVGs for each number using typst.ts
|
||||
const samples = []
|
||||
for (const number of numbers) {
|
||||
try {
|
||||
const typstConfig = {
|
||||
number: number,
|
||||
beadShape: previewConfig.beadShape || 'diamond',
|
||||
colorScheme: previewConfig.colorScheme || 'place-value',
|
||||
hideInactiveBeads: previewConfig.hideInactiveBeads || false,
|
||||
scaleFactor: previewConfig.scaleFactor || 1.0,
|
||||
width: '200pt',
|
||||
height: '250pt'
|
||||
}
|
||||
console.log(`🔍 Generating typst.ts SVG for number ${number}`)
|
||||
const svg = await generateSorobanSVG(typstConfig)
|
||||
console.log(`✅ Generated typst.ts SVG for ${number}, length: ${svg.length}`)
|
||||
samples.push({
|
||||
number,
|
||||
front: svg,
|
||||
back: number.toString()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to generate SVG for number ${number}:`, error instanceof Error ? error.message : error)
|
||||
samples.push({
|
||||
number,
|
||||
front: `<svg width="200" height="300" viewBox="0 0 200 300" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="10" y="10" width="180" height="280" fill="none" stroke="#ccc" stroke-width="2"/>
|
||||
<line x1="20" y1="150" x2="180" y2="150" stroke="#ccc" stroke-width="2"/>
|
||||
<text x="100" y="160" text-anchor="middle" font-size="24" fill="#666">SVG Error</text>
|
||||
<text x="100" y="180" text-anchor="middle" font-size="16" fill="#999">${number}</text>
|
||||
</svg>`,
|
||||
back: number.toString()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
count: numbers.length,
|
||||
samples,
|
||||
note: 'Real individual SVGs generated by typst.ts'
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('⚠️ Typst.ts SVG generation failed, using fallback preview:', error instanceof Error ? error.message : error)
|
||||
return NextResponse.json(getMockPreviewData(config))
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Preview generation failed:', error)
|
||||
|
||||
// Always fall back to mock data for preview
|
||||
const config = await request.json().catch(() => ({ range: '0-9' }))
|
||||
return NextResponse.json(getMockPreviewData(config))
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to parse numbers from range string
|
||||
function parseNumbersFromRange(range: string): number[] {
|
||||
if (!range) return [0, 1, 2]
|
||||
|
||||
if (range.includes('-')) {
|
||||
const [start] = range.split('-')
|
||||
const startNum = parseInt(start) || 0
|
||||
return [startNum, startNum + 1, startNum + 2]
|
||||
}
|
||||
|
||||
if (range.includes(',')) {
|
||||
return range.split(',').slice(0, 3).map(n => parseInt(n.trim()) || 0)
|
||||
}
|
||||
|
||||
const num = parseInt(range) || 0
|
||||
return [num, num + 1, num + 2]
|
||||
}
|
||||
|
||||
// Helper function to limit range for preview
|
||||
function getPreviewRange(range: string): string {
|
||||
if (!range) return '0,1,2'
|
||||
|
||||
if (range.includes('-')) {
|
||||
const [start] = range.split('-')
|
||||
const startNum = parseInt(start) || 0
|
||||
return `${startNum},${startNum + 1},${startNum + 2}`
|
||||
}
|
||||
|
||||
if (range.includes(',')) {
|
||||
const numbers = range.split(',').slice(0, 3)
|
||||
return numbers.join(',')
|
||||
}
|
||||
|
||||
return range
|
||||
}
|
||||
|
||||
// Mock preview data for development and fallback
|
||||
function getMockPreviewData(config: any) {
|
||||
const range = config.range || '0-9'
|
||||
let numbers: number[]
|
||||
|
||||
if (range.includes('-')) {
|
||||
const [start] = range.split('-')
|
||||
const startNum = parseInt(start) || 0
|
||||
numbers = [startNum, startNum + 1, startNum + 2]
|
||||
} else if (range.includes(',')) {
|
||||
numbers = range.split(',').slice(0, 3).map((n: string) => parseInt(n.trim()) || 0)
|
||||
} else {
|
||||
const num = parseInt(range) || 0
|
||||
numbers = [num, num + 1, num + 2]
|
||||
}
|
||||
|
||||
return {
|
||||
count: numbers.length,
|
||||
samples: numbers.map(number => ({
|
||||
number,
|
||||
front: `<svg width="200" height="300" viewBox="0 0 200 300" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="10" y="10" width="180" height="280" fill="none" stroke="#ccc" stroke-width="2"/>
|
||||
<line x1="20" y1="150" x2="180" y2="150" stroke="#ccc" stroke-width="2"/>
|
||||
<text x="100" y="160" text-anchor="middle" font-size="24" fill="#666">Preview Error</text>
|
||||
<text x="100" y="180" text-anchor="middle" font-size="16" fill="#999">${number}</text>
|
||||
</svg>`,
|
||||
back: number.toString()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: 'healthy',
|
||||
endpoint: 'preview',
|
||||
message: 'Preview API is running'
|
||||
})
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { $typst } from '@myriaddreamin/typst.ts/dist/esm/contrib/snippet.mjs'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export interface TypstSVGRequest {
|
||||
number: number
|
||||
beadShape?: 'diamond' | 'circle' | 'square'
|
||||
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating'
|
||||
colorPalette?: 'default' | 'colorblind' | 'mnemonic' | 'grayscale' | 'nature'
|
||||
hideInactiveBeads?: boolean
|
||||
showEmptyColumns?: boolean
|
||||
columns?: number | 'auto'
|
||||
scaleFactor?: number
|
||||
width?: string
|
||||
height?: string
|
||||
fontSize?: string
|
||||
fontFamily?: string
|
||||
transparent?: boolean
|
||||
coloredNumerals?: boolean
|
||||
}
|
||||
|
||||
// Cache for template content
|
||||
let flashcardsTemplate: string | null = null
|
||||
|
||||
async function getFlashcardsTemplate(): Promise<string> {
|
||||
if (flashcardsTemplate) {
|
||||
return flashcardsTemplate
|
||||
}
|
||||
|
||||
try {
|
||||
const { getTemplatePath } = require('@soroban/templates')
|
||||
const templatePath = getTemplatePath('flashcards.typ')
|
||||
flashcardsTemplate = fs.readFileSync(templatePath, 'utf-8')
|
||||
return flashcardsTemplate
|
||||
} catch (error) {
|
||||
console.error('Failed to load flashcards template:', error)
|
||||
throw new Error('Template loading failed')
|
||||
}
|
||||
}
|
||||
|
||||
function processBeadAnnotations(svg: string): string {
|
||||
const { extractBeadAnnotations } = require('@soroban/templates')
|
||||
const result = extractBeadAnnotations(svg)
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
console.log('ℹ️ SVG bead processing warnings:', result.warnings)
|
||||
}
|
||||
|
||||
console.log(`🔗 Processed ${result.count} bead links into data attributes`)
|
||||
return result.processedSVG
|
||||
}
|
||||
|
||||
function createTypstContent(config: TypstSVGRequest, template: string): string {
|
||||
const {
|
||||
number,
|
||||
beadShape = 'diamond',
|
||||
colorScheme = 'place-value',
|
||||
colorPalette = 'default',
|
||||
hideInactiveBeads = false,
|
||||
showEmptyColumns = false,
|
||||
columns = 'auto',
|
||||
scaleFactor = 1.0,
|
||||
width = '120pt',
|
||||
height = '160pt',
|
||||
fontSize = '48pt',
|
||||
fontFamily = 'DejaVu Sans',
|
||||
transparent = false,
|
||||
coloredNumerals = false
|
||||
} = config
|
||||
|
||||
return `
|
||||
${template}
|
||||
|
||||
#set page(
|
||||
width: ${width},
|
||||
height: ${height},
|
||||
margin: 0pt,
|
||||
fill: ${transparent ? 'none' : 'white'}
|
||||
)
|
||||
|
||||
#set text(font: "${fontFamily}", size: ${fontSize}, fallback: true)
|
||||
|
||||
#align(center + horizon)[
|
||||
#box(
|
||||
width: ${width} - 2 * (${width} * 0.05),
|
||||
height: ${height} - 2 * (${height} * 0.05)
|
||||
)[
|
||||
#align(center + horizon)[
|
||||
#scale(x: ${scaleFactor * 100}%, y: ${scaleFactor * 100}%)[
|
||||
#draw-soroban(
|
||||
${number},
|
||||
columns: ${columns},
|
||||
show-empty: ${showEmptyColumns},
|
||||
hide-inactive: ${hideInactiveBeads},
|
||||
bead-shape: "${beadShape}",
|
||||
color-scheme: "${colorScheme}",
|
||||
color-palette: "${colorPalette}",
|
||||
base-size: 1.0
|
||||
)
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
`
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const config: TypstSVGRequest = await request.json()
|
||||
|
||||
console.log('🎨 Generating typst.ts SVG for number:', config.number)
|
||||
|
||||
// Load template
|
||||
const template = await getFlashcardsTemplate()
|
||||
|
||||
// Create typst content
|
||||
const typstContent = createTypstContent(config, template)
|
||||
|
||||
// Generate SVG using typst.ts
|
||||
const rawSvg = await $typst.svg({ mainContent: typstContent })
|
||||
|
||||
// Post-process to convert bead annotations to data attributes
|
||||
const svg = processBeadAnnotations(rawSvg)
|
||||
|
||||
console.log('✅ Generated and processed typst.ts SVG, length:', svg.length)
|
||||
|
||||
return NextResponse.json({
|
||||
svg,
|
||||
success: true,
|
||||
number: config.number
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Typst SVG generation failed:', error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
success: false
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Health check
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: 'healthy',
|
||||
endpoint: 'typst-svg',
|
||||
message: 'Typst.ts SVG generation API is running'
|
||||
})
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import fs from 'fs'
|
||||
import { getTemplatePath } from '@soroban/templates'
|
||||
|
||||
// API endpoint to serve the flashcards.typ template content
|
||||
export async function GET() {
|
||||
try {
|
||||
const templatePath = getTemplatePath('flashcards.typ');
|
||||
const flashcardsTemplate = fs.readFileSync(templatePath, 'utf-8')
|
||||
|
||||
return NextResponse.json({
|
||||
template: flashcardsTemplate,
|
||||
success: true
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load typst template:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to load template',
|
||||
success: false
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
120
apps/web/src/app/api/user-stats/route.ts
Normal file
120
apps/web/src/app/api/user-stats/route.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* GET /api/user-stats
|
||||
* Get user statistics for the current viewer
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Get user record
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
// No user yet, return default stats
|
||||
return NextResponse.json({
|
||||
stats: {
|
||||
gamesPlayed: 0,
|
||||
totalWins: 0,
|
||||
favoriteGameType: null,
|
||||
bestTime: null,
|
||||
highestAccuracy: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Get stats record
|
||||
let stats = await db.query.userStats.findFirst({
|
||||
where: eq(schema.userStats.userId, user.id),
|
||||
})
|
||||
|
||||
// If no stats record exists, create one with defaults
|
||||
if (!stats) {
|
||||
const [newStats] = await db
|
||||
.insert(schema.userStats)
|
||||
.values({
|
||||
userId: user.id,
|
||||
})
|
||||
.returning()
|
||||
|
||||
stats = newStats
|
||||
}
|
||||
|
||||
return NextResponse.json({ stats })
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user stats:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch user stats' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/user-stats
|
||||
* Update user statistics for the current viewer
|
||||
*/
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Get or create user record
|
||||
let user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
// Create user if it doesn't exist
|
||||
const [newUser] = await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
guestId: viewerId,
|
||||
})
|
||||
.returning()
|
||||
|
||||
user = newUser
|
||||
}
|
||||
|
||||
// Get existing stats
|
||||
const stats = await db.query.userStats.findFirst({
|
||||
where: eq(schema.userStats.userId, user.id),
|
||||
})
|
||||
|
||||
// Prepare update values
|
||||
const updates: any = {}
|
||||
if (body.gamesPlayed !== undefined) updates.gamesPlayed = body.gamesPlayed
|
||||
if (body.totalWins !== undefined) updates.totalWins = body.totalWins
|
||||
if (body.favoriteGameType !== undefined) updates.favoriteGameType = body.favoriteGameType
|
||||
if (body.bestTime !== undefined) updates.bestTime = body.bestTime
|
||||
if (body.highestAccuracy !== undefined) updates.highestAccuracy = body.highestAccuracy
|
||||
|
||||
if (stats) {
|
||||
// Update existing stats
|
||||
const [updatedStats] = await db
|
||||
.update(schema.userStats)
|
||||
.set(updates)
|
||||
.where(eq(schema.userStats.userId, user.id))
|
||||
.returning()
|
||||
|
||||
return NextResponse.json({ stats: updatedStats })
|
||||
} else {
|
||||
// Create new stats record
|
||||
const [newStats] = await db
|
||||
.insert(schema.userStats)
|
||||
.values({
|
||||
userId: user.id,
|
||||
...updates,
|
||||
})
|
||||
.returning()
|
||||
|
||||
return NextResponse.json({ stats: newStats }, { status: 201 })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update user stats:', error)
|
||||
return NextResponse.json({ error: 'Failed to update user stats' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
16
apps/web/src/app/api/viewer/route.ts
Normal file
16
apps/web/src/app/api/viewer/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* GET /api/viewer
|
||||
*
|
||||
* Returns the current viewer's ID (guest or authenticated user)
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const viewerId = await getViewerId()
|
||||
return NextResponse.json({ viewerId })
|
||||
} catch (_error) {
|
||||
return NextResponse.json({ error: 'No valid viewer session found' }, { status: 401 })
|
||||
}
|
||||
}
|
||||
665
apps/web/src/app/arcade-rooms/[roomId]/page.tsx
Normal file
665
apps/web/src/app/arcade-rooms/[roomId]/page.tsx
Normal file
@@ -0,0 +1,665 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
|
||||
interface Room {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
gameName: string
|
||||
status: 'lobby' | 'playing' | 'finished'
|
||||
createdBy: string
|
||||
creatorName: string
|
||||
isLocked: boolean
|
||||
}
|
||||
|
||||
interface Member {
|
||||
id: string
|
||||
userId: string
|
||||
displayName: string
|
||||
isCreator: boolean
|
||||
isOnline: boolean
|
||||
joinedAt: Date
|
||||
}
|
||||
|
||||
interface Player {
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
emoji: string
|
||||
color: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export default function RoomDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const roomId = params.roomId as string
|
||||
const { data: guestId } = useViewerId()
|
||||
|
||||
const [room, setRoom] = useState<Room | null>(null)
|
||||
const [members, setMembers] = useState<Member[]>([])
|
||||
const [memberPlayers, setMemberPlayers] = useState<Record<string, Player[]>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [socket, setSocket] = useState<Socket | null>(null)
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoom()
|
||||
}, [roomId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!guestId || !roomId) return
|
||||
|
||||
// Connect to socket
|
||||
const sock = io({ path: '/api/socket' })
|
||||
setSocket(sock)
|
||||
|
||||
sock.on('connect', () => {
|
||||
setIsConnected(true)
|
||||
// Join the room
|
||||
sock.emit('join-room', { roomId, userId: guestId })
|
||||
})
|
||||
|
||||
sock.on('disconnect', () => {
|
||||
setIsConnected(false)
|
||||
})
|
||||
|
||||
sock.on('room-joined', (data) => {
|
||||
console.log('Joined room:', data)
|
||||
if (data.members) {
|
||||
setMembers(data.members)
|
||||
}
|
||||
if (data.memberPlayers) {
|
||||
setMemberPlayers(data.memberPlayers)
|
||||
}
|
||||
})
|
||||
|
||||
sock.on('member-joined', (data) => {
|
||||
console.log('Member joined:', data)
|
||||
if (data.members) {
|
||||
setMembers(data.members)
|
||||
}
|
||||
if (data.memberPlayers) {
|
||||
setMemberPlayers(data.memberPlayers)
|
||||
}
|
||||
})
|
||||
|
||||
sock.on('member-left', (data) => {
|
||||
console.log('Member left:', data)
|
||||
if (data.members) {
|
||||
setMembers(data.members)
|
||||
}
|
||||
if (data.memberPlayers) {
|
||||
setMemberPlayers(data.memberPlayers)
|
||||
}
|
||||
})
|
||||
|
||||
sock.on('room-error', (error) => {
|
||||
console.error('Room error:', error)
|
||||
setError(error.error)
|
||||
})
|
||||
|
||||
sock.on('room-players-updated', (data) => {
|
||||
console.log('Room players updated:', data)
|
||||
if (data.memberPlayers) {
|
||||
setMemberPlayers(data.memberPlayers)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
sock.emit('leave-room', { roomId, userId: guestId })
|
||||
sock.disconnect()
|
||||
}
|
||||
}, [roomId, guestId])
|
||||
|
||||
// Notify room when window regains focus (user might have changed players in another tab)
|
||||
useEffect(() => {
|
||||
if (!socket || !guestId || !roomId) return
|
||||
|
||||
const handleFocus = () => {
|
||||
console.log('Window focused, notifying room of potential player changes')
|
||||
socket.emit('players-updated', { roomId, userId: guestId })
|
||||
}
|
||||
|
||||
window.addEventListener('focus', handleFocus)
|
||||
return () => window.removeEventListener('focus', handleFocus)
|
||||
}, [socket, roomId, guestId])
|
||||
|
||||
const fetchRoom = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetch(`/api/arcade/rooms/${roomId}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
setRoom(data.room)
|
||||
setMembers(data.members || [])
|
||||
setMemberPlayers(data.memberPlayers || {})
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch room:', err)
|
||||
setError('Failed to load room')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startGame = () => {
|
||||
if (!room) return
|
||||
// Navigate to the room game page
|
||||
router.push('/arcade/room')
|
||||
}
|
||||
|
||||
const joinRoom = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/arcade/rooms/${roomId}/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ displayName: 'Player' }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
|
||||
// Handle specific room membership conflict
|
||||
if (errorData.code === 'ROOM_MEMBERSHIP_CONFLICT') {
|
||||
alert(errorData.userMessage || errorData.message)
|
||||
// Refresh the page to update room state
|
||||
await fetchRoom()
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(errorData.error || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Show notification if user was auto-removed from other rooms
|
||||
if (data.autoLeave) {
|
||||
console.log(`[Room Join] ${data.autoLeave.message}`)
|
||||
// Could show a toast notification here in the future
|
||||
}
|
||||
|
||||
// Refresh room data to update membership UI
|
||||
await fetchRoom()
|
||||
} catch (err) {
|
||||
console.error('Failed to join room:', err)
|
||||
alert('Failed to join room')
|
||||
}
|
||||
}
|
||||
|
||||
const leaveRoom = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/arcade/rooms/${roomId}/leave`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
// Navigate to arcade home after successfully leaving
|
||||
router.push('/arcade')
|
||||
} catch (err) {
|
||||
console.error('Failed to leave room:', err)
|
||||
alert('Failed to leave room')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageWithNav>
|
||||
<div
|
||||
className={css({
|
||||
minH: 'calc(100vh - 80px)',
|
||||
bg: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontSize: 'xl',
|
||||
})}
|
||||
>
|
||||
Loading room...
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !room) {
|
||||
return (
|
||||
<PageWithNav>
|
||||
<div
|
||||
className={css({
|
||||
minH: 'calc(100vh - 80px)',
|
||||
bg: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: '8',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(255, 255, 255, 0.05)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
rounded: 'lg',
|
||||
p: '12',
|
||||
textAlign: 'center',
|
||||
maxW: '500px',
|
||||
})}
|
||||
>
|
||||
<p className={css({ fontSize: 'xl', color: 'white', mb: '4' })}>
|
||||
{error || 'Room not found'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push('/arcade-rooms')}
|
||||
className={css({
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: '#3b82f6',
|
||||
color: 'white',
|
||||
rounded: 'lg',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: '#2563eb' },
|
||||
})}
|
||||
>
|
||||
Back to Rooms
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
const onlineMembers = members.filter((m) => m.isOnline)
|
||||
|
||||
// Check if current user is a member
|
||||
const isMember = members.some((m) => m.userId === guestId)
|
||||
|
||||
// Calculate union of all active players in the room
|
||||
const allPlayers: Player[] = []
|
||||
const playerIds = new Set<string>()
|
||||
|
||||
for (const userId in memberPlayers) {
|
||||
for (const player of memberPlayers[userId]) {
|
||||
if (!playerIds.has(player.id)) {
|
||||
playerIds.add(player.id)
|
||||
allPlayers.push(player)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav>
|
||||
<div
|
||||
className={css({
|
||||
minH: 'calc(100vh - 80px)',
|
||||
bg: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
|
||||
p: '8',
|
||||
})}
|
||||
>
|
||||
<div className={css({ maxW: '1000px', mx: 'auto' })}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(255, 255, 255, 0.05)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
rounded: 'lg',
|
||||
p: '8',
|
||||
mb: '6',
|
||||
})}
|
||||
>
|
||||
<div className={css({ mb: '4' })}>
|
||||
<button
|
||||
onClick={() => router.push('/arcade-rooms')}
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
color: '#a0a0ff',
|
||||
fontSize: 'sm',
|
||||
cursor: 'pointer',
|
||||
_hover: { color: '#60a5fa' },
|
||||
mb: '3',
|
||||
})}
|
||||
>
|
||||
← Back to Rooms
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: '4',
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<h1
|
||||
className={css({ fontSize: '3xl', fontWeight: 'bold', color: 'white', mb: '2' })}
|
||||
>
|
||||
{room.name}
|
||||
</h1>
|
||||
<div
|
||||
className={css({ display: 'flex', gap: '4', color: '#a0a0ff', fontSize: 'sm' })}
|
||||
>
|
||||
<span>🎮 {room.gameName}</span>
|
||||
<span>👤 Host: {room.creatorName}</span>
|
||||
<span
|
||||
className={css({
|
||||
px: '3',
|
||||
py: '1',
|
||||
bg: 'rgba(255, 255, 255, 0.1)',
|
||||
color: '#fbbf24',
|
||||
rounded: 'full',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'monospace',
|
||||
})}
|
||||
>
|
||||
Code: {room.code}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={css({ display: 'flex', gap: '3', alignItems: 'center' })}>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
px: '3',
|
||||
py: '2',
|
||||
bg: isConnected ? 'rgba(16, 185, 129, 0.2)' : 'rgba(239, 68, 68, 0.2)',
|
||||
border: `1px solid ${isConnected ? '#10b981' : '#ef4444'}`,
|
||||
rounded: 'full',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
w: '2',
|
||||
h: '2',
|
||||
bg: isConnected ? '#10b981' : '#ef4444',
|
||||
rounded: 'full',
|
||||
})}
|
||||
/>
|
||||
<span
|
||||
className={css({ color: isConnected ? '#10b981' : '#ef4444', fontSize: 'sm' })}
|
||||
>
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Game Players - Union of all active players */}
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(255, 255, 255, 0.05)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
rounded: 'lg',
|
||||
p: '8',
|
||||
mb: '6',
|
||||
})}
|
||||
>
|
||||
<h2 className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'white', mb: '2' })}>
|
||||
🎯 Game Players ({allPlayers.length})
|
||||
</h2>
|
||||
<p className={css({ color: '#a0a0ff', fontSize: 'sm', mb: '4' })}>
|
||||
These players will participate when the game starts
|
||||
</p>
|
||||
{allPlayers.length > 0 ? (
|
||||
<div className={css({ display: 'flex', gap: '2', flexWrap: 'wrap' })}>
|
||||
{allPlayers.map((player) => (
|
||||
<div
|
||||
key={player.id}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
px: '3',
|
||||
py: '2',
|
||||
bg: 'rgba(59, 130, 246, 0.15)',
|
||||
border: '2px solid rgba(59, 130, 246, 0.4)',
|
||||
rounded: 'lg',
|
||||
color: '#60a5fa',
|
||||
fontWeight: '600',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: 'xl' })}>{player.emoji}</span>
|
||||
<span>{player.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
color: '#6b7280',
|
||||
fontStyle: 'italic',
|
||||
textAlign: 'center',
|
||||
py: '4',
|
||||
})}
|
||||
>
|
||||
No active players yet. Members need to set up their players.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Members List */}
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(255, 255, 255, 0.05)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
rounded: 'lg',
|
||||
p: '8',
|
||||
mb: '6',
|
||||
})}
|
||||
>
|
||||
<h2 className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'white', mb: '2' })}>
|
||||
👥 Room Members ({onlineMembers.length}/{members.length})
|
||||
</h2>
|
||||
<p className={css({ color: '#a0a0ff', fontSize: 'sm', mb: '4' })}>
|
||||
Users in this room and their active players
|
||||
</p>
|
||||
<div className={css({ display: 'grid', gap: '3' })}>
|
||||
{members.map((member) => {
|
||||
const players = memberPlayers[member.userId] || []
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2',
|
||||
p: '4',
|
||||
bg: 'rgba(255, 255, 255, 0.05)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
rounded: 'lg',
|
||||
opacity: member.isOnline ? 1 : 0.5,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '3' })}>
|
||||
<div
|
||||
className={css({
|
||||
w: '3',
|
||||
h: '3',
|
||||
bg: member.isOnline ? '#10b981' : '#6b7280',
|
||||
rounded: 'full',
|
||||
})}
|
||||
/>
|
||||
<span className={css({ color: 'white', fontWeight: '600' })}>
|
||||
{member.displayName}
|
||||
</span>
|
||||
{member.isCreator && (
|
||||
<span
|
||||
className={css({
|
||||
px: '2',
|
||||
py: '1',
|
||||
bg: 'rgba(251, 191, 36, 0.2)',
|
||||
color: '#fbbf24',
|
||||
rounded: 'full',
|
||||
fontSize: 'xs',
|
||||
fontWeight: '600',
|
||||
})}
|
||||
>
|
||||
HOST
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={css({ color: '#a0a0ff', fontSize: 'sm' })}>
|
||||
{member.isOnline ? '🟢 Online' : '⚫ Offline'}
|
||||
</span>
|
||||
</div>
|
||||
{players.length > 0 && (
|
||||
<div
|
||||
className={css({ display: 'flex', gap: '2', flexWrap: 'wrap', ml: '6' })}
|
||||
>
|
||||
<span className={css({ color: '#a0a0ff', fontSize: 'xs', mr: '1' })}>
|
||||
Players:
|
||||
</span>
|
||||
{players.map((player) => (
|
||||
<span
|
||||
key={player.id}
|
||||
className={css({
|
||||
px: '2',
|
||||
py: '1',
|
||||
bg: 'rgba(59, 130, 246, 0.2)',
|
||||
color: '#60a5fa',
|
||||
border: '1px solid rgba(59, 130, 246, 0.3)',
|
||||
rounded: 'full',
|
||||
fontSize: 'xs',
|
||||
fontWeight: '600',
|
||||
})}
|
||||
>
|
||||
{player.emoji} {player.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{players.length === 0 && (
|
||||
<div
|
||||
className={css({
|
||||
ml: '6',
|
||||
color: '#6b7280',
|
||||
fontSize: 'xs',
|
||||
fontStyle: 'italic',
|
||||
})}
|
||||
>
|
||||
No active players
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className={css({ display: 'flex', gap: '4' })}>
|
||||
{isMember ? (
|
||||
<>
|
||||
<button
|
||||
onClick={leaveRoom}
|
||||
className={css({
|
||||
flex: 1,
|
||||
px: '6',
|
||||
py: '4',
|
||||
bg: 'rgba(255, 255, 255, 0.1)',
|
||||
color: 'white',
|
||||
rounded: 'lg',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: 'rgba(255, 255, 255, 0.15)' },
|
||||
})}
|
||||
>
|
||||
Leave Room
|
||||
</button>
|
||||
<button
|
||||
onClick={startGame}
|
||||
disabled={allPlayers.length < 1}
|
||||
className={css({
|
||||
flex: 2,
|
||||
px: '6',
|
||||
py: '4',
|
||||
bg: allPlayers.length < 1 ? '#6b7280' : '#10b981',
|
||||
color: 'white',
|
||||
rounded: 'lg',
|
||||
fontSize: 'xl',
|
||||
fontWeight: '600',
|
||||
cursor: allPlayers.length < 1 ? 'not-allowed' : 'pointer',
|
||||
opacity: allPlayers.length < 1 ? 0.5 : 1,
|
||||
_hover: allPlayers.length < 1 ? {} : { bg: '#059669' },
|
||||
})}
|
||||
>
|
||||
{allPlayers.length < 1
|
||||
? 'Waiting for players...'
|
||||
: `🎮 Start Game (${allPlayers.length} players)`}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => router.push('/arcade-rooms')}
|
||||
className={css({
|
||||
flex: 1,
|
||||
px: '6',
|
||||
py: '4',
|
||||
bg: 'rgba(255, 255, 255, 0.1)',
|
||||
color: 'white',
|
||||
rounded: 'lg',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: 'rgba(255, 255, 255, 0.15)' },
|
||||
})}
|
||||
>
|
||||
Back to Rooms
|
||||
</button>
|
||||
<button
|
||||
onClick={joinRoom}
|
||||
disabled={room.isLocked}
|
||||
className={css({
|
||||
flex: 2,
|
||||
px: '6',
|
||||
py: '4',
|
||||
bg: room.isLocked ? '#6b7280' : '#3b82f6',
|
||||
color: 'white',
|
||||
rounded: 'lg',
|
||||
fontSize: 'xl',
|
||||
fontWeight: '600',
|
||||
cursor: room.isLocked ? 'not-allowed' : 'pointer',
|
||||
opacity: room.isLocked ? 0.5 : 1,
|
||||
_hover: room.isLocked ? {} : { bg: '#2563eb' },
|
||||
})}
|
||||
>
|
||||
{room.isLocked ? '🔒 Room Locked' : 'Join Room'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
316
apps/web/src/app/arcade-rooms/__tests__/room-navigation.test.tsx
Normal file
316
apps/web/src/app/arcade-rooms/__tests__/room-navigation.test.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import * as nextNavigation from 'next/navigation'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as arcadeGuard from '@/hooks/useArcadeGuard'
|
||||
import * as roomData from '@/hooks/useRoomData'
|
||||
import * as viewerId from '@/hooks/useViewerId'
|
||||
|
||||
// Mock Next.js navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: vi.fn(),
|
||||
usePathname: vi.fn(),
|
||||
useParams: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('@/hooks/useArcadeGuard')
|
||||
vi.mock('@/hooks/useRoomData')
|
||||
vi.mock('@/hooks/useViewerId')
|
||||
vi.mock('@/hooks/useUserPlayers', () => ({
|
||||
useUserPlayers: () => ({ data: [], isLoading: false }),
|
||||
useCreatePlayer: () => ({ mutate: vi.fn() }),
|
||||
useUpdatePlayer: () => ({ mutate: vi.fn() }),
|
||||
useDeletePlayer: () => ({ mutate: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/hooks/useArcadeSocket', () => ({
|
||||
useArcadeSocket: () => ({
|
||||
connected: false,
|
||||
joinSession: vi.fn(),
|
||||
socket: null,
|
||||
sendMove: vi.fn(),
|
||||
exitSession: vi.fn(),
|
||||
pingSession: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock styled-system
|
||||
vi.mock('../../../../styled-system/css', () => ({
|
||||
css: () => '',
|
||||
}))
|
||||
|
||||
// Mock components
|
||||
vi.mock('@/components/PageWithNav', () => ({
|
||||
PageWithNav: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
// Import pages after mocks
|
||||
import RoomBrowserPage from '../page'
|
||||
|
||||
describe('Room Navigation with Active Sessions', () => {
|
||||
const mockRouter = {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(nextNavigation, 'useRouter').mockReturnValue(mockRouter as any)
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade-rooms')
|
||||
vi.spyOn(viewerId, 'useViewerId').mockReturnValue({
|
||||
data: 'test-user',
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
error: null,
|
||||
} as any)
|
||||
global.fetch = vi.fn()
|
||||
})
|
||||
|
||||
describe('RoomBrowserPage', () => {
|
||||
it('should render room browser without redirecting when user has active game session', async () => {
|
||||
// User has an active game session
|
||||
vi.spyOn(arcadeGuard, 'useArcadeGuard').mockReturnValue({
|
||||
hasActiveSession: true,
|
||||
loading: false,
|
||||
activeSession: {
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
},
|
||||
})
|
||||
|
||||
// User is in a room
|
||||
vi.spyOn(roomData, 'useRoomData').mockReturnValue({
|
||||
roomData: {
|
||||
id: 'room-1',
|
||||
name: 'Test Room',
|
||||
code: 'ABC123',
|
||||
gameName: 'matching',
|
||||
members: [],
|
||||
memberPlayers: {},
|
||||
},
|
||||
isLoading: false,
|
||||
isInRoom: true,
|
||||
notifyRoomOfPlayerUpdate: vi.fn(),
|
||||
})
|
||||
|
||||
// Mock rooms API
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rooms: [
|
||||
{
|
||||
id: 'room-1',
|
||||
code: 'ABC123',
|
||||
name: 'Test Room',
|
||||
gameName: 'matching',
|
||||
status: 'lobby',
|
||||
createdAt: new Date(),
|
||||
creatorName: 'Test User',
|
||||
isLocked: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
render(<RoomBrowserPage />)
|
||||
|
||||
// Should render the page
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('🎮 Multiplayer Rooms')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should NOT redirect to /arcade/room
|
||||
expect(mockRouter.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT redirect when PageWithNav uses arcade guard with enabled=false', async () => {
|
||||
// Simulate PageWithNav calling useArcadeGuard with enabled=false
|
||||
const arcadeGuardSpy = vi.spyOn(arcadeGuard, 'useArcadeGuard')
|
||||
|
||||
// User has an active game session
|
||||
arcadeGuardSpy.mockReturnValue({
|
||||
hasActiveSession: true,
|
||||
loading: false,
|
||||
activeSession: {
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
},
|
||||
})
|
||||
|
||||
vi.spyOn(roomData, 'useRoomData').mockReturnValue({
|
||||
roomData: null,
|
||||
isLoading: false,
|
||||
isInRoom: false,
|
||||
notifyRoomOfPlayerUpdate: vi.fn(),
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ rooms: [] }),
|
||||
})
|
||||
|
||||
render(<RoomBrowserPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('🎮 Multiplayer Rooms')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// PageWithNav should have called useArcadeGuard with enabled=false
|
||||
// This is tested in PageWithNav's own tests, but we verify no redirect happened
|
||||
expect(mockRouter.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow navigation to room detail even with active session', async () => {
|
||||
vi.spyOn(arcadeGuard, 'useArcadeGuard').mockReturnValue({
|
||||
hasActiveSession: true,
|
||||
loading: false,
|
||||
activeSession: {
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
},
|
||||
})
|
||||
|
||||
vi.spyOn(roomData, 'useRoomData').mockReturnValue({
|
||||
roomData: null,
|
||||
isLoading: false,
|
||||
isInRoom: false,
|
||||
notifyRoomOfPlayerUpdate: vi.fn(),
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rooms: [
|
||||
{
|
||||
id: 'room-1',
|
||||
code: 'ABC123',
|
||||
name: 'Test Room',
|
||||
gameName: 'matching',
|
||||
status: 'lobby',
|
||||
createdAt: new Date(),
|
||||
creatorName: 'Test User',
|
||||
isLocked: false,
|
||||
isMember: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
render(<RoomBrowserPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Room')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click on the room card
|
||||
const roomCard = screen.getByText('Test Room').parentElement
|
||||
roomCard?.click()
|
||||
|
||||
// Should navigate to room detail, not to /arcade/room
|
||||
await waitFor(() => {
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/arcade-rooms/room-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Room navigation edge cases', () => {
|
||||
it('should handle rapid navigation between room pages without redirect loops', async () => {
|
||||
vi.spyOn(arcadeGuard, 'useArcadeGuard').mockReturnValue({
|
||||
hasActiveSession: true,
|
||||
loading: false,
|
||||
activeSession: {
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
},
|
||||
})
|
||||
|
||||
vi.spyOn(roomData, 'useRoomData').mockReturnValue({
|
||||
roomData: null,
|
||||
isLoading: false,
|
||||
isInRoom: false,
|
||||
notifyRoomOfPlayerUpdate: vi.fn(),
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ rooms: [] }),
|
||||
})
|
||||
|
||||
const { rerender } = render(<RoomBrowserPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('🎮 Multiplayer Rooms')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Simulate pathname changes (navigating between room pages)
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade-rooms/room-1')
|
||||
rerender(<RoomBrowserPage />)
|
||||
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade-rooms')
|
||||
rerender(<RoomBrowserPage />)
|
||||
|
||||
// Should never redirect to game page
|
||||
expect(mockRouter.push).not.toHaveBeenCalledWith('/arcade/room')
|
||||
})
|
||||
|
||||
it('should allow user to leave room and browse other rooms during active game', async () => {
|
||||
// User is in a room with an active game
|
||||
vi.spyOn(arcadeGuard, 'useArcadeGuard').mockReturnValue({
|
||||
hasActiveSession: true,
|
||||
loading: false,
|
||||
activeSession: {
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
},
|
||||
})
|
||||
|
||||
vi.spyOn(roomData, 'useRoomData').mockReturnValue({
|
||||
roomData: {
|
||||
id: 'room-1',
|
||||
name: 'Current Room',
|
||||
code: 'ABC123',
|
||||
gameName: 'matching',
|
||||
members: [],
|
||||
memberPlayers: {},
|
||||
},
|
||||
isLoading: false,
|
||||
isInRoom: true,
|
||||
notifyRoomOfPlayerUpdate: vi.fn(),
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rooms: [
|
||||
{
|
||||
id: 'room-1',
|
||||
name: 'Current Room',
|
||||
code: 'ABC123',
|
||||
gameName: 'matching',
|
||||
status: 'playing',
|
||||
isMember: true,
|
||||
},
|
||||
{
|
||||
id: 'room-2',
|
||||
name: 'Other Room',
|
||||
code: 'DEF456',
|
||||
gameName: 'memory-quiz',
|
||||
status: 'lobby',
|
||||
isMember: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
render(<RoomBrowserPage />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Current Room')).toBeInTheDocument()
|
||||
expect(screen.getByText('Other Room')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should be able to view both rooms without redirect
|
||||
expect(mockRouter.push).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
468
apps/web/src/app/arcade-rooms/page.tsx
Normal file
468
apps/web/src/app/arcade-rooms/page.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
|
||||
interface Room {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
gameName: string
|
||||
status: 'lobby' | 'playing' | 'finished'
|
||||
createdAt: Date
|
||||
creatorName: string
|
||||
isLocked: boolean
|
||||
memberCount?: number
|
||||
playerCount?: number
|
||||
isMember?: boolean
|
||||
}
|
||||
|
||||
export default function RoomBrowserPage() {
|
||||
const router = useRouter()
|
||||
const [rooms, setRooms] = useState<Room[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchRooms()
|
||||
}, [])
|
||||
|
||||
const fetchRooms = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetch('/api/arcade/rooms')
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
setRooms(data.rooms)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch rooms:', err)
|
||||
setError('Failed to load rooms')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const createRoom = async (name: string, gameName: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/arcade/rooms', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
gameName,
|
||||
creatorName: 'Player',
|
||||
gameConfig: { difficulty: 6 },
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
router.push(`/arcade-rooms/${data.room.id}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to create room:', err)
|
||||
alert('Failed to create room')
|
||||
}
|
||||
}
|
||||
|
||||
const joinRoom = async (roomId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/arcade/rooms/${roomId}/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ displayName: 'Player' }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
|
||||
// Handle specific room membership conflict
|
||||
if (errorData.code === 'ROOM_MEMBERSHIP_CONFLICT') {
|
||||
alert(errorData.userMessage || errorData.message)
|
||||
// Refresh the page to update room list state
|
||||
await fetchRooms()
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(errorData.error || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Show notification if user was auto-removed from other rooms
|
||||
if (data.autoLeave) {
|
||||
console.log(`[Room Join] ${data.autoLeave.message}`)
|
||||
// Could show a toast notification here in the future
|
||||
}
|
||||
|
||||
router.push(`/arcade-rooms/${roomId}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to join room:', err)
|
||||
alert('Failed to join room')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav>
|
||||
<div
|
||||
className={css({
|
||||
minH: 'calc(100vh - 80px)',
|
||||
bg: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
|
||||
p: '8',
|
||||
})}
|
||||
>
|
||||
<div className={css({ maxW: '1200px', mx: 'auto' })}>
|
||||
{/* Header */}
|
||||
<div className={css({ mb: '8', textAlign: 'center' })}>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '4',
|
||||
})}
|
||||
>
|
||||
🎮 Multiplayer Rooms
|
||||
</h1>
|
||||
<p className={css({ color: '#a0a0ff', fontSize: 'lg', mb: '6' })}>
|
||||
Join a room or create your own to play with friends
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className={css({
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: '#10b981',
|
||||
color: 'white',
|
||||
rounded: 'lg',
|
||||
fontSize: 'lg',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: '#059669' },
|
||||
transition: 'all 0.2s',
|
||||
})}
|
||||
>
|
||||
+ Create New Room
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Room List */}
|
||||
{loading && (
|
||||
<div className={css({ textAlign: 'center', color: 'white', py: '12' })}>
|
||||
Loading rooms...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className={css({
|
||||
bg: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
color: '#991b1b',
|
||||
p: '4',
|
||||
rounded: 'lg',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && rooms.length === 0 && (
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(255, 255, 255, 0.05)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
rounded: 'lg',
|
||||
p: '12',
|
||||
textAlign: 'center',
|
||||
color: 'white',
|
||||
})}
|
||||
>
|
||||
<p className={css({ fontSize: 'xl', mb: '2' })}>No rooms available</p>
|
||||
<p className={css({ color: '#a0a0ff' })}>Be the first to create one!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && rooms.length > 0 && (
|
||||
<div className={css({ display: 'grid', gap: '4' })}>
|
||||
{rooms.map((room) => (
|
||||
<div
|
||||
key={room.id}
|
||||
className={css({
|
||||
bg: 'rgba(255, 255, 255, 0.05)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
rounded: 'lg',
|
||||
p: '6',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
bg: 'rgba(255, 255, 255, 0.08)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
onClick={() => router.push(`/arcade-rooms/${room.id}`)}
|
||||
className={css({ flex: 1, cursor: 'pointer' })}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '3',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'white' })}
|
||||
>
|
||||
{room.name}
|
||||
</h3>
|
||||
<span
|
||||
className={css({
|
||||
px: '3',
|
||||
py: '1',
|
||||
bg: 'rgba(255, 255, 255, 0.1)',
|
||||
color: '#fbbf24',
|
||||
rounded: 'full',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'monospace',
|
||||
})}
|
||||
>
|
||||
{room.code}
|
||||
</span>
|
||||
{room.isLocked && (
|
||||
<span className={css({ color: '#f87171', fontSize: 'sm' })}>
|
||||
🔒 Locked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '4',
|
||||
color: '#a0a0ff',
|
||||
fontSize: 'sm',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
<span>👤 Host: {room.creatorName}</span>
|
||||
<span>🎮 {room.gameName}</span>
|
||||
{room.memberCount !== undefined && (
|
||||
<span>
|
||||
👥 {room.memberCount} member{room.memberCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{room.playerCount !== undefined && room.playerCount > 0 && (
|
||||
<span>
|
||||
🎯 {room.playerCount} player{room.playerCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={css({
|
||||
color:
|
||||
room.status === 'lobby'
|
||||
? '#10b981'
|
||||
: room.status === 'playing'
|
||||
? '#fbbf24'
|
||||
: '#6b7280',
|
||||
})}
|
||||
>
|
||||
{room.status === 'lobby'
|
||||
? '⏳ Waiting'
|
||||
: room.status === 'playing'
|
||||
? '🎮 Playing'
|
||||
: '✓ Finished'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{room.isMember ? (
|
||||
<div
|
||||
className={css({
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: '#10b981',
|
||||
color: 'white',
|
||||
rounded: 'lg',
|
||||
fontWeight: '600',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
})}
|
||||
>
|
||||
✓ Joined
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
joinRoom(room.id)
|
||||
}}
|
||||
disabled={room.isLocked}
|
||||
className={css({
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: room.isLocked ? '#6b7280' : '#3b82f6',
|
||||
color: 'white',
|
||||
rounded: 'lg',
|
||||
fontWeight: '600',
|
||||
cursor: room.isLocked ? 'not-allowed' : 'pointer',
|
||||
opacity: room.isLocked ? 0.5 : 1,
|
||||
_hover: room.isLocked ? {} : { bg: '#2563eb' },
|
||||
transition: 'all 0.2s',
|
||||
})}
|
||||
>
|
||||
Join Room
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Room Modal */}
|
||||
{showCreateModal && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
bg: 'rgba(0, 0, 0, 0.7)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 50,
|
||||
})}
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
bg: 'white',
|
||||
rounded: 'xl',
|
||||
p: '8',
|
||||
maxW: '500px',
|
||||
w: 'full',
|
||||
mx: '4',
|
||||
})}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className={css({ fontSize: '2xl', fontWeight: 'bold', mb: '6' })}>
|
||||
Create New Room
|
||||
</h2>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const name = formData.get('name') as string
|
||||
const gameName = formData.get('gameName') as string
|
||||
if (name && gameName) {
|
||||
createRoom(name, gameName)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={css({ mb: '4' })}>
|
||||
<label className={css({ display: 'block', mb: '2', fontWeight: '600' })}>
|
||||
Room Name
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="My Awesome Room"
|
||||
className={css({
|
||||
w: 'full',
|
||||
px: '4',
|
||||
py: '3',
|
||||
border: '1px solid #d1d5db',
|
||||
rounded: 'lg',
|
||||
_focus: { outline: 'none', borderColor: '#3b82f6' },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className={css({ mb: '6' })}>
|
||||
<label className={css({ display: 'block', mb: '2', fontWeight: '600' })}>
|
||||
Game
|
||||
</label>
|
||||
<select
|
||||
name="gameName"
|
||||
required
|
||||
className={css({
|
||||
w: 'full',
|
||||
px: '4',
|
||||
py: '3',
|
||||
border: '1px solid #d1d5db',
|
||||
rounded: 'lg',
|
||||
_focus: { outline: 'none', borderColor: '#3b82f6' },
|
||||
})}
|
||||
>
|
||||
<option value="matching">Memory Matching</option>
|
||||
<option value="memory-quiz">Memory Quiz</option>
|
||||
<option value="complement-race">Complement Race</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className={css({ display: 'flex', gap: '3' })}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className={css({
|
||||
flex: 1,
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: '#e5e7eb',
|
||||
color: '#374151',
|
||||
rounded: 'lg',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: '#d1d5db' },
|
||||
})}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={css({
|
||||
flex: 1,
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: '#10b981',
|
||||
color: 'white',
|
||||
rounded: 'lg',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: '#059669' },
|
||||
})}
|
||||
>
|
||||
Create Room
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface SpeechBubbleProps {
|
||||
message: string
|
||||
onHide: () => void
|
||||
}
|
||||
|
||||
export function SpeechBubble({ message, onHide }: SpeechBubbleProps) {
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-hide after 3.5s (line 11749-11752)
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false)
|
||||
setTimeout(onHide, 300) // Wait for fade-out animation
|
||||
}, 3500)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [onHide])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 'calc(100% + 10px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'white',
|
||||
borderRadius: '15px',
|
||||
padding: '10px 15px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
|
||||
fontSize: '14px',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
maxWidth: '250px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
{/* Tail pointing down */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-8px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '8px solid transparent',
|
||||
borderRight: '8px solid transparent',
|
||||
borderTop: '8px solid white',
|
||||
filter: 'drop-shadow(0 2px 2px rgba(0,0,0,0.1))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { AIRacer } from '../../lib/gameTypes'
|
||||
|
||||
export type CommentaryContext =
|
||||
| 'ahead'
|
||||
| 'behind'
|
||||
| 'adaptive_struggle'
|
||||
| 'adaptive_mastery'
|
||||
| 'player_passed'
|
||||
| 'ai_passed'
|
||||
| 'lapped'
|
||||
| 'desperate_catchup'
|
||||
|
||||
// Swift AI - Competitive personality (lines 11768-11834)
|
||||
export const swiftAICommentary: Record<CommentaryContext, string[]> = {
|
||||
ahead: [
|
||||
'💨 Eat my dust!',
|
||||
'🔥 Too slow for me!',
|
||||
"⚡ You can't catch me!",
|
||||
"🚀 I'm built for speed!",
|
||||
'🏃♂️ This is way too easy!',
|
||||
],
|
||||
behind: [
|
||||
'😤 Not over yet!',
|
||||
"💪 I'm just getting started!",
|
||||
'🔥 Watch me catch up to you!',
|
||||
"⚡ I'm coming for you!",
|
||||
'🏃♂️ This is my comeback!',
|
||||
],
|
||||
adaptive_struggle: [
|
||||
'😏 You struggling much?',
|
||||
'🤖 Math is easy for me!',
|
||||
'⚡ You need to think faster!',
|
||||
'🔥 Need me to slow down?',
|
||||
],
|
||||
adaptive_mastery: [
|
||||
"😮 You're actually impressive!",
|
||||
"🤔 You're getting faster...",
|
||||
'😤 Time for me to step it up!',
|
||||
'⚡ Not bad for a human!',
|
||||
],
|
||||
player_passed: [
|
||||
'😠 No way you just passed me!',
|
||||
"🔥 This isn't over!",
|
||||
"💨 I'm just getting warmed up!",
|
||||
"😤 Your lucky streak won't last!",
|
||||
"⚡ I'll be back in front of you soon!",
|
||||
],
|
||||
ai_passed: [
|
||||
'💨 See ya later, slowpoke!',
|
||||
'😎 Thanks for the warm-up!',
|
||||
"🔥 This is how it's done!",
|
||||
"⚡ I'll see you at the finish line!",
|
||||
'💪 Try to keep up with me!',
|
||||
],
|
||||
lapped: [
|
||||
'😡 You just lapped me?! No way!',
|
||||
'🤬 This is embarrassing for me!',
|
||||
"😤 I'm not going down without a fight!",
|
||||
'💢 How did you get so far ahead?!',
|
||||
'🔥 Time to show you my real speed!',
|
||||
"😠 You won't stay ahead for long!",
|
||||
],
|
||||
desperate_catchup: [
|
||||
"🚨 TURBO MODE ACTIVATED! I'm coming for you!",
|
||||
'💥 You forced me to unleash my true power!',
|
||||
'🔥 NO MORE MR. NICE AI! Time to go all out!',
|
||||
"⚡ I'm switching to MAXIMUM OVERDRIVE!",
|
||||
"😤 You made me angry - now you'll see what I can do!",
|
||||
"🚀 AFTERBURNERS ENGAGED! This isn't over!",
|
||||
],
|
||||
}
|
||||
|
||||
// Math Bot - Analytical personality (lines 11835-11901)
|
||||
export const mathBotCommentary: Record<CommentaryContext, string[]> = {
|
||||
ahead: [
|
||||
'📊 My performance is optimal!',
|
||||
'🤖 My logic beats your speed!',
|
||||
'📈 I have 87% win probability!',
|
||||
"⚙️ I'm perfectly calibrated!",
|
||||
'🔬 Science prevails over you!',
|
||||
],
|
||||
behind: [
|
||||
'🤔 Recalculating my strategy...',
|
||||
"📊 You're exceeding my projections!",
|
||||
'⚙️ Adjusting my parameters!',
|
||||
"🔬 I'm analyzing your technique!",
|
||||
"📈 You're a statistical anomaly!",
|
||||
],
|
||||
adaptive_struggle: [
|
||||
'📊 I detect inefficiencies in you!',
|
||||
'🔬 You should focus on patterns!',
|
||||
'⚙️ Use that extra time wisely!',
|
||||
'📈 You have room for improvement!',
|
||||
],
|
||||
adaptive_mastery: [
|
||||
'🤖 Your optimization is excellent!',
|
||||
'📊 Your metrics are impressive!',
|
||||
"⚙️ I'm updating my models because of you!",
|
||||
'🔬 You have near-AI efficiency!',
|
||||
],
|
||||
player_passed: [
|
||||
'🤖 Your strategy is fascinating!',
|
||||
"📊 You're an unexpected variable!",
|
||||
"⚙️ I'm adjusting my algorithms...",
|
||||
'🔬 Your execution is impressive!',
|
||||
"📈 I'm recalculating the odds!",
|
||||
],
|
||||
ai_passed: [
|
||||
'🤖 My efficiency is optimized!',
|
||||
'📊 Just as I calculated!',
|
||||
'⚙️ All my systems nominal!',
|
||||
'🔬 My logic prevails over you!',
|
||||
"📈 I'm at 96% confidence level!",
|
||||
],
|
||||
lapped: [
|
||||
'🤖 Error: You have exceeded my projections!',
|
||||
'📊 This outcome has 0.3% probability!',
|
||||
'⚙️ I need to recalibrate my systems!',
|
||||
'🔬 Your performance is... statistically improbable!',
|
||||
'📈 My confidence level just dropped to 12%!',
|
||||
'🤔 I must analyze your methodology!',
|
||||
],
|
||||
desperate_catchup: [
|
||||
'🤖 EMERGENCY PROTOCOL ACTIVATED! Initiating maximum speed!',
|
||||
'🚨 CRITICAL GAP DETECTED! Engaging catchup algorithms!',
|
||||
'⚙️ OVERCLOCKING MY PROCESSORS! Prepare for rapid acceleration!',
|
||||
'📊 PROBABILITY OF FAILURE: UNACCEPTABLE! Switching to turbo mode!',
|
||||
"🔬 HYPOTHESIS: You're about to see my true potential!",
|
||||
'📈 CONFIDENCE LEVEL: RISING! My comeback protocol is online!',
|
||||
],
|
||||
}
|
||||
|
||||
// Get AI commentary message (lines 11636-11657)
|
||||
export function getAICommentary(
|
||||
racer: AIRacer,
|
||||
context: CommentaryContext,
|
||||
_playerProgress: number,
|
||||
_aiProgress: number
|
||||
): string | null {
|
||||
// Check cooldown (line 11759-11761)
|
||||
const now = Date.now()
|
||||
if (now - racer.lastComment < racer.commentCooldown) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Select message set based on personality and context
|
||||
const messages =
|
||||
racer.personality === 'competitive' ? swiftAICommentary[context] : mathBotCommentary[context]
|
||||
|
||||
if (!messages || messages.length === 0) return null
|
||||
|
||||
// Return random message
|
||||
return messages[Math.floor(Math.random() * messages.length)]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
|
||||
interface AbacusTargetProps {
|
||||
number: number // The complement number to display
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a small abacus showing a complement number inline in the equation
|
||||
* Used to help learners recognize the abacus representation of complement numbers
|
||||
*/
|
||||
export function AbacusTarget({ number }: AbacusTargetProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 0,
|
||||
}}
|
||||
>
|
||||
<AbacusReact
|
||||
value={number}
|
||||
columns={1}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
hideInactiveBeads={true}
|
||||
scaleFactor={0.72}
|
||||
customStyles={{
|
||||
columnPosts: { opacity: 0 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
'use client'
|
||||
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { GameControls } from './GameControls'
|
||||
import { GameCountdown } from './GameCountdown'
|
||||
import { GameDisplay } from './GameDisplay'
|
||||
import { GameIntro } from './GameIntro'
|
||||
import { GameResults } from './GameResults'
|
||||
|
||||
export function ComplementRaceGame() {
|
||||
const { state } = useComplementRace()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="game-page-root"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
padding: '20px 8px',
|
||||
minHeight: '100vh',
|
||||
maxHeight: '100vh',
|
||||
background:
|
||||
state.style === 'sprint'
|
||||
? 'linear-gradient(to bottom, #2563eb 0%, #60a5fa 100%)'
|
||||
: 'radial-gradient(ellipse at center, #8db978 0%, #7ba565 40%, #6a9354 100%)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Background pattern - subtle grass texture */}
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
opacity: 0.15,
|
||||
}}
|
||||
>
|
||||
<svg width="100%" height="100%">
|
||||
<defs>
|
||||
<pattern
|
||||
id="grass-texture"
|
||||
x="0"
|
||||
y="0"
|
||||
width="40"
|
||||
height="40"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<rect width="40" height="40" fill="transparent" />
|
||||
<line x1="2" y1="5" x2="8" y2="5" stroke="#2d5016" strokeWidth="1" opacity="0.3" />
|
||||
<line
|
||||
x1="15"
|
||||
y1="8"
|
||||
x2="20"
|
||||
y2="8"
|
||||
stroke="#2d5016"
|
||||
strokeWidth="1"
|
||||
opacity="0.25"
|
||||
/>
|
||||
<line
|
||||
x1="25"
|
||||
y1="12"
|
||||
x2="32"
|
||||
y2="12"
|
||||
stroke="#2d5016"
|
||||
strokeWidth="1"
|
||||
opacity="0.2"
|
||||
/>
|
||||
<line
|
||||
x1="5"
|
||||
y1="18"
|
||||
x2="12"
|
||||
y2="18"
|
||||
stroke="#2d5016"
|
||||
strokeWidth="1"
|
||||
opacity="0.3"
|
||||
/>
|
||||
<line
|
||||
x1="28"
|
||||
y1="22"
|
||||
x2="35"
|
||||
y2="22"
|
||||
stroke="#2d5016"
|
||||
strokeWidth="1"
|
||||
opacity="0.25"
|
||||
/>
|
||||
<line
|
||||
x1="10"
|
||||
y1="30"
|
||||
x2="16"
|
||||
y2="30"
|
||||
stroke="#2d5016"
|
||||
strokeWidth="1"
|
||||
opacity="0.2"
|
||||
/>
|
||||
<line
|
||||
x1="22"
|
||||
y1="35"
|
||||
x2="28"
|
||||
y2="35"
|
||||
stroke="#2d5016"
|
||||
strokeWidth="1"
|
||||
opacity="0.3"
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grass-texture)" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtle tree clusters around edges - top-down view with gentle sway */}
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
{/* Top-left tree cluster */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '5%',
|
||||
left: '3%',
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.2,
|
||||
filter: 'blur(4px)',
|
||||
animation: 'treeSway1 8s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Top-right tree cluster */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8%',
|
||||
right: '5%',
|
||||
width: '100px',
|
||||
height: '100px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.18,
|
||||
filter: 'blur(5px)',
|
||||
animation: 'treeSway2 10s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Bottom-left tree cluster */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '10%',
|
||||
left: '8%',
|
||||
width: '90px',
|
||||
height: '90px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.15,
|
||||
filter: 'blur(4px)',
|
||||
animation: 'treeSway1 9s ease-in-out infinite reverse',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Bottom-right tree cluster */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '5%',
|
||||
right: '4%',
|
||||
width: '110px',
|
||||
height: '110px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.2,
|
||||
filter: 'blur(6px)',
|
||||
animation: 'treeSway2 11s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Additional smaller clusters for depth */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '40%',
|
||||
left: '2%',
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.12,
|
||||
filter: 'blur(3px)',
|
||||
animation: 'treeSway1 7s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '55%',
|
||||
right: '3%',
|
||||
width: '70px',
|
||||
height: '70px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.14,
|
||||
filter: 'blur(4px)',
|
||||
animation: 'treeSway2 8.5s ease-in-out infinite reverse',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flying bird shadows - very subtle from aerial view */}
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '30%',
|
||||
left: '-5%',
|
||||
width: '15px',
|
||||
height: '8px',
|
||||
background: 'rgba(0, 0, 0, 0.08)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(2px)',
|
||||
animation: 'birdFly1 20s linear infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '60%',
|
||||
left: '-5%',
|
||||
width: '12px',
|
||||
height: '6px',
|
||||
background: 'rgba(0, 0, 0, 0.06)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(2px)',
|
||||
animation: 'birdFly2 28s linear infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '45%',
|
||||
left: '-5%',
|
||||
width: '10px',
|
||||
height: '5px',
|
||||
background: 'rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(1px)',
|
||||
animation: 'birdFly1 35s linear infinite',
|
||||
animationDelay: '-12s',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtle cloud shadows moving across field */}
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-10%',
|
||||
left: '-20%',
|
||||
width: '300px',
|
||||
height: '200px',
|
||||
background: 'radial-gradient(ellipse, rgba(0, 0, 0, 0.03) 0%, transparent 60%)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(20px)',
|
||||
animation: 'cloudShadow1 45s linear infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-10%',
|
||||
left: '-20%',
|
||||
width: '250px',
|
||||
height: '180px',
|
||||
background: 'radial-gradient(ellipse, rgba(0, 0, 0, 0.025) 0%, transparent 60%)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(25px)',
|
||||
animation: 'cloudShadow2 60s linear infinite',
|
||||
animationDelay: '-20s',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSS animations */}
|
||||
<style>{`
|
||||
@keyframes treeSway1 {
|
||||
0%, 100% { transform: scale(1) translate(0, 0); }
|
||||
25% { transform: scale(1.02) translate(2px, -1px); }
|
||||
50% { transform: scale(0.98) translate(-1px, 1px); }
|
||||
75% { transform: scale(1.01) translate(-2px, -1px); }
|
||||
}
|
||||
@keyframes treeSway2 {
|
||||
0%, 100% { transform: scale(1) translate(0, 0); }
|
||||
30% { transform: scale(1.015) translate(-2px, 1px); }
|
||||
60% { transform: scale(0.985) translate(2px, -1px); }
|
||||
80% { transform: scale(1.01) translate(1px, 1px); }
|
||||
}
|
||||
@keyframes birdFly1 {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(calc(100vw + 100px), -20vh); }
|
||||
}
|
||||
@keyframes birdFly2 {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(calc(100vw + 100px), 15vh); }
|
||||
}
|
||||
@keyframes cloudShadow1 {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(calc(100vw + 400px), 30vh); }
|
||||
}
|
||||
@keyframes cloudShadow2 {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(calc(100vw + 350px), -20vh); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{state.gamePhase === 'intro' && <GameIntro />}
|
||||
{state.gamePhase === 'controls' && <GameControls />}
|
||||
{state.gamePhase === 'countdown' && <GameCountdown />}
|
||||
{state.gamePhase === 'playing' && <GameDisplay />}
|
||||
{state.gamePhase === 'results' && <GameResults />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
'use client'
|
||||
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import type { ComplementDisplay, GameMode, GameStyle, TimeoutSetting } from '../lib/gameTypes'
|
||||
import { AbacusTarget } from './AbacusTarget'
|
||||
|
||||
export function GameControls() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
|
||||
const handleModeSelect = (mode: GameMode) => {
|
||||
dispatch({ type: 'SET_MODE', mode })
|
||||
}
|
||||
|
||||
const handleStyleSelect = (style: GameStyle) => {
|
||||
dispatch({ type: 'SET_STYLE', style })
|
||||
// Start the game immediately - no navigation needed
|
||||
if (style === 'sprint') {
|
||||
dispatch({ type: 'BEGIN_GAME' })
|
||||
} else {
|
||||
dispatch({ type: 'START_COUNTDOWN' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleTimeoutSelect = (timeout: TimeoutSetting) => {
|
||||
dispatch({ type: 'SET_TIMEOUT', timeout })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'linear-gradient(to bottom, #0f172a 0%, #1e293b 50%, #334155 100%)',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Animated background pattern */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage:
|
||||
'radial-gradient(circle at 20% 50%, rgba(59, 130, 246, 0.1) 0%, transparent 50%), radial-gradient(circle at 80% 80%, rgba(139, 92, 246, 0.1) 0%, transparent 50%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '20px',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '32px',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
margin: 0,
|
||||
letterSpacing: '-0.5px',
|
||||
}}
|
||||
>
|
||||
Complement Race
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Settings Bar */}
|
||||
<div
|
||||
style={{
|
||||
padding: '0 20px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Number Mode & Display */}
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(30, 41, 59, 0.8)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderRadius: '16px',
|
||||
padding: '16px',
|
||||
border: '1px solid rgba(148, 163, 184, 0.2)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '20px',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Number Mode Pills */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: '200px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#94a3b8',
|
||||
fontWeight: '600',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
>
|
||||
Mode:
|
||||
</span>
|
||||
{[
|
||||
{ mode: 'friends5' as GameMode, label: '5' },
|
||||
{ mode: 'friends10' as GameMode, label: '10' },
|
||||
{ mode: 'mixed' as GameMode, label: 'Mix' },
|
||||
].map(({ mode, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => handleModeSelect(mode)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
border: 'none',
|
||||
background:
|
||||
state.mode === mode
|
||||
? 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)'
|
||||
: 'rgba(148, 163, 184, 0.2)',
|
||||
color: state.mode === mode ? 'white' : '#94a3b8',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Complement Display Pills */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: '200px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#94a3b8',
|
||||
fontWeight: '600',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
>
|
||||
Show:
|
||||
</span>
|
||||
{(['number', 'abacus', 'random'] as ComplementDisplay[]).map((displayMode) => (
|
||||
<button
|
||||
key={displayMode}
|
||||
onClick={() => dispatch({ type: 'SET_COMPLEMENT_DISPLAY', display: displayMode })}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
border: 'none',
|
||||
background:
|
||||
state.complementDisplay === displayMode
|
||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
: 'rgba(148, 163, 184, 0.2)',
|
||||
color: state.complementDisplay === displayMode ? 'white' : '#94a3b8',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{displayMode === 'number' ? '123' : displayMode === 'abacus' ? '🧮' : '🎲'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Speed Pills */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: '200px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#94a3b8',
|
||||
fontWeight: '600',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
>
|
||||
Speed:
|
||||
</span>
|
||||
{(
|
||||
[
|
||||
'preschool',
|
||||
'kindergarten',
|
||||
'relaxed',
|
||||
'slow',
|
||||
'normal',
|
||||
'fast',
|
||||
'expert',
|
||||
] as TimeoutSetting[]
|
||||
).map((timeout) => (
|
||||
<button
|
||||
key={timeout}
|
||||
onClick={() => handleTimeoutSelect(timeout)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '16px',
|
||||
border: 'none',
|
||||
background:
|
||||
state.timeoutSetting === timeout
|
||||
? 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)'
|
||||
: 'rgba(148, 163, 184, 0.2)',
|
||||
color: state.timeoutSetting === timeout ? 'white' : '#94a3b8',
|
||||
fontWeight: state.timeoutSetting === timeout ? 'bold' : 'normal',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '11px',
|
||||
}}
|
||||
>
|
||||
{timeout === 'preschool'
|
||||
? 'Pre'
|
||||
: timeout === 'kindergarten'
|
||||
? 'K'
|
||||
: timeout.charAt(0).toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview - compact */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
padding: '12px',
|
||||
borderRadius: '12px',
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: '1px solid rgba(148, 163, 184, 0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '11px', color: '#94a3b8', fontWeight: '600' }}>Preview:</span>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
color: 'white',
|
||||
padding: '2px 10px',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
>
|
||||
?
|
||||
</div>
|
||||
<span style={{ fontSize: '16px', color: '#64748b' }}>+</span>
|
||||
{state.complementDisplay === 'number' ? (
|
||||
<span>3</span>
|
||||
) : state.complementDisplay === 'abacus' ? (
|
||||
<div style={{ transform: 'scale(0.8)' }}>
|
||||
<AbacusTarget number={3} />
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: '14px' }}>🎲</span>
|
||||
)}
|
||||
<span style={{ fontSize: '16px', color: '#64748b' }}>=</span>
|
||||
<span style={{ color: '#10b981' }}>
|
||||
{state.mode === 'friends5' ? '5' : state.mode === 'friends10' ? '10' : '?'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HERO SECTION - Race Cards */}
|
||||
<div
|
||||
data-component="race-cards-container"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '0 20px 20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{
|
||||
style: 'practice' as GameStyle,
|
||||
emoji: '🏁',
|
||||
title: 'Practice Race',
|
||||
desc: 'Race against AI to 20 correct answers',
|
||||
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
shadowColor: 'rgba(16, 185, 129, 0.5)',
|
||||
accentColor: '#34d399',
|
||||
},
|
||||
{
|
||||
style: 'sprint' as GameStyle,
|
||||
emoji: '🚂',
|
||||
title: 'Steam Sprint',
|
||||
desc: 'High-speed 60-second train journey',
|
||||
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
shadowColor: 'rgba(245, 158, 11, 0.5)',
|
||||
accentColor: '#fbbf24',
|
||||
},
|
||||
{
|
||||
style: 'survival' as GameStyle,
|
||||
emoji: '🔄',
|
||||
title: 'Survival Circuit',
|
||||
desc: 'Endless laps - beat your best time',
|
||||
gradient: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)',
|
||||
shadowColor: 'rgba(139, 92, 246, 0.5)',
|
||||
accentColor: '#a78bfa',
|
||||
},
|
||||
].map(({ style, emoji, title, desc, gradient, shadowColor, accentColor }) => (
|
||||
<button
|
||||
key={style}
|
||||
onClick={() => handleStyleSelect(style)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: '0',
|
||||
border: 'none',
|
||||
borderRadius: '24px',
|
||||
background: gradient,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: `0 10px 40px ${shadowColor}, 0 0 0 1px rgba(255, 255, 255, 0.1)`,
|
||||
transform: 'translateY(0)',
|
||||
flex: 1,
|
||||
minHeight: '140px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-8px) scale(1.02)'
|
||||
e.currentTarget.style.boxShadow = `0 20px 60px ${shadowColor}, 0 0 0 2px ${accentColor}`
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0) scale(1)'
|
||||
e.currentTarget.style.boxShadow = `0 10px 40px ${shadowColor}, 0 0 0 1px rgba(255, 255, 255, 0.1)`
|
||||
}}
|
||||
>
|
||||
{/* Shine effect overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.2) 0%, transparent 50%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '28px 32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '20px',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '64px',
|
||||
lineHeight: 1,
|
||||
filter: 'drop-shadow(0 4px 8px rgba(0,0,0,0.3))',
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
</div>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
marginBottom: '6px',
|
||||
textShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
textShadow: '0 1px 4px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
>
|
||||
{desc}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PLAY NOW button */}
|
||||
<div
|
||||
style={{
|
||||
background: 'white',
|
||||
color: gradient.includes('10b981')
|
||||
? '#047857'
|
||||
: gradient.includes('f59e0b')
|
||||
? '#d97706'
|
||||
: '#6b21a8',
|
||||
padding: '16px 32px',
|
||||
borderRadius: '16px',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '18px',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.25)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<span>PLAY</span>
|
||||
<span style={{ fontSize: '24px' }}>▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { useSoundEffects } from '../hooks/useSoundEffects'
|
||||
|
||||
export function GameCountdown() {
|
||||
const { dispatch } = useComplementRace()
|
||||
const { playSound } = useSoundEffects()
|
||||
const [count, setCount] = useState(3)
|
||||
const [showGo, setShowGo] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const countdownInterval = setInterval(() => {
|
||||
setCount((prevCount) => {
|
||||
if (prevCount > 1) {
|
||||
// Play countdown beep (volume 0.4)
|
||||
playSound('countdown', 0.4)
|
||||
return prevCount - 1
|
||||
} else if (prevCount === 1) {
|
||||
// Show GO!
|
||||
setShowGo(true)
|
||||
// Play race start fanfare (volume 0.6)
|
||||
playSound('race_start', 0.6)
|
||||
return 0
|
||||
}
|
||||
return prevCount
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(countdownInterval)
|
||||
}, [playSound])
|
||||
|
||||
useEffect(() => {
|
||||
if (showGo) {
|
||||
// Hide countdown and start game after GO animation
|
||||
const timer = setTimeout(() => {
|
||||
dispatch({ type: 'BEGIN_GAME' })
|
||||
}, 1000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [showGo, dispatch])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(0, 0, 0, 0.9)',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: showGo ? '120px' : '160px',
|
||||
fontWeight: 'bold',
|
||||
color: showGo ? '#10b981' : 'white',
|
||||
textShadow: '0 4px 20px rgba(0, 0, 0, 0.5)',
|
||||
animation: showGo ? 'scaleUp 1s ease-out' : 'pulse 0.5s ease-in-out',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{showGo ? 'GO!' : count}
|
||||
</div>
|
||||
|
||||
{!showGo && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '32px',
|
||||
fontSize: '24px',
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Get Ready!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.1); opacity: 0.8; }
|
||||
}
|
||||
@keyframes scaleUp {
|
||||
0% { transform: scale(0.5); opacity: 0; }
|
||||
50% { transform: scale(1.2); opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { useAdaptiveDifficulty } from '../hooks/useAdaptiveDifficulty'
|
||||
import { useAIRacers } from '../hooks/useAIRacers'
|
||||
import { useSoundEffects } from '../hooks/useSoundEffects'
|
||||
import { useSteamJourney } from '../hooks/useSteamJourney'
|
||||
import { generatePassengers } from '../lib/passengerGenerator'
|
||||
import { AbacusTarget } from './AbacusTarget'
|
||||
import { CircularTrack } from './RaceTrack/CircularTrack'
|
||||
import { LinearTrack } from './RaceTrack/LinearTrack'
|
||||
import { SteamTrainJourney } from './RaceTrack/SteamTrainJourney'
|
||||
import { RouteCelebration } from './RouteCelebration'
|
||||
|
||||
type FeedbackAnimation = 'correct' | 'incorrect' | null
|
||||
|
||||
export function GameDisplay() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
useAIRacers() // Activate AI racer updates (not used in sprint mode)
|
||||
const { trackPerformance, getAdaptiveFeedbackMessage } = useAdaptiveDifficulty()
|
||||
const { boostMomentum } = useSteamJourney()
|
||||
const { playSound } = useSoundEffects()
|
||||
const [feedbackAnimation, setFeedbackAnimation] = useState<FeedbackAnimation>(null)
|
||||
|
||||
// Clear feedback animation after it plays (line 1996, 2001)
|
||||
useEffect(() => {
|
||||
if (feedbackAnimation) {
|
||||
const timer = setTimeout(() => {
|
||||
setFeedbackAnimation(null)
|
||||
}, 500) // Match animation duration
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [feedbackAnimation])
|
||||
|
||||
// Show adaptive feedback with auto-hide
|
||||
useEffect(() => {
|
||||
if (state.adaptiveFeedback) {
|
||||
const timer = setTimeout(() => {
|
||||
dispatch({ type: 'CLEAR_ADAPTIVE_FEEDBACK' })
|
||||
}, 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [state.adaptiveFeedback, dispatch])
|
||||
|
||||
// Check for finish line (player reaches race goal) - only for practice mode
|
||||
useEffect(() => {
|
||||
if (
|
||||
state.correctAnswers >= state.raceGoal &&
|
||||
state.isGameActive &&
|
||||
state.style === 'practice'
|
||||
) {
|
||||
// Play celebration sound (line 14182)
|
||||
playSound('celebration')
|
||||
// End the game
|
||||
dispatch({ type: 'END_RACE' })
|
||||
// Show results after a short delay
|
||||
setTimeout(() => {
|
||||
dispatch({ type: 'SHOW_RESULTS' })
|
||||
}, 1500)
|
||||
}
|
||||
}, [state.correctAnswers, state.raceGoal, state.isGameActive, state.style, dispatch, playSound])
|
||||
|
||||
// For survival mode (endless circuit), track laps but never end
|
||||
// For sprint mode (steam sprint), end after 60 seconds (will implement later)
|
||||
|
||||
// Handle keyboard input
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
// Only process number keys
|
||||
if (/^[0-9]$/.test(e.key)) {
|
||||
const newInput = state.currentInput + e.key
|
||||
dispatch({ type: 'UPDATE_INPUT', input: newInput })
|
||||
|
||||
// Check if answer is complete
|
||||
if (state.currentQuestion) {
|
||||
const answer = parseInt(newInput, 10)
|
||||
const correctAnswer = state.currentQuestion.correctAnswer
|
||||
|
||||
// If we have enough digits to match the answer, submit
|
||||
if (newInput.length >= correctAnswer.toString().length) {
|
||||
const responseTime = Date.now() - state.questionStartTime
|
||||
const isCorrect = answer === correctAnswer
|
||||
const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}`
|
||||
|
||||
if (isCorrect) {
|
||||
// Correct answer
|
||||
dispatch({ type: 'SUBMIT_ANSWER', answer })
|
||||
trackPerformance(true, responseTime)
|
||||
|
||||
// Trigger correct answer animation (line 1996)
|
||||
setFeedbackAnimation('correct')
|
||||
|
||||
// Play appropriate sound based on performance (from web_generator.py lines 11530-11542)
|
||||
const newStreak = state.streak + 1
|
||||
if (newStreak > 0 && newStreak % 5 === 0) {
|
||||
// Epic streak sound for every 5th correct answer
|
||||
playSound('streak')
|
||||
} else if (responseTime < 800) {
|
||||
// Whoosh sound for very fast responses (under 800ms)
|
||||
playSound('whoosh')
|
||||
} else if (responseTime < 1200 && state.streak >= 3) {
|
||||
// Combo sound for rapid answers while on a streak
|
||||
playSound('combo')
|
||||
} else {
|
||||
// Regular correct sound
|
||||
playSound('correct')
|
||||
}
|
||||
|
||||
// Boost momentum for sprint mode
|
||||
if (state.style === 'sprint') {
|
||||
boostMomentum()
|
||||
|
||||
// Play train whistle for milestones in sprint mode (line 13222-13235)
|
||||
if (newStreak >= 5 && newStreak % 3 === 0) {
|
||||
// Major milestone - play train whistle
|
||||
setTimeout(() => {
|
||||
playSound('train_whistle', 0.4)
|
||||
}, 200)
|
||||
} else if (state.momentum >= 90) {
|
||||
// High momentum celebration - occasional whistle
|
||||
if (Math.random() < 0.3) {
|
||||
setTimeout(() => {
|
||||
playSound('train_whistle', 0.25)
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show adaptive feedback
|
||||
const feedback = getAdaptiveFeedbackMessage(pairKey, true, responseTime)
|
||||
if (feedback) {
|
||||
dispatch({ type: 'SHOW_ADAPTIVE_FEEDBACK', feedback })
|
||||
}
|
||||
|
||||
dispatch({ type: 'NEXT_QUESTION' })
|
||||
} else {
|
||||
// Incorrect answer
|
||||
trackPerformance(false, responseTime)
|
||||
|
||||
// Trigger incorrect answer animation (line 2001)
|
||||
setFeedbackAnimation('incorrect')
|
||||
|
||||
// Play incorrect sound (from web_generator.py line 11589)
|
||||
playSound('incorrect')
|
||||
|
||||
// Show adaptive feedback
|
||||
const feedback = getAdaptiveFeedbackMessage(pairKey, false, responseTime)
|
||||
if (feedback) {
|
||||
dispatch({ type: 'SHOW_ADAPTIVE_FEEDBACK', feedback })
|
||||
}
|
||||
|
||||
dispatch({ type: 'UPDATE_INPUT', input: '' })
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Backspace') {
|
||||
dispatch({ type: 'UPDATE_INPUT', input: state.currentInput.slice(0, -1) })
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyPress)
|
||||
return () => window.removeEventListener('keydown', handleKeyPress)
|
||||
}, [
|
||||
state.currentInput,
|
||||
state.currentQuestion,
|
||||
state.questionStartTime,
|
||||
state.style,
|
||||
state.streak,
|
||||
dispatch,
|
||||
trackPerformance,
|
||||
getAdaptiveFeedbackMessage,
|
||||
boostMomentum,
|
||||
playSound,
|
||||
state.momentum,
|
||||
])
|
||||
|
||||
// Handle route celebration continue
|
||||
const handleContinueToNextRoute = () => {
|
||||
const nextRoute = state.currentRoute + 1
|
||||
|
||||
// Start new route (this also hides celebration)
|
||||
dispatch({
|
||||
type: 'START_NEW_ROUTE',
|
||||
routeNumber: nextRoute,
|
||||
stations: state.stations, // Keep same stations for now
|
||||
})
|
||||
|
||||
// Generate new passengers
|
||||
const newPassengers = generatePassengers(state.stations)
|
||||
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
|
||||
}
|
||||
|
||||
if (!state.currentQuestion) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="game-display"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Adaptive Feedback */}
|
||||
{state.adaptiveFeedback && (
|
||||
<div
|
||||
data-component="adaptive-feedback"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '80px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
padding: '12px 24px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 20px rgba(102, 126, 234, 0.4)',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
zIndex: 1000,
|
||||
animation: 'slideDown 0.3s ease-out',
|
||||
maxWidth: '600px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{state.adaptiveFeedback.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Header - constrained width, hidden for sprint mode */}
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
data-component="stats-container"
|
||||
style={{
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
width: '100%',
|
||||
padding: '0 20px',
|
||||
marginTop: '10px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-component="stats-header"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
marginBottom: '10px',
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '10px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<div data-stat="score" style={{ textAlign: 'center' }}>
|
||||
<div style={{ color: '#6b7280', fontSize: '14px', marginBottom: '4px' }}>Score</div>
|
||||
<div style={{ fontWeight: 'bold', fontSize: '24px', color: '#3b82f6' }}>
|
||||
{state.score}
|
||||
</div>
|
||||
</div>
|
||||
<div data-stat="streak" style={{ textAlign: 'center' }}>
|
||||
<div style={{ color: '#6b7280', fontSize: '14px', marginBottom: '4px' }}>Streak</div>
|
||||
<div style={{ fontWeight: 'bold', fontSize: '24px', color: '#10b981' }}>
|
||||
{state.streak} 🔥
|
||||
</div>
|
||||
</div>
|
||||
<div data-stat="progress" style={{ textAlign: 'center' }}>
|
||||
<div style={{ color: '#6b7280', fontSize: '14px', marginBottom: '4px' }}>
|
||||
Progress
|
||||
</div>
|
||||
<div style={{ fontWeight: 'bold', fontSize: '24px', color: '#f59e0b' }}>
|
||||
{state.correctAnswers}/{state.raceGoal}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Race Track - full width, break out of padding */}
|
||||
<div
|
||||
data-component="track-container"
|
||||
style={{
|
||||
width: '100vw',
|
||||
position: 'relative',
|
||||
left: '50%',
|
||||
right: '50%',
|
||||
marginLeft: '-50vw',
|
||||
marginRight: '-50vw',
|
||||
padding: state.style === 'sprint' ? '0' : '0 20px',
|
||||
display: 'flex',
|
||||
justifyContent: state.style === 'sprint' ? 'stretch' : 'center',
|
||||
background: 'transparent',
|
||||
flex: state.style === 'sprint' ? 1 : 'initial',
|
||||
minHeight: state.style === 'sprint' ? 0 : 'initial',
|
||||
}}
|
||||
>
|
||||
{state.style === 'survival' ? (
|
||||
<CircularTrack
|
||||
playerProgress={state.correctAnswers}
|
||||
playerLap={state.playerLap}
|
||||
aiRacers={state.aiRacers}
|
||||
aiLaps={state.aiLaps}
|
||||
/>
|
||||
) : state.style === 'sprint' ? (
|
||||
<SteamTrainJourney
|
||||
momentum={state.momentum}
|
||||
trainPosition={state.trainPosition}
|
||||
pressure={state.pressure}
|
||||
elapsedTime={state.elapsedTime}
|
||||
currentQuestion={state.currentQuestion}
|
||||
currentInput={state.currentInput}
|
||||
/>
|
||||
) : (
|
||||
<LinearTrack
|
||||
playerProgress={state.correctAnswers}
|
||||
aiRacers={state.aiRacers}
|
||||
raceGoal={state.raceGoal}
|
||||
showFinishLine={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Question Display - only for non-sprint modes */}
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
data-component="question-container"
|
||||
style={{
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
width: '100%',
|
||||
padding: '0 20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginTop: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-component="question-display"
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
borderRadius: '24px',
|
||||
padding: '28px 50px',
|
||||
boxShadow: '0 16px 40px rgba(0, 0, 0, 0.3), 0 0 0 5px rgba(59, 130, 246, 0.4)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
border: '4px solid rgba(255, 255, 255, 0.95)',
|
||||
}}
|
||||
>
|
||||
{/* Complement equation as main focus */}
|
||||
<div
|
||||
data-element="question-equation"
|
||||
style={{
|
||||
fontSize: '96px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
lineHeight: '1.1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '20px',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
color: 'white',
|
||||
padding: '12px 32px',
|
||||
borderRadius: '16px',
|
||||
minWidth: '140px',
|
||||
display: 'inline-block',
|
||||
textShadow: '0 3px 10px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
{state.currentInput || '?'}
|
||||
</span>
|
||||
<span style={{ color: '#6b7280' }}>+</span>
|
||||
{state.currentQuestion.showAsAbacus ? (
|
||||
<div
|
||||
style={{
|
||||
transform: 'scale(2.4) translateY(8%)',
|
||||
transformOrigin: 'center center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AbacusTarget number={state.currentQuestion.number} />
|
||||
</div>
|
||||
) : (
|
||||
<span>{state.currentQuestion.number}</span>
|
||||
)}
|
||||
<span style={{ color: '#6b7280' }}>=</span>
|
||||
<span style={{ color: '#10b981' }}>{state.currentQuestion.targetSum}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route Celebration Modal */}
|
||||
{state.showRouteCelebration && state.style === 'sprint' && (
|
||||
<RouteCelebration
|
||||
completedRouteNumber={state.currentRoute}
|
||||
nextRouteNumber={state.currentRoute + 1}
|
||||
onContinue={handleContinueToNextRoute}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user