Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8473b6d670 | ||
|
|
53797dbb2d | ||
|
|
6b890b30f4 | ||
|
|
0596ef6587 | ||
|
|
debf786ed9 | ||
|
|
a2aada2e69 | ||
|
|
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 |
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
|
||||
398
CHANGELOG.md
398
CHANGELOG.md
@@ -1,3 +1,401 @@
|
||||
## [2.17.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.1...v2.17.2) (2025-10-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct playerMetadata userId assignment for room-based multiplayer ([53797db](https://github.com/antialias/soroban-abacus-flashcards/commit/53797dbb2d5ccb80e61cbc186ca0a344fe1fbd96))
|
||||
|
||||
## [2.17.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.0...v2.17.1) (2025-10-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct hover avatar and turn indicator to show only current player ([0596ef6](https://github.com/antialias/soroban-abacus-flashcards/commit/0596ef65879a303f1f71863ef307af69bf270c70))
|
||||
|
||||
## [2.17.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.7...v2.17.0) (2025-10-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* hide hover avatar when card is flipped to reveal value ([a2aada2](https://github.com/antialias/soroban-abacus-flashcards/commit/a2aada2e6922fb3af363e0d191275e06b8f8f040))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
32
Dockerfile
32
Dockerfile
@@ -34,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"]
|
||||
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.**
|
||||
@@ -1,36 +1,17 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfix: lazy-load database connection to prevent build-time access\n\nRefactor db/index.ts to use lazy initialization via Proxy pattern.\nThis prevents the database from being accessed at module import time,\nwhich was causing Next.js build failures in CI/CD environments where\nno database file exists.\n\nThe database connection is now created only when first accessed at\nruntime, allowing static site generation to complete successfully.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git push:*)",
|
||||
"Read(//Users/antialias/projects/soroban-abacus-flashcards/**)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(pnpm add:*)",
|
||||
"Bash(npx biome check:*)",
|
||||
"Bash(npx:*)",
|
||||
"Bash(eslint:*)",
|
||||
"Bash(npm run lint:fix:*)",
|
||||
"Bash(npm run format:*)",
|
||||
"Bash(npm test:*)",
|
||||
"Read(//Users/antialias/projects/**)",
|
||||
"Bash(npm run lint:*)",
|
||||
"Bash(pnpm install:*)",
|
||||
"Bash(pnpm run:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(lsof:*)",
|
||||
"Bash(xargs kill:*)",
|
||||
"Bash(tee:*)",
|
||||
"Bash(for file in src/app/arcade/complement-race/components/RaceTrack/CircularTrack.tsx src/app/arcade/complement-race/components/RaceTrack/LinearTrack.tsx src/app/games/complement-race/components/RaceTrack/CircularTrack.tsx src/app/games/complement-race/components/RaceTrack/LinearTrack.tsx)",
|
||||
"Bash(do)",
|
||||
"Bash(done)",
|
||||
"Bash(for file in src/app/arcade/complement-race/components/RaceTrack/SteamTrainJourney.tsx src/app/games/complement-race/components/RaceTrack/SteamTrainJourney.tsx)",
|
||||
"Bash(for file in src/app/arcade/complement-race/hooks/useTrackManagement.ts src/app/games/complement-race/hooks/useTrackManagement.ts)",
|
||||
"Bash(echo \"EXIT CODE: $?\")",
|
||||
"Bash(git commit -m \"$(cat <<''EOF''\nfeat: add Biome + ESLint linting setup\n\nAdd Biome for formatting and general linting, with minimal ESLint\nconfiguration for React Hooks rules only. This provides:\n\n- Fast formatting via Biome (10-100x faster than Prettier)\n- General JS/TS linting via Biome\n- React Hooks validation via ESLint (rules-of-hooks)\n- Import organization via Biome\n\nConfiguration files:\n- biome.jsonc: Biome config with custom rule overrides\n- eslint.config.js: Minimal flat config for React Hooks only\n- .gitignore: Added Biome cache exclusion\n- LINTING.md: Documentation for the setup\n\nScripts added to package.json:\n- npm run lint: Check all files\n- npm run lint:fix: Auto-fix issues\n- npm run format: Format all files\n- npm run check: Full Biome check\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
|
||||
"Bash(git commit:*)"
|
||||
"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": []
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -74,7 +74,10 @@ describe('Middleware E2E', () => {
|
||||
|
||||
it('sets secure flag in production', async () => {
|
||||
const originalEnv = process.env.NODE_ENV
|
||||
process.env.NODE_ENV = 'production'
|
||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
||||
value: 'production',
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/')
|
||||
const res = await middleware(req)
|
||||
@@ -82,12 +85,18 @@ describe('Middleware E2E', () => {
|
||||
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
|
||||
expect(cookie?.secure).toBe(true)
|
||||
|
||||
process.env.NODE_ENV = originalEnv
|
||||
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
|
||||
process.env.NODE_ENV = 'development'
|
||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
||||
value: 'development',
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/')
|
||||
const res = await middleware(req)
|
||||
@@ -95,7 +104,10 @@ describe('Middleware E2E', () => {
|
||||
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
|
||||
expect(cookie?.secure).toBe(false)
|
||||
|
||||
process.env.NODE_ENV = originalEnv
|
||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
||||
value: originalEnv,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('sets maxAge correctly', async () => {
|
||||
|
||||
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))
|
||||
})
|
||||
})
|
||||
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
|
||||
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
|
||||
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`);
|
||||
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": {}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,13 @@
|
||||
"when": 1759781243105,
|
||||
"tag": "0003_naive_reptil",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1759930182541,
|
||||
"tag": "0004_shiny_madelyne_pryor",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently \"node server.js\" \"npx @pandacss/dev --watch\"",
|
||||
"build": "node scripts/generate-build-info.js && next build",
|
||||
"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",
|
||||
@@ -89,6 +90,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"
|
||||
|
||||
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
|
||||
@@ -11,9 +11,8 @@ const handle = app.getRequestHandler()
|
||||
|
||||
// Run migrations before starting server
|
||||
console.log('🔄 Running database migrations...')
|
||||
require('tsx/cjs')
|
||||
const { migrate } = require('drizzle-orm/better-sqlite3/migrator')
|
||||
const { db } = require('./src/db/index.ts')
|
||||
const { db } = require('./src/db/index.js')
|
||||
|
||||
try {
|
||||
migrate(db, { migrationsFolder: './drizzle' })
|
||||
@@ -35,9 +34,8 @@ app.prepare().then(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Initialize Socket.IO (load TypeScript with tsx)
|
||||
require('tsx/cjs')
|
||||
const { initializeSocketServer } = require('./socket-server.ts')
|
||||
// Initialize Socket.IO
|
||||
const { initializeSocketServer } = require('./socket-server.js')
|
||||
initializeSocketServer(server)
|
||||
|
||||
server
|
||||
|
||||
@@ -1,29 +1,319 @@
|
||||
const { Server } = require('socket.io')
|
||||
|
||||
function initializeSocketServer(httpServer) {
|
||||
const io = new Server(httpServer, {
|
||||
path: '/api/socket',
|
||||
cors: {
|
||||
origin: process.env.NEXT_PUBLIC_URL || 'http://localhost:3000',
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
console.log('🔌 Client connected:', socket.id)
|
||||
|
||||
socket.on('join-arcade-session', ({ userId }) => {
|
||||
socket.join(`arcade:${userId}`)
|
||||
console.log(`👤 User ${userId} joined arcade room`)
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('🔌 Client disconnected:', socket.id)
|
||||
})
|
||||
})
|
||||
|
||||
console.log('✅ Socket.IO initialized on /api/socket')
|
||||
return io
|
||||
"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;
|
||||
}
|
||||
|
||||
module.exports = { initializeSocketServer }
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
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 type { GameMove } from './src/lib/arcade/validation'
|
||||
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',
|
||||
@@ -24,45 +43,72 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
let currentUserId: string | null = null
|
||||
|
||||
// Join arcade session room
|
||||
socket.on('join-arcade-session', async ({ userId }: { userId: string }) => {
|
||||
currentUserId = userId
|
||||
socket.join(`arcade:${userId}`)
|
||||
console.log(`👤 User ${userId} joined arcade room`)
|
||||
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`)
|
||||
|
||||
// Send current session state if exists
|
||||
try {
|
||||
const session = await getArcadeSession(userId)
|
||||
if (session) {
|
||||
socket.emit('session-state', {
|
||||
gameState: session.gameState,
|
||||
currentGame: session.currentGame,
|
||||
gameUrl: session.gameUrl,
|
||||
activePlayers: session.activePlayers,
|
||||
version: session.version,
|
||||
})
|
||||
} else {
|
||||
socket.emit('no-active-session')
|
||||
// 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' })
|
||||
}
|
||||
} 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 }) => {
|
||||
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') {
|
||||
const existingSession = await getArcadeSession(data.userId)
|
||||
// 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')
|
||||
@@ -85,20 +131,57 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
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/matching',
|
||||
gameUrl: '/arcade/room', // Room-based sessions use /arcade/room
|
||||
initialState,
|
||||
activePlayers,
|
||||
roomId: room.id,
|
||||
})
|
||||
|
||||
console.log('✅ Session created successfully')
|
||||
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', {
|
||||
io!.to(`arcade:${data.userId}`).emit('session-state', {
|
||||
gameState: newSession.gameState,
|
||||
currentGame: newSession.currentGame,
|
||||
gameUrl: newSession.gameUrl,
|
||||
@@ -110,15 +193,24 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
}
|
||||
}
|
||||
|
||||
const result = await applyGameMove(data.userId, data.move)
|
||||
// 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) {
|
||||
// Broadcast the updated state to all devices for this user
|
||||
io.to(`arcade:${data.userId}`).emit('move-accepted', {
|
||||
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)
|
||||
@@ -145,7 +237,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
|
||||
try {
|
||||
await deleteArcadeSession(userId)
|
||||
io.to(`arcade:${userId}`).emit('session-ended')
|
||||
io!.to(`arcade:${userId}`).emit('session-ended')
|
||||
} catch (error) {
|
||||
console.error('Error ending session:', error)
|
||||
socket.emit('session-error', { error: 'Failed to end session' })
|
||||
@@ -162,6 +254,111 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
}
|
||||
})
|
||||
|
||||
// Room: Join
|
||||
socket.on('join-room', async ({ roomId, userId }: { roomId: string; userId: string }) => {
|
||||
console.log(`🏠 User ${userId} joining room ${roomId}`)
|
||||
|
||||
try {
|
||||
// Join the socket room
|
||||
socket.join(`room:${roomId}`)
|
||||
|
||||
// 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) {
|
||||
@@ -171,6 +368,8 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
})
|
||||
})
|
||||
|
||||
// Store in globalThis to make accessible across module boundaries
|
||||
globalThis.__socketIO = io
|
||||
console.log('✅ Socket.IO initialized on /api/socket')
|
||||
return io
|
||||
}
|
||||
|
||||
@@ -1,54 +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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,10 +47,16 @@ export async function GET(request: NextRequest) {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { userId, gameName, gameUrl, initialState, activePlayers } = body
|
||||
const { userId, gameName, gameUrl, initialState, activePlayers, roomId } = body
|
||||
|
||||
if (!userId || !gameName || !gameUrl || !initialState || !activePlayers) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
|
||||
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({
|
||||
@@ -59,6 +65,7 @@ export async function POST(request: NextRequest) {
|
||||
gameUrl,
|
||||
initialState,
|
||||
activePlayers,
|
||||
roomId,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export async function GET(_request: NextRequest, { params }: { params: { id: str
|
||||
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,
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function GET(_request: NextRequest, { params }: { params: { id: str
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -62,7 +62,7 @@ export async function POST(request: NextRequest) {
|
||||
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}"`,
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type DisembarkingAnimation,
|
||||
usePassengerAnimations,
|
||||
} from '../../hooks/usePassengerAnimations'
|
||||
import type { ComplementQuestion } from '../../lib/gameTypes'
|
||||
import { useSteamJourney } from '../../hooks/useSteamJourney'
|
||||
import { useTrackManagement } from '../../hooks/useTrackManagement'
|
||||
import { useTrainTransforms } from '../../hooks/useTrainTransforms'
|
||||
@@ -80,7 +81,7 @@ interface SteamTrainJourneyProps {
|
||||
trainPosition: number
|
||||
pressure: number
|
||||
elapsedTime: number
|
||||
currentQuestion: { number: number; targetSum: number; correctAnswer: number } | null
|
||||
currentQuestion: ComplementQuestion | null
|
||||
currentInput: string
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('GameHUD', () => {
|
||||
|
||||
const mockPassenger: Passenger = {
|
||||
id: 'passenger-1',
|
||||
name: 'Test Passenger',
|
||||
avatar: '👨',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
@@ -49,6 +50,7 @@ describe('GameHUD', () => {
|
||||
number: 3,
|
||||
targetSum: 10,
|
||||
correctAnswer: 7,
|
||||
showAsAbacus: false,
|
||||
},
|
||||
currentInput: '7',
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ describe('usePassengerAnimations', () => {
|
||||
// Create mock passengers
|
||||
mockPassenger1 = {
|
||||
id: 'passenger-1',
|
||||
name: 'Passenger 1',
|
||||
avatar: '👨',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
@@ -54,6 +55,7 @@ describe('usePassengerAnimations', () => {
|
||||
|
||||
mockPassenger2 = {
|
||||
id: 'passenger-2',
|
||||
name: 'Passenger 2',
|
||||
avatar: '👩',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
|
||||
@@ -15,9 +15,9 @@ import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import { useSteamJourney } from '../useSteamJourney'
|
||||
|
||||
// Mock sound effects
|
||||
jest.mock('../useSoundEffects', () => ({
|
||||
vi.mock('../useSoundEffects', () => ({
|
||||
useSoundEffects: () => ({
|
||||
playSound: jest.fn(),
|
||||
playSound: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -53,12 +53,12 @@ const _testStations: Station[] = [
|
||||
|
||||
describe('useSteamJourney - Passenger Boarding', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers()
|
||||
jest.useRealTimers()
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
test('passenger boards when train reaches their origin station', () => {
|
||||
@@ -106,7 +106,7 @@ describe('useSteamJourney - Passenger Boarding', () => {
|
||||
|
||||
// Advance timers to trigger the interval
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100)
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// Verify passenger boarded
|
||||
@@ -150,7 +150,7 @@ describe('useSteamJourney - Passenger Boarding', () => {
|
||||
|
||||
// Advance timers
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100)
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// All three passengers should board (one per car)
|
||||
@@ -190,7 +190,7 @@ describe('useSteamJourney - Passenger Boarding', () => {
|
||||
pressure: 120,
|
||||
elapsedTime: 1000 + pos * 50,
|
||||
})
|
||||
jest.advanceTimersByTime(50)
|
||||
vi.advanceTimersByTime(50)
|
||||
})
|
||||
|
||||
// Check if passenger boarded
|
||||
@@ -239,7 +239,7 @@ describe('useSteamJourney - Passenger Boarding', () => {
|
||||
})
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100)
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// p2 should board (on car 1 since car 0 is occupied)
|
||||
@@ -282,7 +282,7 @@ describe('useSteamJourney - Passenger Boarding', () => {
|
||||
})
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100)
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// Passenger should be delivered
|
||||
|
||||
@@ -17,7 +17,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
mockPath.getPointAtLength = vi.fn((distance: number) => ({
|
||||
x: distance,
|
||||
y: 300,
|
||||
}))
|
||||
w: 1,
|
||||
z: 0,
|
||||
matrixTransform: () => new DOMPoint(),
|
||||
toJSON: () => ({ x: distance, y: 300, w: 1, z: 0 }),
|
||||
})) as any
|
||||
mockPathRef = { current: mockPath }
|
||||
|
||||
// Mock track generator
|
||||
|
||||
@@ -25,7 +25,11 @@ describe('useTrackManagement', () => {
|
||||
mockPath.getPointAtLength = vi.fn((distance: number) => ({
|
||||
x: distance,
|
||||
y: 300,
|
||||
}))
|
||||
w: 1,
|
||||
z: 0,
|
||||
matrixTransform: () => new DOMPoint(),
|
||||
toJSON: () => ({ x: distance, y: 300, w: 1, z: 0 }),
|
||||
})) as any
|
||||
mockPathRef = { current: mockPath }
|
||||
|
||||
// Mock track generator
|
||||
@@ -52,6 +56,7 @@ describe('useTrackManagement', () => {
|
||||
mockPassengers = [
|
||||
{
|
||||
id: 'passenger-1',
|
||||
name: 'Passenger 1',
|
||||
avatar: '👨',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
@@ -73,6 +78,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -90,6 +97,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -107,6 +116,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -123,6 +134,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -233,6 +246,7 @@ describe('useTrackManagement', () => {
|
||||
const newPassengers: Passenger[] = [
|
||||
{
|
||||
id: 'passenger-2',
|
||||
name: 'Passenger 2',
|
||||
avatar: '👩',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { pluralizeWord } from '../../../../utils/pluralization'
|
||||
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { MemoryGrid } from './MemoryGrid'
|
||||
import { PlayerStatusBar } from './PlayerStatusBar'
|
||||
|
||||
export function GamePhase() {
|
||||
const { state, resetGame: _resetGame, activePlayers } = useArcadeMemoryPairs()
|
||||
const { state, resetGame: _resetGame, activePlayers } = useMemoryPairs()
|
||||
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
|
||||
|
||||
// Convert Map to array and create mapping from numeric index to player
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { animated, useSpring } from '@react-spring/web'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { getGridConfiguration } from '../utils/cardGeneration'
|
||||
import { GameCard } from './GameCard'
|
||||
|
||||
@@ -80,8 +82,114 @@ function useGridDimensions(gridConfig: any, totalCards: number) {
|
||||
return gridDimensions
|
||||
}
|
||||
|
||||
// Animated hover avatar component
|
||||
function HoverAvatar({
|
||||
playerId,
|
||||
playerInfo,
|
||||
cardElement,
|
||||
isPlayersTurn,
|
||||
isCardFlipped,
|
||||
}: {
|
||||
playerId: string
|
||||
playerInfo: { emoji: string; name: string; color?: string }
|
||||
cardElement: HTMLElement | null
|
||||
isPlayersTurn: boolean
|
||||
isCardFlipped: boolean
|
||||
}) {
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
const isFirstRender = useRef(true)
|
||||
|
||||
// Update position when card element changes
|
||||
useEffect(() => {
|
||||
if (cardElement) {
|
||||
const rect = cardElement.getBoundingClientRect()
|
||||
// Calculate the center of the card for avatar positioning
|
||||
const avatarCenterX = rect.left + rect.width / 2
|
||||
const avatarCenterY = rect.top + rect.height / 2
|
||||
|
||||
setPosition({
|
||||
x: avatarCenterX,
|
||||
y: avatarCenterY,
|
||||
})
|
||||
}
|
||||
}, [cardElement])
|
||||
|
||||
// Smooth spring animation for position changes
|
||||
const springProps = useSpring({
|
||||
x: position?.x ?? 0,
|
||||
y: position?.y ?? 0,
|
||||
// Hide avatar if: no position, not player's turn, no card element, OR card is flipped
|
||||
opacity: position && isPlayersTurn && cardElement && !isCardFlipped ? 1 : 0,
|
||||
config: {
|
||||
tension: 280,
|
||||
friction: 60,
|
||||
mass: 1,
|
||||
},
|
||||
immediate: isFirstRender.current, // Skip animation on first render only
|
||||
})
|
||||
|
||||
// Clear first render flag after initial render
|
||||
useEffect(() => {
|
||||
if (position && isFirstRender.current) {
|
||||
isFirstRender.current = false
|
||||
}
|
||||
}, [position])
|
||||
|
||||
// Don't render until we have a position
|
||||
if (!position) return null
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
// Don't use translate, just position directly at the calculated point
|
||||
left: springProps.x.to((x) => `${x}px`),
|
||||
top: springProps.y.to((y) => `${y}px`),
|
||||
opacity: springProps.opacity,
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
marginLeft: '-40px', // Center horizontally (half of width)
|
||||
marginTop: '-40px', // Center vertically (half of height)
|
||||
borderRadius: '50%',
|
||||
background: playerInfo.color || 'linear-gradient(135deg, #667eea, #764ba2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '48px',
|
||||
// 3D elevation effect
|
||||
boxShadow:
|
||||
'0 12px 30px rgba(0,0,0,0.5), 0 6px 12px rgba(0,0,0,0.4), 0 0 40px rgba(102, 126, 234, 0.8)',
|
||||
border: '4px solid white',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none',
|
||||
filter: 'drop-shadow(0 0 12px rgba(102, 126, 234, 0.9))',
|
||||
}}
|
||||
className={css({
|
||||
animation: 'hoverFloat 2s ease-in-out infinite',
|
||||
})}
|
||||
title={`${playerInfo.name} is considering this card`}
|
||||
>
|
||||
{playerInfo.emoji}
|
||||
</animated.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MemoryGrid() {
|
||||
const { state, flipCard } = useArcadeMemoryPairs()
|
||||
const { state, flipCard, hoverCard, gameMode } = useMemoryPairs()
|
||||
const { data: viewerId } = useViewerId()
|
||||
|
||||
// Track card element refs for positioning hover avatars
|
||||
const cardRefs = useRef<Map<string, HTMLElement>>(new Map())
|
||||
|
||||
// Check if it's the local player's turn
|
||||
const isMyTurn = useMemo(() => {
|
||||
if (gameMode === 'single') return true // Always your turn in single player
|
||||
|
||||
// In local games, all players belong to current user, so always their turn
|
||||
// In room games, check if current player belongs to this user
|
||||
const currentPlayerMetadata = state.playerMetadata?.[state.currentPlayer]
|
||||
return currentPlayerMetadata?.userId === viewerId
|
||||
}, [state.currentPlayer, state.playerMetadata, viewerId, gameMode])
|
||||
|
||||
// Hooks must be called before early return
|
||||
const gridConfig = useMemo(() => getGridConfiguration(state.difficulty), [state.difficulty])
|
||||
@@ -95,6 +203,28 @@ export function MemoryGrid() {
|
||||
flipCard(cardId)
|
||||
}
|
||||
|
||||
// Get player metadata for hover avatars
|
||||
const getPlayerHoverInfo = (playerId: string) => {
|
||||
// Get player info from game state metadata
|
||||
const player = state.playerMetadata?.[playerId]
|
||||
return player
|
||||
? {
|
||||
emoji: player.emoji,
|
||||
name: player.name,
|
||||
color: player.color,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
// Set card ref callback
|
||||
const setCardRef = (cardId: string) => (element: HTMLDivElement | null) => {
|
||||
if (element) {
|
||||
cardRefs.current.set(cardId, element)
|
||||
} else {
|
||||
cardRefs.current.delete(cardId)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -162,6 +292,7 @@ export function MemoryGrid() {
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
ref={setCardRef(card.id)}
|
||||
className={css({
|
||||
aspectRatio: '3/4',
|
||||
// Fully responsive card sizing - no fixed pixel sizes
|
||||
@@ -172,7 +303,20 @@ export function MemoryGrid() {
|
||||
opacity: isDimmed ? 0.3 : 1,
|
||||
transition: 'opacity 0.3s ease',
|
||||
filter: isDimmed ? 'grayscale(0.7)' : 'none',
|
||||
position: 'relative',
|
||||
})}
|
||||
onMouseEnter={() => {
|
||||
// Only send hover if it's your turn and card is not matched
|
||||
if (hoverCard && !isMatched && isMyTurn) {
|
||||
hoverCard(card.id)
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
// Clear hover state when mouse leaves card
|
||||
if (hoverCard && !isMatched && isMyTurn) {
|
||||
hoverCard(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<GameCard
|
||||
card={card}
|
||||
@@ -233,23 +377,68 @@ export function MemoryGrid() {
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Animated Hover Avatars - Rendered as fixed positioned elements that smoothly transition */}
|
||||
{/* Render one avatar per player - key by playerId to keep component alive */}
|
||||
{state.playerHovers &&
|
||||
Object.entries(state.playerHovers)
|
||||
.filter(([playerId]) => {
|
||||
// Only show avatar for the CURRENT player whose turn it is
|
||||
// Don't show for other players (they're waiting for their turn)
|
||||
return playerId === state.currentPlayer
|
||||
})
|
||||
.map(([playerId, cardId]) => {
|
||||
const playerInfo = getPlayerHoverInfo(playerId)
|
||||
// Get card element if player is hovering (cardId might be null)
|
||||
const cardElement = cardId ? cardRefs.current.get(cardId) : null
|
||||
// Check if it's this player's turn
|
||||
const isPlayersTurn = state.currentPlayer === playerId
|
||||
// Check if the card being hovered is flipped
|
||||
const hoveredCard = cardId ? state.gameCards.find((c) => c.id === cardId) : null
|
||||
const isCardFlipped = hoveredCard
|
||||
? state.flippedCards.some((c) => c.id === hoveredCard.id) || hoveredCard.matched
|
||||
: false
|
||||
|
||||
if (!playerInfo) return null
|
||||
|
||||
// Render avatar even if no cardElement (it will handle hiding itself)
|
||||
return (
|
||||
<HoverAvatar
|
||||
key={playerId} // Key by playerId keeps component alive across card changes!
|
||||
playerId={playerId}
|
||||
playerInfo={playerInfo}
|
||||
cardElement={cardElement}
|
||||
isPlayersTurn={isPlayersTurn}
|
||||
isCardFlipped={isCardFlipped}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Add shake animation for mismatch feedback
|
||||
const shakeAnimation = `
|
||||
// Add animations for mismatch feedback and hover avatars
|
||||
const gridAnimations = `
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translate(-50%, -50%) translateX(0); }
|
||||
25% { transform: translate(-50%, -50%) translateX(-5px); }
|
||||
75% { transform: translate(-50%, -50%) translateX(5px); }
|
||||
}
|
||||
|
||||
@keyframes hoverFloat {
|
||||
0%, 100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Inject animation styles
|
||||
if (typeof document !== 'undefined' && !document.getElementById('memory-grid-animations')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'memory-grid-animations'
|
||||
style.textContent = shakeAnimation
|
||||
style.textContent = gridAnimations
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
@@ -3,20 +3,18 @@
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { StandardGameLayout } from '../../../../components/StandardGameLayout'
|
||||
import { useFullscreen } from '../../../../contexts/FullscreenContext'
|
||||
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { GamePhase } from './GamePhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
|
||||
export function MemoryPairsGame() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession, resetGame } = useArcadeMemoryPairs()
|
||||
const { state, exitSession, resetGame, goToSetup, canModifyPlayers } = useMemoryPairs()
|
||||
const { setFullscreenElement } = useFullscreen()
|
||||
const { canModifyPlayers } = useArcadeRedirect({ currentGame: 'matching' })
|
||||
const gameRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,11 +35,14 @@ export function MemoryPairsGame() {
|
||||
exitSession()
|
||||
router.push('/arcade')
|
||||
}}
|
||||
onSetup={() => {
|
||||
// Exit current session and return to arcade (which will redirect to setup)
|
||||
exitSession()
|
||||
router.push('/arcade/matching')
|
||||
}}
|
||||
onSetup={
|
||||
goToSetup
|
||||
? () => {
|
||||
// Transition to setup phase (will pause game if active)
|
||||
goToSetup()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onNewGame={() => {
|
||||
resetGame()
|
||||
}}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { gamePlurals } from '../../../../utils/pluralization'
|
||||
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
|
||||
interface PlayerStatusBarProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
|
||||
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
|
||||
const { state } = useArcadeMemoryPairs()
|
||||
const { state } = useMemoryPairs()
|
||||
const { data: viewerId } = useViewerId()
|
||||
|
||||
// Get active players array
|
||||
const activePlayersData = Array.from(activePlayerIds)
|
||||
.map((id) => playerMap.get(id))
|
||||
// Get active players from game state (not GameModeContext)
|
||||
// This ensures we only show players actually in this game
|
||||
const activePlayersData = state.activePlayers
|
||||
.map((id) => state.playerMetadata?.[id])
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined)
|
||||
|
||||
// Map active players to display data with scores
|
||||
@@ -26,8 +27,14 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
|
||||
displayEmoji: player.emoji,
|
||||
score: state.scores[player.id] || 0,
|
||||
consecutiveMatches: state.consecutiveMatches?.[player.id] || 0,
|
||||
// Check if this player belongs to the current viewer
|
||||
isLocalPlayer: player.userId === viewerId,
|
||||
}))
|
||||
|
||||
// Check if current player is local (your turn) or remote (waiting)
|
||||
const currentPlayer = activePlayers.find((p) => p.id === state.currentPlayer)
|
||||
const isYourTurn = currentPlayer?.isLocalPlayer === true
|
||||
|
||||
// Get celebration level based on consecutive matches
|
||||
const getCelebrationLevel = (consecutiveMatches: number) => {
|
||||
if (consecutiveMatches >= 5) return 'legendary'
|
||||
@@ -250,14 +257,16 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
|
||||
{isCurrentPlayer && (
|
||||
<span
|
||||
className={css({
|
||||
color: 'red.600',
|
||||
color: player.isLocalPlayer ? 'red.600' : 'blue.600',
|
||||
fontWeight: 'black',
|
||||
fontSize: isCurrentPlayer ? { base: 'sm', md: 'lg' } : 'inherit',
|
||||
animation: 'none',
|
||||
textShadow: '0 0 15px currentColor',
|
||||
animation: player.isLocalPlayer
|
||||
? 'none'
|
||||
: 'gentle-pulse 2s ease-in-out infinite',
|
||||
textShadow: player.isLocalPlayer ? '0 0 15px currentColor' : 'none',
|
||||
})}
|
||||
>
|
||||
{' • Your turn'}
|
||||
{player.isLocalPlayer ? ' • Your turn' : ' • Their turn'}
|
||||
</span>
|
||||
)}
|
||||
{player.consecutiveMatches > 1 && (
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { formatGameTime, getMultiplayerWinner, getPerformanceAnalysis } from '../utils/gameScoring'
|
||||
|
||||
export function ResultsPhase() {
|
||||
const router = useRouter()
|
||||
const { state, resetGame, activePlayers, gameMode } = useArcadeMemoryPairs()
|
||||
const { state, resetGame, activePlayers, gameMode, exitSession } = useMemoryPairs()
|
||||
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
|
||||
|
||||
// Get active player data array
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { useArcadeMemoryPairs } from '../context/ArcadeMemoryPairsContext'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
|
||||
// Add bounce animation for the start button
|
||||
const bounceAnimation = `
|
||||
@@ -32,14 +33,87 @@ export function SetupPhase() {
|
||||
state,
|
||||
setGameType,
|
||||
setDifficulty,
|
||||
setTurnTimer,
|
||||
startGame,
|
||||
resumeGame,
|
||||
canResumeGame,
|
||||
hasConfigChanged,
|
||||
activePlayers: _activePlayers,
|
||||
} = useArcadeMemoryPairs()
|
||||
} = useMemoryPairs()
|
||||
|
||||
const { activePlayerCount, gameMode: _globalGameMode } = useGameMode()
|
||||
|
||||
const handleStartGame = () => {
|
||||
startGame()
|
||||
// PAUSE/RESUME: Warning dialog state
|
||||
const [showConfigWarning, setShowConfigWarning] = useState(false)
|
||||
const [hasSeenWarning, setHasSeenWarning] = useState(false)
|
||||
const [pendingConfigChange, setPendingConfigChange] = useState<{
|
||||
type: 'gameType' | 'difficulty' | 'turnTimer'
|
||||
value: any
|
||||
} | null>(null)
|
||||
|
||||
// Check if we should show warning when changing config
|
||||
const shouldShowWarning = state.pausedGamePhase && !hasSeenWarning && !hasConfigChanged
|
||||
|
||||
// Config change handlers that check for paused game
|
||||
const handleSetGameType = (value: typeof state.gameType) => {
|
||||
if (shouldShowWarning) {
|
||||
setPendingConfigChange({ type: 'gameType', value })
|
||||
setShowConfigWarning(true)
|
||||
} else {
|
||||
setGameType(value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetDifficulty = (value: typeof state.difficulty) => {
|
||||
if (shouldShowWarning) {
|
||||
setPendingConfigChange({ type: 'difficulty', value })
|
||||
setShowConfigWarning(true)
|
||||
} else {
|
||||
setDifficulty(value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetTurnTimer = (value: typeof state.turnTimer) => {
|
||||
if (shouldShowWarning) {
|
||||
setPendingConfigChange({ type: 'turnTimer', value })
|
||||
setShowConfigWarning(true)
|
||||
} else {
|
||||
setTurnTimer(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply pending config change after warning
|
||||
const applyPendingChange = () => {
|
||||
if (pendingConfigChange) {
|
||||
switch (pendingConfigChange.type) {
|
||||
case 'gameType':
|
||||
setGameType(pendingConfigChange.value)
|
||||
break
|
||||
case 'difficulty':
|
||||
setDifficulty(pendingConfigChange.value)
|
||||
break
|
||||
case 'turnTimer':
|
||||
setTurnTimer(pendingConfigChange.value)
|
||||
break
|
||||
}
|
||||
setHasSeenWarning(true)
|
||||
setPendingConfigChange(null)
|
||||
setShowConfigWarning(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel config change
|
||||
const cancelConfigChange = () => {
|
||||
setPendingConfigChange(null)
|
||||
setShowConfigWarning(false)
|
||||
}
|
||||
|
||||
const handleStartOrResumeGame = () => {
|
||||
if (canResumeGame) {
|
||||
resumeGame()
|
||||
} else {
|
||||
startGame()
|
||||
}
|
||||
}
|
||||
|
||||
const getButtonStyles = (
|
||||
@@ -150,6 +224,94 @@ export function SetupPhase() {
|
||||
minHeight: 0, // Allow shrinking
|
||||
})}
|
||||
>
|
||||
{/* PAUSE/RESUME: Config change warning */}
|
||||
{showConfigWarning && (
|
||||
<div
|
||||
className={css({
|
||||
p: '4',
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(251, 191, 36, 0.15), rgba(245, 158, 11, 0.15))',
|
||||
border: '2px solid',
|
||||
borderColor: 'yellow.400',
|
||||
rounded: 'xl',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 4px 12px rgba(251, 191, 36, 0.2)',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
color: 'yellow.700',
|
||||
fontSize: { base: '15px', md: '17px' },
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
⚠️ Warning: Changing Settings Will End Current Game
|
||||
</p>
|
||||
<p
|
||||
className={css({
|
||||
color: 'gray.600',
|
||||
fontSize: { base: '13px', md: '14px' },
|
||||
marginBottom: '12px',
|
||||
})}
|
||||
>
|
||||
You have a paused game in progress. Changing any setting will end it and you won't be
|
||||
able to resume.
|
||||
</p>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #10b981, #059669)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.4)',
|
||||
},
|
||||
})}
|
||||
onClick={cancelConfigChange}
|
||||
>
|
||||
✓ Keep Game & Cancel Change
|
||||
</button>
|
||||
<button
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #ef4444, #dc2626)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
boxShadow: '0 2px 8px rgba(239, 68, 68, 0.3)',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(239, 68, 68, 0.4)',
|
||||
},
|
||||
})}
|
||||
onClick={applyPendingChange}
|
||||
>
|
||||
✗ End Game & Apply Change
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning if no players */}
|
||||
{activePlayerCount === 0 && (
|
||||
<div
|
||||
@@ -200,7 +362,7 @@ export function SetupPhase() {
|
||||
>
|
||||
<button
|
||||
className={getButtonStyles(state.gameType === 'abacus-numeral', 'secondary')}
|
||||
onClick={() => setGameType('abacus-numeral')}
|
||||
onClick={() => handleSetGameType('abacus-numeral')}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
@@ -246,7 +408,7 @@ export function SetupPhase() {
|
||||
</button>
|
||||
<button
|
||||
className={getButtonStyles(state.gameType === 'complement-pairs', 'secondary')}
|
||||
onClick={() => setGameType('complement-pairs')}
|
||||
onClick={() => handleSetGameType('complement-pairs')}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
@@ -342,7 +504,7 @@ export function SetupPhase() {
|
||||
<button
|
||||
key={difficulty}
|
||||
className={getButtonStyles(state.difficulty === difficulty, 'difficulty')}
|
||||
onClick={() => setDifficulty(difficulty)}
|
||||
onClick={() => handleSetDifficulty(difficulty)}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
@@ -414,7 +576,7 @@ export function SetupPhase() {
|
||||
<button
|
||||
key={timer}
|
||||
className={getButtonStyles(state.turnTimer === timer, 'secondary')}
|
||||
onClick={() => dispatch({ type: 'SET_TURN_TIMER', timer })}
|
||||
onClick={() => handleSetTurnTimer(timer)}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
@@ -464,7 +626,9 @@ export function SetupPhase() {
|
||||
>
|
||||
<button
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #ff6b6b 0%, #ee5a24 50%, #ff9ff3 100%)',
|
||||
background: canResumeGame
|
||||
? 'linear-gradient(135deg, #10b981 0%, #059669 50%, #34d399 100%)'
|
||||
: 'linear-gradient(135deg, #ff6b6b 0%, #ee5a24 50%, #ff9ff3 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: { base: '16px', sm: '20px', md: '24px' },
|
||||
@@ -473,7 +637,9 @@ export function SetupPhase() {
|
||||
fontWeight: 'black',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: '0 8px 20px rgba(255, 107, 107, 0.4), inset 0 2px 0 rgba(255,255,255,0.3)',
|
||||
boxShadow: canResumeGame
|
||||
? '0 8px 20px rgba(16, 185, 129, 0.4), inset 0 2px 0 rgba(255,255,255,0.3)'
|
||||
: '0 8px 20px rgba(255, 107, 107, 0.4), inset 0 2px 0 rgba(255,255,255,0.3)',
|
||||
textShadow: '0 2px 4px rgba(0,0,0,0.3)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
@@ -491,9 +657,12 @@ export function SetupPhase() {
|
||||
},
|
||||
_hover: {
|
||||
transform: { base: 'translateY(-2px)', md: 'translateY(-3px) scale(1.02)' },
|
||||
boxShadow:
|
||||
'0 12px 30px rgba(255, 107, 107, 0.6), inset 0 2px 0 rgba(255,255,255,0.3)',
|
||||
background: 'linear-gradient(135deg, #ff5252 0%, #dd2c00 50%, #e91e63 100%)',
|
||||
boxShadow: canResumeGame
|
||||
? '0 12px 30px rgba(16, 185, 129, 0.6), inset 0 2px 0 rgba(255,255,255,0.3)'
|
||||
: '0 12px 30px rgba(255, 107, 107, 0.6), inset 0 2px 0 rgba(255,255,255,0.3)',
|
||||
background: canResumeGame
|
||||
? 'linear-gradient(135deg, #059669 0%, #047857 50%, #10b981 100%)'
|
||||
: 'linear-gradient(135deg, #ff5252 0%, #dd2c00 50%, #e91e63 100%)',
|
||||
_before: {
|
||||
left: '100%',
|
||||
},
|
||||
@@ -502,7 +671,7 @@ export function SetupPhase() {
|
||||
transform: 'translateY(-1px) scale(1.01)',
|
||||
},
|
||||
})}
|
||||
onClick={handleStartGame}
|
||||
onClick={handleStartOrResumeGame}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
@@ -518,9 +687,9 @@ export function SetupPhase() {
|
||||
animation: 'bounce 2s infinite',
|
||||
})}
|
||||
>
|
||||
🚀
|
||||
{canResumeGame ? '▶️' : '🚀'}
|
||||
</span>
|
||||
<span>START GAME</span>
|
||||
<span>{canResumeGame ? 'RESUME GAME' : 'START GAME'}</span>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: { base: '18px', sm: '20px', md: '24px' },
|
||||
@@ -528,7 +697,7 @@ export function SetupPhase() {
|
||||
animationDelay: '0.5s',
|
||||
})}
|
||||
>
|
||||
🎮
|
||||
{canResumeGame ? '🎮' : '🎮'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo } from 'react'
|
||||
import { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import type { GameMove } from '@/lib/arcade/validation'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
@@ -104,6 +105,7 @@ const ArcadeMemoryPairsContext = createContext<MemoryPairsContextValue | null>(n
|
||||
// Provider component
|
||||
export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
const { activePlayerCount, activePlayers: activePlayerIds } = useGameMode()
|
||||
|
||||
// Get active player IDs directly as strings (UUIDs)
|
||||
@@ -112,7 +114,7 @@ export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
// Derive game mode from active player count
|
||||
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
|
||||
|
||||
// Arcade session integration
|
||||
// Arcade session integration with room-wide sync
|
||||
const {
|
||||
state,
|
||||
sendMove,
|
||||
@@ -120,6 +122,7 @@ export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
exitSession,
|
||||
} = useArcadeSession<MemoryPairsState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // Enable multi-user sync for room-based games
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
@@ -143,22 +146,77 @@ export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
// Computed values
|
||||
const isGameActive = state.gamePhase === 'playing'
|
||||
|
||||
const { players } = useGameMode()
|
||||
|
||||
const canFlipCard = useCallback(
|
||||
(cardId: string): boolean => {
|
||||
if (!isGameActive || state.isProcessingMove) return false
|
||||
console.log('[canFlipCard] Checking card:', {
|
||||
cardId,
|
||||
isGameActive,
|
||||
isProcessingMove: state.isProcessingMove,
|
||||
currentPlayer: state.currentPlayer,
|
||||
hasRoomData: !!roomData,
|
||||
flippedCardsCount: state.flippedCards.length,
|
||||
})
|
||||
|
||||
if (!isGameActive || state.isProcessingMove) {
|
||||
console.log('[canFlipCard] Blocked: game not active or processing')
|
||||
return false
|
||||
}
|
||||
|
||||
const card = state.gameCards.find((c) => c.id === cardId)
|
||||
if (!card || card.matched) return false
|
||||
if (!card || card.matched) {
|
||||
console.log('[canFlipCard] Blocked: card not found or already matched')
|
||||
return false
|
||||
}
|
||||
|
||||
// Can't flip if already flipped
|
||||
if (state.flippedCards.some((c) => c.id === cardId)) return false
|
||||
if (state.flippedCards.some((c) => c.id === cardId)) {
|
||||
console.log('[canFlipCard] Blocked: card already flipped')
|
||||
return false
|
||||
}
|
||||
|
||||
// Can't flip more than 2 cards
|
||||
if (state.flippedCards.length >= 2) return false
|
||||
if (state.flippedCards.length >= 2) {
|
||||
console.log('[canFlipCard] Blocked: 2 cards already flipped')
|
||||
return false
|
||||
}
|
||||
|
||||
// Authorization check: Only allow flipping if it's your player's turn
|
||||
if (roomData && state.currentPlayer) {
|
||||
const currentPlayerData = players.get(state.currentPlayer)
|
||||
console.log('[canFlipCard] Authorization check:', {
|
||||
currentPlayerId: state.currentPlayer,
|
||||
currentPlayerFound: !!currentPlayerData,
|
||||
currentPlayerIsLocal: currentPlayerData?.isLocal,
|
||||
})
|
||||
|
||||
// Block if current player is explicitly marked as remote (isLocal === false)
|
||||
if (currentPlayerData && currentPlayerData.isLocal === false) {
|
||||
console.log('[canFlipCard] BLOCKED: Current player is remote (not your turn)')
|
||||
return false
|
||||
}
|
||||
|
||||
// If player data not found in map, this might be an issue - allow for now but warn
|
||||
if (!currentPlayerData) {
|
||||
console.warn(
|
||||
'[canFlipCard] WARNING: Current player not found in players map, allowing move'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[canFlipCard] ALLOWED: All checks passed')
|
||||
return true
|
||||
},
|
||||
[isGameActive, state.isProcessingMove, state.gameCards, state.flippedCards]
|
||||
[
|
||||
isGameActive,
|
||||
state.isProcessingMove,
|
||||
state.gameCards,
|
||||
state.flippedCards,
|
||||
state.currentPlayer,
|
||||
roomData,
|
||||
players,
|
||||
]
|
||||
)
|
||||
|
||||
const currentGameStatistics: GameStatistics = useMemo(
|
||||
@@ -195,7 +253,7 @@ export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
activePlayers,
|
||||
},
|
||||
})
|
||||
}, [state.gameType, state.difficulty, activePlayers, sendMove])
|
||||
}, [state.gameType, state.difficulty, activePlayers, sendMove, roomData])
|
||||
|
||||
const flipCard = useCallback(
|
||||
(cardId: string) => {
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useReducer } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { useUserPlayers } from '@/hooks/useUserPlayers'
|
||||
import { generateGameCards } from '../utils/cardGeneration'
|
||||
import { validateMatch } from '../utils/matchValidation'
|
||||
import { MemoryPairsContext } from './MemoryPairsContext'
|
||||
import type { GameMode, GameStatistics, MemoryPairsContextValue, MemoryPairsState } from './types'
|
||||
|
||||
// Initial state for local-only games
|
||||
const initialState: MemoryPairsState = {
|
||||
cards: [],
|
||||
gameCards: [],
|
||||
flippedCards: [],
|
||||
gameType: 'abacus-numeral',
|
||||
difficulty: 6,
|
||||
turnTimer: 30,
|
||||
gamePhase: 'setup',
|
||||
currentPlayer: '',
|
||||
matchedPairs: 0,
|
||||
totalPairs: 6,
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
consecutiveMatches: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: null,
|
||||
timerInterval: null,
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
originalConfig: undefined,
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
playerHovers: {},
|
||||
}
|
||||
|
||||
// Action types for local reducer
|
||||
type LocalAction =
|
||||
| { type: 'START_GAME'; cards: any[]; activePlayers: string[]; playerMetadata: any }
|
||||
| { type: 'FLIP_CARD'; cardId: string }
|
||||
| { type: 'MATCH_FOUND'; cardIds: [string, string]; playerId: string }
|
||||
| { type: 'MATCH_FAILED'; cardIds: [string, string] }
|
||||
| { type: 'CLEAR_MISMATCH' }
|
||||
| { type: 'SWITCH_PLAYER' }
|
||||
| { type: 'GO_TO_SETUP' }
|
||||
| { type: 'SET_CONFIG'; field: string; value: any }
|
||||
| { type: 'RESUME_GAME' }
|
||||
| { type: 'HOVER_CARD'; playerId: string; cardId: string | null }
|
||||
| { type: 'END_GAME' }
|
||||
|
||||
// Pure client-side reducer with complete game logic
|
||||
function localMemoryPairsReducer(state: MemoryPairsState, action: LocalAction): MemoryPairsState {
|
||||
switch (action.type) {
|
||||
case 'START_GAME':
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
gameCards: action.cards,
|
||||
cards: action.cards,
|
||||
flippedCards: [],
|
||||
matchedPairs: 0,
|
||||
moves: 0,
|
||||
scores: action.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
|
||||
consecutiveMatches: action.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
|
||||
activePlayers: action.activePlayers,
|
||||
playerMetadata: action.playerMetadata,
|
||||
currentPlayer: action.activePlayers[0] || '',
|
||||
gameStartTime: Date.now(),
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: Date.now(),
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
originalConfig: {
|
||||
gameType: state.gameType,
|
||||
difficulty: state.difficulty,
|
||||
turnTimer: state.turnTimer,
|
||||
},
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
}
|
||||
|
||||
case 'FLIP_CARD': {
|
||||
const card = state.gameCards.find((c) => c.id === action.cardId)
|
||||
if (!card) return state
|
||||
|
||||
const newFlippedCards = [...state.flippedCards, card]
|
||||
|
||||
return {
|
||||
...state,
|
||||
flippedCards: newFlippedCards,
|
||||
currentMoveStartTime: state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
|
||||
isProcessingMove: newFlippedCards.length === 2,
|
||||
showMismatchFeedback: false,
|
||||
}
|
||||
}
|
||||
|
||||
case 'MATCH_FOUND': {
|
||||
const [id1, id2] = action.cardIds
|
||||
const updatedCards = state.gameCards.map((card) =>
|
||||
card.id === id1 || card.id === id2
|
||||
? { ...card, matched: true, matchedBy: action.playerId }
|
||||
: card
|
||||
)
|
||||
|
||||
const newMatchedPairs = state.matchedPairs + 1
|
||||
const newScores = {
|
||||
...state.scores,
|
||||
[action.playerId]: (state.scores[action.playerId] || 0) + 1,
|
||||
}
|
||||
const newConsecutiveMatches = {
|
||||
...state.consecutiveMatches,
|
||||
[action.playerId]: (state.consecutiveMatches[action.playerId] || 0) + 1,
|
||||
}
|
||||
|
||||
// Check if game is complete
|
||||
const gameComplete = newMatchedPairs >= state.totalPairs
|
||||
|
||||
return {
|
||||
...state,
|
||||
gameCards: updatedCards,
|
||||
cards: updatedCards,
|
||||
flippedCards: [],
|
||||
matchedPairs: newMatchedPairs,
|
||||
moves: state.moves + 1,
|
||||
scores: newScores,
|
||||
consecutiveMatches: newConsecutiveMatches,
|
||||
lastMatchedPair: action.cardIds,
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
gamePhase: gameComplete ? 'results' : state.gamePhase,
|
||||
gameEndTime: gameComplete ? Date.now() : null,
|
||||
// Player keeps their turn on match
|
||||
}
|
||||
}
|
||||
|
||||
case 'MATCH_FAILED': {
|
||||
// Reset consecutive matches for current player
|
||||
const newConsecutiveMatches = {
|
||||
...state.consecutiveMatches,
|
||||
[state.currentPlayer]: 0,
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
moves: state.moves + 1,
|
||||
showMismatchFeedback: true,
|
||||
isProcessingMove: true,
|
||||
consecutiveMatches: newConsecutiveMatches,
|
||||
// Don't clear flipped cards yet - CLEAR_MISMATCH will do that
|
||||
}
|
||||
}
|
||||
|
||||
case 'CLEAR_MISMATCH': {
|
||||
return {
|
||||
...state,
|
||||
flippedCards: [],
|
||||
showMismatchFeedback: false,
|
||||
isProcessingMove: false,
|
||||
}
|
||||
}
|
||||
|
||||
case 'SWITCH_PLAYER': {
|
||||
const currentIndex = state.activePlayers.indexOf(state.currentPlayer)
|
||||
const nextIndex = (currentIndex + 1) % state.activePlayers.length
|
||||
const nextPlayer = state.activePlayers[nextIndex]
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentPlayer: nextPlayer,
|
||||
currentMoveStartTime: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
case 'GO_TO_SETUP': {
|
||||
const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results'
|
||||
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'setup',
|
||||
pausedGamePhase: isPausingGame ? state.gamePhase : undefined,
|
||||
pausedGameState: isPausingGame
|
||||
? {
|
||||
gameCards: state.gameCards,
|
||||
currentPlayer: state.currentPlayer,
|
||||
matchedPairs: state.matchedPairs,
|
||||
moves: state.moves,
|
||||
scores: state.scores,
|
||||
activePlayers: state.activePlayers,
|
||||
playerMetadata: state.playerMetadata || {},
|
||||
consecutiveMatches: state.consecutiveMatches,
|
||||
gameStartTime: state.gameStartTime,
|
||||
}
|
||||
: undefined,
|
||||
gameCards: [],
|
||||
cards: [],
|
||||
flippedCards: [],
|
||||
currentPlayer: '',
|
||||
matchedPairs: 0,
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
consecutiveMatches: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: null,
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
}
|
||||
}
|
||||
|
||||
case 'SET_CONFIG': {
|
||||
const clearPausedGame = !!state.pausedGamePhase
|
||||
|
||||
return {
|
||||
...state,
|
||||
[action.field]: action.value,
|
||||
...(action.field === 'difficulty' ? { totalPairs: action.value } : {}),
|
||||
...(clearPausedGame
|
||||
? { pausedGamePhase: undefined, pausedGameState: undefined, originalConfig: undefined }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
case 'RESUME_GAME': {
|
||||
if (!state.pausedGamePhase || !state.pausedGameState) {
|
||||
return state
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
gamePhase: state.pausedGamePhase,
|
||||
gameCards: state.pausedGameState.gameCards,
|
||||
cards: state.pausedGameState.gameCards,
|
||||
currentPlayer: state.pausedGameState.currentPlayer,
|
||||
matchedPairs: state.pausedGameState.matchedPairs,
|
||||
moves: state.pausedGameState.moves,
|
||||
scores: state.pausedGameState.scores,
|
||||
activePlayers: state.pausedGameState.activePlayers,
|
||||
playerMetadata: state.pausedGameState.playerMetadata,
|
||||
consecutiveMatches: state.pausedGameState.consecutiveMatches,
|
||||
gameStartTime: state.pausedGameState.gameStartTime,
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
case 'HOVER_CARD': {
|
||||
return {
|
||||
...state,
|
||||
playerHovers: {
|
||||
...state.playerHovers,
|
||||
[action.playerId]: action.cardId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'END_GAME': {
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'results',
|
||||
gameEndTime: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// Provider component for LOCAL-ONLY play (no network, no arcade session)
|
||||
export function LocalMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const router = useRouter()
|
||||
const { data: viewerId } = useViewerId()
|
||||
|
||||
// LOCAL-ONLY: Get only the current user's players (no room members)
|
||||
const { data: userPlayers = [] } = useUserPlayers()
|
||||
|
||||
// Use arcade redirect to determine button visibility
|
||||
const { canModifyPlayers } = useArcadeRedirect({ currentGame: 'matching' })
|
||||
|
||||
// Build players map from current user's players only
|
||||
const players = useMemo(() => {
|
||||
const map = new Map()
|
||||
userPlayers.forEach((player) => {
|
||||
map.set(player.id, {
|
||||
id: player.id,
|
||||
name: player.name,
|
||||
emoji: player.emoji,
|
||||
color: player.color,
|
||||
isLocal: true,
|
||||
})
|
||||
})
|
||||
return map
|
||||
}, [userPlayers])
|
||||
|
||||
// Get active player IDs from current user's players only
|
||||
const activePlayers = useMemo(() => {
|
||||
return userPlayers.filter((p) => p.isActive).map((p) => p.id)
|
||||
}, [userPlayers])
|
||||
|
||||
// Derive game mode from active player count
|
||||
const gameMode = activePlayers.length > 1 ? 'multiplayer' : 'single'
|
||||
|
||||
// Pure client-side state with useReducer
|
||||
const [state, dispatch] = useReducer(localMemoryPairsReducer, initialState)
|
||||
|
||||
// Handle mismatch feedback timeout and player switching
|
||||
useEffect(() => {
|
||||
if (state.showMismatchFeedback && state.flippedCards.length === 2) {
|
||||
const timeout = setTimeout(() => {
|
||||
dispatch({ type: 'CLEAR_MISMATCH' })
|
||||
// Switch to next player after mismatch
|
||||
dispatch({ type: 'SWITCH_PLAYER' })
|
||||
}, 1500)
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [state.showMismatchFeedback, state.flippedCards.length])
|
||||
|
||||
// Handle automatic match checking when 2 cards flipped
|
||||
useEffect(() => {
|
||||
if (state.flippedCards.length === 2 && !state.showMismatchFeedback) {
|
||||
const [card1, card2] = state.flippedCards
|
||||
const isMatch = validateMatch(card1, card2)
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (isMatch.isValid) {
|
||||
dispatch({
|
||||
type: 'MATCH_FOUND',
|
||||
cardIds: [card1.id, card2.id],
|
||||
playerId: state.currentPlayer,
|
||||
})
|
||||
// Player keeps turn on match - no SWITCH_PLAYER
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'MATCH_FAILED',
|
||||
cardIds: [card1.id, card2.id],
|
||||
})
|
||||
// SWITCH_PLAYER will happen after CLEAR_MISMATCH timeout
|
||||
}
|
||||
}, 600) // Small delay to show both cards
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [state.flippedCards, state.showMismatchFeedback, state.currentPlayer])
|
||||
|
||||
// Computed values
|
||||
const isGameActive = state.gamePhase === 'playing'
|
||||
|
||||
const canFlipCard = useCallback(
|
||||
(cardId: string): boolean => {
|
||||
if (!isGameActive || state.isProcessingMove) {
|
||||
return false
|
||||
}
|
||||
|
||||
const card = state.gameCards.find((c) => c.id === cardId)
|
||||
if (!card || card.matched) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (state.flippedCards.some((c) => c.id === cardId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (state.flippedCards.length >= 2) {
|
||||
return false
|
||||
}
|
||||
|
||||
// In local play, all local players can flip during their turn
|
||||
const currentPlayerData = players.get(state.currentPlayer)
|
||||
if (currentPlayerData && currentPlayerData.isLocal === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
[isGameActive, state.isProcessingMove, state.gameCards, state.flippedCards, state.currentPlayer, players]
|
||||
)
|
||||
|
||||
const currentGameStatistics: GameStatistics = useMemo(
|
||||
() => ({
|
||||
totalMoves: state.moves,
|
||||
matchedPairs: state.matchedPairs,
|
||||
totalPairs: state.totalPairs,
|
||||
gameTime: state.gameStartTime ? (state.gameEndTime || Date.now()) - state.gameStartTime : 0,
|
||||
accuracy: state.moves > 0 ? (state.matchedPairs / state.moves) * 100 : 0,
|
||||
averageTimePerMove:
|
||||
state.moves > 0 && state.gameStartTime
|
||||
? ((state.gameEndTime || Date.now()) - state.gameStartTime) / state.moves
|
||||
: 0,
|
||||
}),
|
||||
[state.moves, state.matchedPairs, state.totalPairs, state.gameStartTime, state.gameEndTime]
|
||||
)
|
||||
|
||||
const hasConfigChanged = useMemo(() => {
|
||||
if (!state.originalConfig) return false
|
||||
return (
|
||||
state.gameType !== state.originalConfig.gameType ||
|
||||
state.difficulty !== state.originalConfig.difficulty ||
|
||||
state.turnTimer !== state.originalConfig.turnTimer
|
||||
)
|
||||
}, [state.gameType, state.difficulty, state.turnTimer, state.originalConfig])
|
||||
|
||||
const canResumeGame = useMemo(() => {
|
||||
return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
|
||||
}, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged])
|
||||
|
||||
// Action creators
|
||||
const startGame = useCallback(() => {
|
||||
if (activePlayers.length === 0) {
|
||||
console.error('[LocalMemoryPairs] Cannot start game without active players')
|
||||
return
|
||||
}
|
||||
|
||||
const playerMetadata: { [playerId: string]: any } = {}
|
||||
for (const playerId of activePlayers) {
|
||||
const playerData = players.get(playerId)
|
||||
if (playerData) {
|
||||
playerMetadata[playerId] = {
|
||||
id: playerId,
|
||||
name: playerData.name,
|
||||
emoji: playerData.emoji,
|
||||
userId: viewerId || '',
|
||||
color: playerData.color,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cards = generateGameCards(state.gameType, state.difficulty)
|
||||
dispatch({
|
||||
type: 'START_GAME',
|
||||
cards,
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
})
|
||||
}, [state.gameType, state.difficulty, activePlayers, players, viewerId])
|
||||
|
||||
const flipCard = useCallback(
|
||||
(cardId: string) => {
|
||||
if (!canFlipCard(cardId)) {
|
||||
return
|
||||
}
|
||||
dispatch({ type: 'FLIP_CARD', cardId })
|
||||
},
|
||||
[canFlipCard]
|
||||
)
|
||||
|
||||
const resetGame = useCallback(() => {
|
||||
if (activePlayers.length === 0) {
|
||||
console.error('[LocalMemoryPairs] Cannot reset game without active players')
|
||||
return
|
||||
}
|
||||
|
||||
const playerMetadata: { [playerId: string]: any } = {}
|
||||
for (const playerId of activePlayers) {
|
||||
const playerData = players.get(playerId)
|
||||
if (playerData) {
|
||||
playerMetadata[playerId] = {
|
||||
id: playerId,
|
||||
name: playerData.name,
|
||||
emoji: playerData.emoji,
|
||||
userId: viewerId || '',
|
||||
color: playerData.color,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cards = generateGameCards(state.gameType, state.difficulty)
|
||||
dispatch({
|
||||
type: 'START_GAME',
|
||||
cards,
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
})
|
||||
}, [state.gameType, state.difficulty, activePlayers, players, viewerId])
|
||||
|
||||
const setGameType = useCallback((gameType: typeof state.gameType) => {
|
||||
dispatch({ type: 'SET_CONFIG', field: 'gameType', value: gameType })
|
||||
}, [])
|
||||
|
||||
const setDifficulty = useCallback((difficulty: typeof state.difficulty) => {
|
||||
dispatch({ type: 'SET_CONFIG', field: 'difficulty', value: difficulty })
|
||||
}, [])
|
||||
|
||||
const setTurnTimer = useCallback((turnTimer: typeof state.turnTimer) => {
|
||||
dispatch({ type: 'SET_CONFIG', field: 'turnTimer', value: turnTimer })
|
||||
}, [])
|
||||
|
||||
const resumeGame = useCallback(() => {
|
||||
if (!canResumeGame) {
|
||||
console.warn('[LocalMemoryPairs] Cannot resume - no paused game or config changed')
|
||||
return
|
||||
}
|
||||
dispatch({ type: 'RESUME_GAME' })
|
||||
}, [canResumeGame])
|
||||
|
||||
const goToSetup = useCallback(() => {
|
||||
dispatch({ type: 'GO_TO_SETUP' })
|
||||
}, [])
|
||||
|
||||
const hoverCard = useCallback(
|
||||
(cardId: string | null) => {
|
||||
const playerId = state.currentPlayer || activePlayers[0] || ''
|
||||
if (!playerId) return
|
||||
|
||||
dispatch({
|
||||
type: 'HOVER_CARD',
|
||||
playerId,
|
||||
cardId,
|
||||
})
|
||||
},
|
||||
[state.currentPlayer, activePlayers]
|
||||
)
|
||||
|
||||
const exitSession = useCallback(() => {
|
||||
router.push('/arcade')
|
||||
}, [router])
|
||||
|
||||
const effectiveState = { ...state, gameMode } as MemoryPairsState & { gameMode: GameMode }
|
||||
|
||||
const contextValue: MemoryPairsContextValue = {
|
||||
state: effectiveState,
|
||||
dispatch: () => {
|
||||
// No-op - local provider uses action creators instead
|
||||
console.warn('dispatch() is not available in local mode, use action creators instead')
|
||||
},
|
||||
isGameActive,
|
||||
canFlipCard,
|
||||
currentGameStatistics,
|
||||
hasConfigChanged,
|
||||
canResumeGame,
|
||||
canModifyPlayers,
|
||||
startGame,
|
||||
resumeGame,
|
||||
flipCard,
|
||||
resetGame,
|
||||
goToSetup,
|
||||
setGameType,
|
||||
setDifficulty,
|
||||
setTurnTimer,
|
||||
hoverCard,
|
||||
exitSession,
|
||||
gameMode,
|
||||
activePlayers,
|
||||
}
|
||||
|
||||
return <MemoryPairsContext.Provider value={contextValue}>{children}</MemoryPairsContext.Provider>
|
||||
}
|
||||
@@ -251,7 +251,7 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
|
||||
}
|
||||
|
||||
// Create context
|
||||
const MemoryPairsContext = createContext<MemoryPairsContextValue | null>(null)
|
||||
export const MemoryPairsContext = createContext<MemoryPairsContextValue | null>(null)
|
||||
|
||||
// Provider component
|
||||
export function MemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
|
||||
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
|
||||
import { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import type { GameMove } from '@/lib/arcade/validation'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { generateGameCards } from '../utils/cardGeneration'
|
||||
import { MemoryPairsContext } from './MemoryPairsContext'
|
||||
import type { GameMode, GameStatistics, MemoryPairsContextValue, MemoryPairsState } from './types'
|
||||
|
||||
// Initial state
|
||||
const initialState: MemoryPairsState = {
|
||||
cards: [],
|
||||
gameCards: [],
|
||||
flippedCards: [],
|
||||
gameType: 'abacus-numeral',
|
||||
difficulty: 6,
|
||||
turnTimer: 30,
|
||||
gamePhase: 'setup',
|
||||
currentPlayer: '', // Will be set to first player ID on START_GAME
|
||||
matchedPairs: 0,
|
||||
totalPairs: 6,
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
playerMetadata: {}, // Player metadata for cross-user visibility
|
||||
consecutiveMatches: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: null,
|
||||
timerInterval: null,
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
// PAUSE/RESUME: Initialize paused game fields
|
||||
originalConfig: undefined,
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
// HOVER: Initialize hover state
|
||||
playerHovers: {},
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic move application (client-side prediction)
|
||||
* The server will validate and send back the authoritative state
|
||||
*/
|
||||
function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): MemoryPairsState {
|
||||
switch (move.type) {
|
||||
case 'START_GAME':
|
||||
// Generate cards and initialize game
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
gameCards: move.data.cards,
|
||||
cards: move.data.cards,
|
||||
flippedCards: [],
|
||||
matchedPairs: 0,
|
||||
moves: 0,
|
||||
scores: move.data.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
|
||||
consecutiveMatches: move.data.activePlayers.reduce(
|
||||
(acc: any, p: string) => ({ ...acc, [p]: 0 }),
|
||||
{}
|
||||
),
|
||||
activePlayers: move.data.activePlayers,
|
||||
playerMetadata: move.data.playerMetadata || {}, // Include player metadata
|
||||
currentPlayer: move.data.activePlayers[0] || '',
|
||||
gameStartTime: Date.now(),
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: Date.now(),
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
// PAUSE/RESUME: Save original config and clear paused state
|
||||
originalConfig: {
|
||||
gameType: state.gameType,
|
||||
difficulty: state.difficulty,
|
||||
turnTimer: state.turnTimer,
|
||||
},
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
}
|
||||
|
||||
case 'FLIP_CARD': {
|
||||
// Optimistically flip the card
|
||||
const card = state.gameCards.find((c) => c.id === move.data.cardId)
|
||||
if (!card) return state
|
||||
|
||||
const newFlippedCards = [...state.flippedCards, card]
|
||||
|
||||
return {
|
||||
...state,
|
||||
flippedCards: newFlippedCards,
|
||||
currentMoveStartTime:
|
||||
state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
|
||||
isProcessingMove: newFlippedCards.length === 2, // Processing if 2 cards flipped
|
||||
showMismatchFeedback: false,
|
||||
}
|
||||
}
|
||||
|
||||
case 'CLEAR_MISMATCH': {
|
||||
// Clear mismatched cards and feedback
|
||||
return {
|
||||
...state,
|
||||
flippedCards: [],
|
||||
showMismatchFeedback: false,
|
||||
isProcessingMove: false,
|
||||
}
|
||||
}
|
||||
|
||||
case 'GO_TO_SETUP': {
|
||||
// Return to setup phase - pause game if coming from playing/results
|
||||
const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results'
|
||||
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'setup',
|
||||
// PAUSE: Save game state if pausing from active game
|
||||
pausedGamePhase: isPausingGame ? state.gamePhase : undefined,
|
||||
pausedGameState: isPausingGame
|
||||
? {
|
||||
gameCards: state.gameCards,
|
||||
currentPlayer: state.currentPlayer,
|
||||
matchedPairs: state.matchedPairs,
|
||||
moves: state.moves,
|
||||
scores: state.scores,
|
||||
activePlayers: state.activePlayers,
|
||||
playerMetadata: state.playerMetadata,
|
||||
consecutiveMatches: state.consecutiveMatches,
|
||||
gameStartTime: state.gameStartTime,
|
||||
}
|
||||
: undefined,
|
||||
// Reset visible game state
|
||||
gameCards: [],
|
||||
cards: [],
|
||||
flippedCards: [],
|
||||
currentPlayer: '',
|
||||
matchedPairs: 0,
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
consecutiveMatches: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: null,
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
}
|
||||
}
|
||||
|
||||
case 'SET_CONFIG': {
|
||||
// Update configuration field optimistically
|
||||
const { field, value } = move.data as { field: string; value: any }
|
||||
const clearPausedGame = !!state.pausedGamePhase
|
||||
|
||||
return {
|
||||
...state,
|
||||
[field]: value,
|
||||
// Update totalPairs if difficulty changes
|
||||
...(field === 'difficulty' ? { totalPairs: value } : {}),
|
||||
// Clear paused game if config changed
|
||||
...(clearPausedGame
|
||||
? { pausedGamePhase: undefined, pausedGameState: undefined, originalConfig: undefined }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
case 'RESUME_GAME': {
|
||||
// Resume paused game
|
||||
if (!state.pausedGamePhase || !state.pausedGameState) {
|
||||
return state // No paused game, no-op
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
gamePhase: state.pausedGamePhase,
|
||||
gameCards: state.pausedGameState.gameCards,
|
||||
cards: state.pausedGameState.gameCards,
|
||||
currentPlayer: state.pausedGameState.currentPlayer,
|
||||
matchedPairs: state.pausedGameState.matchedPairs,
|
||||
moves: state.pausedGameState.moves,
|
||||
scores: state.pausedGameState.scores,
|
||||
activePlayers: state.pausedGameState.activePlayers,
|
||||
playerMetadata: state.pausedGameState.playerMetadata,
|
||||
consecutiveMatches: state.pausedGameState.consecutiveMatches,
|
||||
gameStartTime: state.pausedGameState.gameStartTime,
|
||||
// Clear paused state
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
case 'HOVER_CARD': {
|
||||
// Update player hover state for networked presence
|
||||
return {
|
||||
...state,
|
||||
playerHovers: {
|
||||
...state.playerHovers,
|
||||
[move.playerId]: move.data.cardId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// Provider component for ROOM-BASED play (with network sync)
|
||||
// NOTE: This provider should ONLY be used for room-based multiplayer games.
|
||||
// For arcade sessions without rooms, use LocalMemoryPairsProvider instead.
|
||||
export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData() // Fetch room data for room-based play
|
||||
const { activePlayerCount, activePlayers: activePlayerIds, players } = useGameMode()
|
||||
|
||||
// Get active player IDs directly as strings (UUIDs)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
// Derive game mode from active player count
|
||||
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
|
||||
|
||||
// NO LOCAL STATE - Configuration lives in session state
|
||||
// Changes are sent as moves and synchronized across all room members
|
||||
|
||||
// Arcade session integration WITH room sync
|
||||
const {
|
||||
state,
|
||||
sendMove,
|
||||
connected: _connected,
|
||||
exitSession,
|
||||
} = useArcadeSession<MemoryPairsState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // CRITICAL: Pass roomId for network sync across room members
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// Handle mismatch feedback timeout
|
||||
useEffect(() => {
|
||||
if (state.showMismatchFeedback && state.flippedCards.length === 2) {
|
||||
// After 1.5 seconds, send CLEAR_MISMATCH
|
||||
// Server will validate that cards are still in mismatch state before clearing
|
||||
const timeout = setTimeout(() => {
|
||||
sendMove({
|
||||
type: 'CLEAR_MISMATCH',
|
||||
playerId: state.currentPlayer,
|
||||
data: {},
|
||||
})
|
||||
}, 1500)
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [state.showMismatchFeedback, state.flippedCards.length, sendMove, state.currentPlayer])
|
||||
|
||||
// Computed values
|
||||
const isGameActive = state.gamePhase === 'playing'
|
||||
|
||||
const canFlipCard = useCallback(
|
||||
(cardId: string): boolean => {
|
||||
console.log('[RoomProvider][canFlipCard] Checking card:', {
|
||||
cardId,
|
||||
isGameActive,
|
||||
isProcessingMove: state.isProcessingMove,
|
||||
currentPlayer: state.currentPlayer,
|
||||
hasRoomData: !!roomData,
|
||||
flippedCardsCount: state.flippedCards.length,
|
||||
})
|
||||
|
||||
if (!isGameActive || state.isProcessingMove) {
|
||||
console.log('[RoomProvider][canFlipCard] Blocked: game not active or processing')
|
||||
return false
|
||||
}
|
||||
|
||||
const card = state.gameCards.find((c) => c.id === cardId)
|
||||
if (!card || card.matched) {
|
||||
console.log('[RoomProvider][canFlipCard] Blocked: card not found or already matched')
|
||||
return false
|
||||
}
|
||||
|
||||
// Can't flip if already flipped
|
||||
if (state.flippedCards.some((c) => c.id === cardId)) {
|
||||
console.log('[RoomProvider][canFlipCard] Blocked: card already flipped')
|
||||
return false
|
||||
}
|
||||
|
||||
// Can't flip more than 2 cards
|
||||
if (state.flippedCards.length >= 2) {
|
||||
console.log('[RoomProvider][canFlipCard] Blocked: 2 cards already flipped')
|
||||
return false
|
||||
}
|
||||
|
||||
// Authorization check: Only allow flipping if it's your player's turn
|
||||
if (roomData && state.currentPlayer) {
|
||||
const currentPlayerData = players.get(state.currentPlayer)
|
||||
console.log('[RoomProvider][canFlipCard] Authorization check:', {
|
||||
currentPlayerId: state.currentPlayer,
|
||||
currentPlayerFound: !!currentPlayerData,
|
||||
currentPlayerIsLocal: currentPlayerData?.isLocal,
|
||||
})
|
||||
|
||||
// Block if current player is explicitly marked as remote (isLocal === false)
|
||||
if (currentPlayerData && currentPlayerData.isLocal === false) {
|
||||
console.log(
|
||||
'[RoomProvider][canFlipCard] BLOCKED: Current player is remote (not your turn)'
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// If player data not found in map, this might be an issue - allow for now but warn
|
||||
if (!currentPlayerData) {
|
||||
console.warn(
|
||||
'[RoomProvider][canFlipCard] WARNING: Current player not found in players map, allowing move'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[RoomProvider][canFlipCard] ALLOWED: All checks passed')
|
||||
return true
|
||||
},
|
||||
[
|
||||
isGameActive,
|
||||
state.isProcessingMove,
|
||||
state.gameCards,
|
||||
state.flippedCards,
|
||||
state.currentPlayer,
|
||||
roomData,
|
||||
players,
|
||||
]
|
||||
)
|
||||
|
||||
const currentGameStatistics: GameStatistics = useMemo(
|
||||
() => ({
|
||||
totalMoves: state.moves,
|
||||
matchedPairs: state.matchedPairs,
|
||||
totalPairs: state.totalPairs,
|
||||
gameTime: state.gameStartTime ? (state.gameEndTime || Date.now()) - state.gameStartTime : 0,
|
||||
accuracy: state.moves > 0 ? (state.matchedPairs / state.moves) * 100 : 0,
|
||||
averageTimePerMove:
|
||||
state.moves > 0 && state.gameStartTime
|
||||
? ((state.gameEndTime || Date.now()) - state.gameStartTime) / state.moves
|
||||
: 0,
|
||||
}),
|
||||
[state.moves, state.matchedPairs, state.totalPairs, state.gameStartTime, state.gameEndTime]
|
||||
)
|
||||
|
||||
// PAUSE/RESUME: Computed values for pause/resume functionality
|
||||
const hasConfigChanged = useMemo(() => {
|
||||
if (!state.originalConfig) return false
|
||||
return (
|
||||
state.gameType !== state.originalConfig.gameType ||
|
||||
state.difficulty !== state.originalConfig.difficulty ||
|
||||
state.turnTimer !== state.originalConfig.turnTimer
|
||||
)
|
||||
}, [state.gameType, state.difficulty, state.turnTimer, state.originalConfig])
|
||||
|
||||
const canResumeGame = useMemo(() => {
|
||||
return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
|
||||
}, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged])
|
||||
|
||||
// Helper to build player metadata with correct userId ownership
|
||||
// This uses roomData.memberPlayers to determine which user owns which player
|
||||
const buildPlayerMetadata = useCallback(
|
||||
(playerIds: string[]) => {
|
||||
const playerMetadata: { [playerId: string]: any } = {}
|
||||
|
||||
// Build reverse mapping: playerId -> userId from roomData.memberPlayers
|
||||
const playerOwnership = new Map<string, string>()
|
||||
if (roomData?.memberPlayers) {
|
||||
for (const [userId, userPlayers] of Object.entries(roomData.memberPlayers)) {
|
||||
for (const player of userPlayers) {
|
||||
playerOwnership.set(player.id, userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const playerId of playerIds) {
|
||||
const playerData = players.get(playerId)
|
||||
if (playerData) {
|
||||
// Get the actual owner userId from roomData, or use local viewerId as fallback
|
||||
const ownerUserId = playerOwnership.get(playerId) || viewerId || ''
|
||||
|
||||
playerMetadata[playerId] = {
|
||||
id: playerId,
|
||||
name: playerData.name,
|
||||
emoji: playerData.emoji,
|
||||
userId: ownerUserId, // CORRECT: Use actual owner's userId
|
||||
color: playerData.color,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return playerMetadata
|
||||
},
|
||||
[players, roomData, viewerId]
|
||||
)
|
||||
|
||||
// Action creators - send moves to arcade session
|
||||
const startGame = useCallback(() => {
|
||||
// Must have at least one active player
|
||||
if (activePlayers.length === 0) {
|
||||
console.error('[RoomMemoryPairs] Cannot start game without active players')
|
||||
return
|
||||
}
|
||||
|
||||
// Capture player metadata from local players map
|
||||
// This ensures all room members can display player info even if they don't own the players
|
||||
const playerMetadata = buildPlayerMetadata(activePlayers)
|
||||
|
||||
// Use current session state configuration (no local state!)
|
||||
const cards = generateGameCards(state.gameType, state.difficulty)
|
||||
// Use first active player as playerId for START_GAME move
|
||||
const firstPlayer = activePlayers[0]
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: firstPlayer,
|
||||
data: {
|
||||
cards,
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
},
|
||||
})
|
||||
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove])
|
||||
|
||||
const flipCard = useCallback(
|
||||
(cardId: string) => {
|
||||
console.log('[RoomProvider] flipCard called:', {
|
||||
cardId,
|
||||
viewerId,
|
||||
currentPlayer: state.currentPlayer,
|
||||
activePlayers: state.activePlayers,
|
||||
gamePhase: state.gamePhase,
|
||||
canFlip: canFlipCard(cardId),
|
||||
})
|
||||
|
||||
if (!canFlipCard(cardId)) {
|
||||
console.log('[RoomProvider] Cannot flip card - canFlipCard returned false')
|
||||
return
|
||||
}
|
||||
|
||||
const move = {
|
||||
type: 'FLIP_CARD' as const,
|
||||
playerId: state.currentPlayer, // Use the current player ID from game state (database player ID)
|
||||
data: { cardId },
|
||||
}
|
||||
console.log('[RoomProvider] Sending FLIP_CARD move via sendMove:', move)
|
||||
sendMove(move)
|
||||
},
|
||||
[canFlipCard, sendMove, viewerId, state.currentPlayer, state.activePlayers, state.gamePhase]
|
||||
)
|
||||
|
||||
const resetGame = useCallback(() => {
|
||||
// Must have at least one active player
|
||||
if (activePlayers.length === 0) {
|
||||
console.error('[RoomMemoryPairs] Cannot reset game without active players')
|
||||
return
|
||||
}
|
||||
|
||||
// Capture player metadata with correct userId ownership
|
||||
const playerMetadata = buildPlayerMetadata(activePlayers)
|
||||
|
||||
// Use current session state configuration (no local state!)
|
||||
const cards = generateGameCards(state.gameType, state.difficulty)
|
||||
// Use first active player as playerId for START_GAME move
|
||||
const firstPlayer = activePlayers[0]
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: firstPlayer,
|
||||
data: {
|
||||
cards,
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
},
|
||||
})
|
||||
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove])
|
||||
|
||||
const setGameType = useCallback(
|
||||
(gameType: typeof state.gameType) => {
|
||||
// Use first active player as playerId, or empty string if none
|
||||
const playerId = activePlayers[0] || ''
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId,
|
||||
data: { field: 'gameType', value: gameType },
|
||||
})
|
||||
},
|
||||
[activePlayers, sendMove]
|
||||
)
|
||||
|
||||
const setDifficulty = useCallback(
|
||||
(difficulty: typeof state.difficulty) => {
|
||||
const playerId = activePlayers[0] || ''
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId,
|
||||
data: { field: 'difficulty', value: difficulty },
|
||||
})
|
||||
},
|
||||
[activePlayers, sendMove]
|
||||
)
|
||||
|
||||
const setTurnTimer = useCallback(
|
||||
(turnTimer: typeof state.turnTimer) => {
|
||||
const playerId = activePlayers[0] || ''
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId,
|
||||
data: { field: 'turnTimer', value: turnTimer },
|
||||
})
|
||||
},
|
||||
[activePlayers, sendMove]
|
||||
)
|
||||
|
||||
const goToSetup = useCallback(() => {
|
||||
// Send GO_TO_SETUP move - synchronized across all room members
|
||||
const playerId = activePlayers[0] || state.currentPlayer || ''
|
||||
sendMove({
|
||||
type: 'GO_TO_SETUP',
|
||||
playerId,
|
||||
data: {},
|
||||
})
|
||||
}, [activePlayers, state.currentPlayer, sendMove])
|
||||
|
||||
const resumeGame = useCallback(() => {
|
||||
// PAUSE/RESUME: Resume paused game if config unchanged
|
||||
if (!canResumeGame) {
|
||||
console.warn('[RoomMemoryPairs] Cannot resume - no paused game or config changed')
|
||||
return
|
||||
}
|
||||
|
||||
const playerId = activePlayers[0] || state.currentPlayer || ''
|
||||
sendMove({
|
||||
type: 'RESUME_GAME',
|
||||
playerId,
|
||||
data: {},
|
||||
})
|
||||
}, [canResumeGame, activePlayers, state.currentPlayer, sendMove])
|
||||
|
||||
const hoverCard = useCallback(
|
||||
(cardId: string | null) => {
|
||||
// HOVER: Send hover state for networked presence
|
||||
// Use current player as the one hovering
|
||||
const playerId = state.currentPlayer || activePlayers[0] || ''
|
||||
if (!playerId) return // No active player to send hover for
|
||||
|
||||
sendMove({
|
||||
type: 'HOVER_CARD',
|
||||
playerId,
|
||||
data: { cardId },
|
||||
})
|
||||
},
|
||||
[state.currentPlayer, activePlayers, sendMove]
|
||||
)
|
||||
|
||||
// NO MORE effectiveState merging! Just use session state directly with gameMode added
|
||||
const effectiveState = { ...state, gameMode } as MemoryPairsState & { gameMode: GameMode }
|
||||
|
||||
const contextValue: MemoryPairsContextValue = {
|
||||
state: effectiveState,
|
||||
dispatch: () => {
|
||||
// No-op - replaced with sendMove
|
||||
console.warn('dispatch() is deprecated in arcade mode, use action creators instead')
|
||||
},
|
||||
isGameActive,
|
||||
canFlipCard,
|
||||
currentGameStatistics,
|
||||
hasConfigChanged,
|
||||
canResumeGame,
|
||||
canModifyPlayers: false, // Room-based games: always show buttons (false = show buttons)
|
||||
startGame,
|
||||
resumeGame,
|
||||
flipCard,
|
||||
resetGame,
|
||||
goToSetup,
|
||||
setGameType,
|
||||
setDifficulty,
|
||||
setTurnTimer,
|
||||
hoverCard,
|
||||
exitSession,
|
||||
gameMode,
|
||||
activePlayers,
|
||||
}
|
||||
|
||||
return <MemoryPairsContext.Provider value={contextValue}>{children}</MemoryPairsContext.Provider>
|
||||
}
|
||||
|
||||
// Export the hook for this provider
|
||||
export { useMemoryPairs } from './MemoryPairsContext'
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Unit test for player ownership bug in RoomMemoryPairsProvider
|
||||
*
|
||||
* Bug: playerMetadata[playerId].userId is set to the LOCAL viewerId for ALL players,
|
||||
* including remote players from other room members. This causes "Your turn" to show
|
||||
* even when it's a remote player's turn.
|
||||
*
|
||||
* Fix: Use player.isLocal from GameModeContext to determine correct userId ownership.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('Player Metadata userId Assignment', () => {
|
||||
it('should assign local userId to local players only', () => {
|
||||
const viewerId = 'local-user-id'
|
||||
const players = new Map([
|
||||
[
|
||||
'local-player-1',
|
||||
{
|
||||
id: 'local-player-1',
|
||||
name: 'Local Player',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
isLocal: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'remote-player-1',
|
||||
{
|
||||
id: 'remote-player-1',
|
||||
name: 'Remote Player',
|
||||
emoji: '🤠',
|
||||
color: '#10b981',
|
||||
isLocal: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const activePlayers = ['local-player-1', 'remote-player-1']
|
||||
|
||||
// CURRENT BUGGY IMPLEMENTATION (from RoomMemoryPairsProvider.tsx:378-390)
|
||||
const buggyPlayerMetadata: Record<string, any> = {}
|
||||
for (const playerId of activePlayers) {
|
||||
const playerData = players.get(playerId)
|
||||
if (playerData) {
|
||||
buggyPlayerMetadata[playerId] = {
|
||||
id: playerId,
|
||||
name: playerData.name,
|
||||
emoji: playerData.emoji,
|
||||
userId: viewerId, // BUG: Always uses local viewerId!
|
||||
color: playerData.color,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BUG MANIFESTATION: Both players have local userId
|
||||
expect(buggyPlayerMetadata['local-player-1'].userId).toBe('local-user-id')
|
||||
expect(buggyPlayerMetadata['remote-player-1'].userId).toBe('local-user-id') // WRONG!
|
||||
|
||||
// CORRECT IMPLEMENTATION
|
||||
const correctPlayerMetadata: Record<string, any> = {}
|
||||
for (const playerId of activePlayers) {
|
||||
const playerData = players.get(playerId)
|
||||
if (playerData) {
|
||||
correctPlayerMetadata[playerId] = {
|
||||
id: playerId,
|
||||
name: playerData.name,
|
||||
emoji: playerData.emoji,
|
||||
// FIX: Only use local viewerId for local players
|
||||
// For remote players, we don't know their userId from this context,
|
||||
// but we can mark them as NOT belonging to local user
|
||||
userId: playerData.isLocal ? viewerId : `remote-user-${playerId}`,
|
||||
color: playerData.color,
|
||||
isLocal: playerData.isLocal, // Also include isLocal for clarity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT BEHAVIOR: Each player has correct userId
|
||||
expect(correctPlayerMetadata['local-player-1'].userId).toBe('local-user-id')
|
||||
expect(correctPlayerMetadata['remote-player-1'].userId).not.toBe('local-user-id')
|
||||
})
|
||||
|
||||
it('reproduces "Your turn" bug when checking current player', () => {
|
||||
const viewerId = 'local-user-id'
|
||||
const currentPlayer = 'remote-player-1' // Remote player's turn
|
||||
|
||||
// Buggy playerMetadata (all players have local userId)
|
||||
const buggyPlayerMetadata = {
|
||||
'local-player-1': {
|
||||
id: 'local-player-1',
|
||||
userId: 'local-user-id',
|
||||
},
|
||||
'remote-player-1': {
|
||||
id: 'remote-player-1',
|
||||
userId: 'local-user-id', // BUG!
|
||||
},
|
||||
}
|
||||
|
||||
// PlayerStatusBar logic (line 31 in PlayerStatusBar.tsx)
|
||||
const buggyIsLocalPlayer = buggyPlayerMetadata[currentPlayer]?.userId === viewerId
|
||||
|
||||
// BUG: Shows "Your turn" even though it's remote player's turn!
|
||||
expect(buggyIsLocalPlayer).toBe(true) // WRONG!
|
||||
expect(buggyIsLocalPlayer ? 'Your turn' : 'Their turn').toBe('Your turn') // WRONG!
|
||||
|
||||
// Correct playerMetadata (each player has correct userId)
|
||||
const correctPlayerMetadata = {
|
||||
'local-player-1': {
|
||||
id: 'local-player-1',
|
||||
userId: 'local-user-id',
|
||||
},
|
||||
'remote-player-1': {
|
||||
id: 'remote-player-1',
|
||||
userId: 'remote-user-id', // CORRECT!
|
||||
},
|
||||
}
|
||||
|
||||
// PlayerStatusBar logic with correct data
|
||||
const correctIsLocalPlayer = correctPlayerMetadata[currentPlayer]?.userId === viewerId
|
||||
|
||||
// CORRECT: Shows "Their turn" because it's remote player's turn
|
||||
expect(correctIsLocalPlayer).toBe(false) // CORRECT!
|
||||
expect(correctIsLocalPlayer ? 'Your turn' : 'Their turn').toBe('Their turn') // CORRECT!
|
||||
})
|
||||
|
||||
it('reproduces hover avatar bug when filtering by current player', () => {
|
||||
const viewerId = 'local-user-id'
|
||||
const currentPlayer = 'remote-player-1' // Remote player's turn
|
||||
|
||||
// Buggy playerMetadata
|
||||
const buggyPlayerMetadata = {
|
||||
'remote-player-1': {
|
||||
id: 'remote-player-1',
|
||||
userId: 'local-user-id', // BUG!
|
||||
},
|
||||
}
|
||||
|
||||
// OLD WRONG logic from MemoryGrid.tsx (showed remote players)
|
||||
const oldWrongFilter = buggyPlayerMetadata[currentPlayer]?.userId !== viewerId
|
||||
expect(oldWrongFilter).toBe(false) // Would hide avatar incorrectly
|
||||
|
||||
// CURRENT logic in MemoryGrid.tsx (shows only current player)
|
||||
// This is actually correct - show avatar for whoever's turn it is
|
||||
const currentLogic = currentPlayer === 'remote-player-1'
|
||||
expect(currentLogic).toBe(true) // Shows avatar for current player
|
||||
|
||||
// The REAL issue is in PlayerStatusBar showing "Your turn"
|
||||
// when it should show "Their turn"
|
||||
})
|
||||
})
|
||||
20
apps/web/src/app/arcade/matching/context/index.ts
Normal file
20
apps/web/src/app/arcade/matching/context/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Central export point for arcade matching game context
|
||||
* Re-exports the hook from the appropriate provider
|
||||
*/
|
||||
|
||||
// Export the hook (works with both local and room providers)
|
||||
export { useMemoryPairs } from './MemoryPairsContext'
|
||||
|
||||
// Export the room provider (networked multiplayer)
|
||||
export { RoomMemoryPairsProvider } from './RoomMemoryPairsProvider'
|
||||
|
||||
// Export types
|
||||
export type {
|
||||
GameCard,
|
||||
GameMode,
|
||||
GamePhase,
|
||||
GameType,
|
||||
MemoryPairsState,
|
||||
MemoryPairsContextValue,
|
||||
} from './types'
|
||||
@@ -59,6 +59,7 @@ export interface MemoryPairsState {
|
||||
moves: number
|
||||
scores: PlayerScore
|
||||
activePlayers: Player[] // Track active player IDs
|
||||
playerMetadata?: { [playerId: string]: any } // Player metadata for cross-user visibility
|
||||
consecutiveMatches: { [playerId: string]: number } // Track consecutive matches per player
|
||||
|
||||
// Timing
|
||||
@@ -72,6 +73,24 @@ export interface MemoryPairsState {
|
||||
isProcessingMove: boolean
|
||||
showMismatchFeedback: boolean
|
||||
lastMatchedPair: [string, string] | null
|
||||
|
||||
// PAUSE/RESUME: Paused game state
|
||||
originalConfig?: { gameType: GameType; difficulty: Difficulty; turnTimer: number }
|
||||
pausedGamePhase?: GamePhase
|
||||
pausedGameState?: {
|
||||
gameCards: GameCard[]
|
||||
currentPlayer: Player
|
||||
matchedPairs: number
|
||||
moves: number
|
||||
scores: PlayerScore
|
||||
activePlayers: Player[]
|
||||
playerMetadata: { [playerId: string]: any }
|
||||
consecutiveMatches: { [playerId: string]: number }
|
||||
gameStartTime: number | null
|
||||
}
|
||||
|
||||
// HOVER: Networked hover state
|
||||
playerHovers?: { [playerId: string]: string | null }
|
||||
}
|
||||
|
||||
export type MemoryPairsAction =
|
||||
@@ -101,6 +120,11 @@ export interface MemoryPairsContextValue {
|
||||
currentGameStatistics: GameStatistics
|
||||
gameMode: GameMode // Derived from global context
|
||||
activePlayers: Player[] // Active player IDs from arena
|
||||
canModifyPlayers: boolean // Whether players can be added/removed (controls button visibility)
|
||||
|
||||
// PAUSE/RESUME: Computed pause/resume values
|
||||
hasConfigChanged?: boolean
|
||||
canResumeGame?: boolean
|
||||
|
||||
// Actions
|
||||
startGame: () => void
|
||||
@@ -108,6 +132,10 @@ export interface MemoryPairsContextValue {
|
||||
resetGame: () => void
|
||||
setGameType: (type: GameType) => void
|
||||
setDifficulty: (difficulty: Difficulty) => void
|
||||
setTurnTimer?: (timer: number) => void
|
||||
goToSetup?: () => void
|
||||
resumeGame?: () => void
|
||||
hoverCard?: (cardId: string | null) => void
|
||||
exitSession: () => void
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { ArcadeGuardedPage } from '@/components/ArcadeGuardedPage'
|
||||
import { MemoryPairsGame } from './components/MemoryPairsGame'
|
||||
import { ArcadeMemoryPairsProvider } from './context/ArcadeMemoryPairsContext'
|
||||
import { LocalMemoryPairsProvider } from './context/LocalMemoryPairsProvider'
|
||||
|
||||
export default function MatchingPage() {
|
||||
return (
|
||||
<ArcadeGuardedPage>
|
||||
<ArcadeMemoryPairsProvider>
|
||||
<LocalMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</ArcadeMemoryPairsProvider>
|
||||
</LocalMemoryPairsProvider>
|
||||
</ArcadeGuardedPage>
|
||||
)
|
||||
}
|
||||
|
||||
93
apps/web/src/app/arcade/room/page.tsx
Normal file
93
apps/web/src/app/arcade/room/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client'
|
||||
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
|
||||
import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProvider'
|
||||
|
||||
/**
|
||||
* /arcade/room - Renders the game for the user's current room
|
||||
* Since users can only be in one room at a time, this is a simple singular route
|
||||
*
|
||||
* Note: We don't redirect to /arcade if no room exists because:
|
||||
* - It would conflict with arcade session redirects and create loops
|
||||
* - useArcadeRedirect on /arcade page handles redirecting to active sessions
|
||||
*/
|
||||
export default function RoomPage() {
|
||||
const { roomData, isLoading } = useRoomData()
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Loading room...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show error if no room (instead of redirecting)
|
||||
if (!roomData) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div>No active room found</div>
|
||||
<a
|
||||
href="/arcade"
|
||||
style={{
|
||||
color: '#3b82f6',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Go to Champion Arena
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render the appropriate game based on room's gameName
|
||||
// Note: We don't use ArcadeGuardedPage here because room-based games
|
||||
// have their own navigation logic via useRoomData
|
||||
switch (roomData.gameName) {
|
||||
case 'matching':
|
||||
return (
|
||||
<RoomMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</RoomMemoryPairsProvider>
|
||||
)
|
||||
|
||||
// TODO: Add other games (complement-race, memory-quiz, etc.)
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not yet supported
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type DisembarkingAnimation,
|
||||
usePassengerAnimations,
|
||||
} from '../../hooks/usePassengerAnimations'
|
||||
import type { ComplementQuestion } from '../../lib/gameTypes'
|
||||
import { useSteamJourney } from '../../hooks/useSteamJourney'
|
||||
import { useTrackManagement } from '../../hooks/useTrackManagement'
|
||||
import { useTrainTransforms } from '../../hooks/useTrainTransforms'
|
||||
@@ -80,7 +81,7 @@ interface SteamTrainJourneyProps {
|
||||
trainPosition: number
|
||||
pressure: number
|
||||
elapsedTime: number
|
||||
currentQuestion: { number: number; targetSum: number; correctAnswer: number } | null
|
||||
currentQuestion: ComplementQuestion | null
|
||||
currentInput: string
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ describe('GameHUD', () => {
|
||||
number: 3,
|
||||
targetSum: 10,
|
||||
correctAnswer: 7,
|
||||
showAsAbacus: false,
|
||||
},
|
||||
currentInput: '7',
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@ import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import { useSteamJourney } from '../useSteamJourney'
|
||||
|
||||
// Mock sound effects
|
||||
jest.mock('../useSoundEffects', () => ({
|
||||
vi.mock('../useSoundEffects', () => ({
|
||||
useSoundEffects: () => ({
|
||||
playSound: jest.fn(),
|
||||
playSound: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -53,12 +53,12 @@ const _testStations: Station[] = [
|
||||
|
||||
describe('useSteamJourney - Passenger Boarding', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers()
|
||||
jest.useRealTimers()
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
test('passenger boards when train reaches their origin station', () => {
|
||||
@@ -106,7 +106,7 @@ describe('useSteamJourney - Passenger Boarding', () => {
|
||||
|
||||
// Advance timers to trigger the interval
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100)
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// Verify passenger boarded
|
||||
@@ -150,7 +150,7 @@ describe('useSteamJourney - Passenger Boarding', () => {
|
||||
|
||||
// Advance timers
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100)
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// All three passengers should board (one per car)
|
||||
@@ -190,7 +190,7 @@ describe('useSteamJourney - Passenger Boarding', () => {
|
||||
pressure: 120,
|
||||
elapsedTime: 1000 + pos * 50,
|
||||
})
|
||||
jest.advanceTimersByTime(50)
|
||||
vi.advanceTimersByTime(50)
|
||||
})
|
||||
|
||||
// Check if passenger boarded
|
||||
@@ -239,7 +239,7 @@ describe('useSteamJourney - Passenger Boarding', () => {
|
||||
})
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100)
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// p2 should board (on car 1 since car 0 is occupied)
|
||||
@@ -282,7 +282,7 @@ describe('useSteamJourney - Passenger Boarding', () => {
|
||||
})
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100)
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
// Passenger should be delivered
|
||||
|
||||
3
apps/web/src/app/games/matching/context/types.js
Normal file
3
apps/web/src/app/games/matching/context/types.js
Normal file
@@ -0,0 +1,3 @@
|
||||
"use strict";
|
||||
// TypeScript interfaces for Memory Pairs Challenge game
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
@@ -40,6 +40,20 @@ export interface GameStatistics {
|
||||
averageTimePerMove: number
|
||||
}
|
||||
|
||||
export interface PlayerMetadata {
|
||||
id: string // Player ID
|
||||
name: string
|
||||
emoji: string
|
||||
userId: string // Which user owns this player
|
||||
color?: string
|
||||
}
|
||||
|
||||
export interface GameConfiguration {
|
||||
gameType: GameType
|
||||
difficulty: Difficulty
|
||||
turnTimer: number
|
||||
}
|
||||
|
||||
export interface MemoryPairsState {
|
||||
// Core game data
|
||||
cards: GameCard[]
|
||||
@@ -51,6 +65,22 @@ export interface MemoryPairsState {
|
||||
difficulty: Difficulty
|
||||
turnTimer: number // Seconds for two-player mode
|
||||
|
||||
// Paused game state - for Resume functionality
|
||||
originalConfig?: GameConfiguration // Config when game started - used to detect changes
|
||||
pausedGamePhase?: 'playing' | 'results' // Set when GO_TO_SETUP called from active game
|
||||
pausedGameState?: {
|
||||
// Snapshot of game state when paused
|
||||
gameCards: GameCard[]
|
||||
currentPlayer: Player
|
||||
matchedPairs: number
|
||||
moves: number
|
||||
scores: PlayerScore
|
||||
activePlayers: Player[]
|
||||
playerMetadata: { [playerId: string]: PlayerMetadata }
|
||||
consecutiveMatches: { [playerId: string]: number }
|
||||
gameStartTime: number | null
|
||||
}
|
||||
|
||||
// Game progression
|
||||
gamePhase: GamePhase
|
||||
currentPlayer: Player
|
||||
@@ -59,6 +89,7 @@ export interface MemoryPairsState {
|
||||
moves: number
|
||||
scores: PlayerScore
|
||||
activePlayers: Player[] // Track active player IDs
|
||||
playerMetadata: { [playerId: string]: PlayerMetadata } // Player metadata snapshot for cross-user visibility
|
||||
consecutiveMatches: { [playerId: string]: number } // Track consecutive matches per player
|
||||
|
||||
// Timing
|
||||
@@ -72,6 +103,9 @@ export interface MemoryPairsState {
|
||||
isProcessingMove: boolean
|
||||
showMismatchFeedback: boolean
|
||||
lastMatchedPair: [string, string] | null
|
||||
|
||||
// Hover state for networked presence
|
||||
playerHovers: { [playerId: string]: string | null } // playerId -> cardId (or null if not hovering)
|
||||
}
|
||||
|
||||
export type MemoryPairsAction =
|
||||
@@ -101,13 +135,19 @@ export interface MemoryPairsContextValue {
|
||||
currentGameStatistics: GameStatistics
|
||||
gameMode: GameMode // Derived from global context
|
||||
activePlayers: Player[] // Active player IDs from arena
|
||||
hasConfigChanged: boolean // True if current config differs from originalConfig
|
||||
canResumeGame: boolean // True if there's a paused game and config hasn't changed
|
||||
|
||||
// Actions
|
||||
startGame: () => void
|
||||
resumeGame: () => void
|
||||
flipCard: (cardId: string) => void
|
||||
resetGame: () => void
|
||||
setGameType: (type: GameType) => void
|
||||
setDifficulty: (difficulty: Difficulty) => void
|
||||
setTurnTimer: (timer: number) => void
|
||||
hoverCard: (cardId: string | null) => void // Send hover state for networked presence
|
||||
goToSetup: () => void
|
||||
exitSession: () => void // Exit arcade session (no-op for non-arcade mode)
|
||||
}
|
||||
|
||||
@@ -133,14 +173,6 @@ export interface GameGridProps {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
// Configuration interfaces
|
||||
export interface GameConfiguration {
|
||||
gameMode: GameMode
|
||||
gameType: GameType
|
||||
difficulty: Difficulty
|
||||
turnTimer: number
|
||||
}
|
||||
|
||||
export interface MatchValidationResult {
|
||||
isValid: boolean
|
||||
reason?: string
|
||||
|
||||
164
apps/web/src/app/games/matching/utils/cardGeneration.js
Normal file
164
apps/web/src/app/games/matching/utils/cardGeneration.js
Normal file
@@ -0,0 +1,164 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.generateAbacusNumeralCards = generateAbacusNumeralCards;
|
||||
exports.generateComplementCards = generateComplementCards;
|
||||
exports.generateGameCards = generateGameCards;
|
||||
exports.getGridConfiguration = getGridConfiguration;
|
||||
exports.generateCardId = generateCardId;
|
||||
// Utility function to generate unique random numbers
|
||||
function generateUniqueNumbers(count, options) {
|
||||
const numbers = new Set();
|
||||
const { min, max } = options;
|
||||
while (numbers.size < count) {
|
||||
const randomNum = Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
numbers.add(randomNum);
|
||||
}
|
||||
return Array.from(numbers);
|
||||
}
|
||||
// Utility function to shuffle an array
|
||||
function shuffleArray(array) {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
// Generate cards for abacus-numeral game mode
|
||||
function generateAbacusNumeralCards(pairs) {
|
||||
// Generate unique numbers based on difficulty
|
||||
// For easier games, use smaller numbers; for harder games, use larger ranges
|
||||
const numberRanges = {
|
||||
6: { min: 1, max: 50 }, // 6 pairs: 1-50
|
||||
8: { min: 1, max: 100 }, // 8 pairs: 1-100
|
||||
12: { min: 1, max: 200 }, // 12 pairs: 1-200
|
||||
15: { min: 1, max: 300 }, // 15 pairs: 1-300
|
||||
};
|
||||
const range = numberRanges[pairs];
|
||||
const numbers = generateUniqueNumbers(pairs, range);
|
||||
const cards = [];
|
||||
numbers.forEach((number) => {
|
||||
// Abacus representation card
|
||||
cards.push({
|
||||
id: `abacus_${number}`,
|
||||
type: 'abacus',
|
||||
number,
|
||||
matched: false,
|
||||
});
|
||||
// Numerical representation card
|
||||
cards.push({
|
||||
id: `number_${number}`,
|
||||
type: 'number',
|
||||
number,
|
||||
matched: false,
|
||||
});
|
||||
});
|
||||
return shuffleArray(cards);
|
||||
}
|
||||
// Generate cards for complement pairs game mode
|
||||
function generateComplementCards(pairs) {
|
||||
// Define complement pairs for friends of 5 and friends of 10
|
||||
const complementPairs = [
|
||||
// Friends of 5
|
||||
{ pair: [0, 5], targetSum: 5 },
|
||||
{ pair: [1, 4], targetSum: 5 },
|
||||
{ pair: [2, 3], targetSum: 5 },
|
||||
// Friends of 10
|
||||
{ pair: [0, 10], targetSum: 10 },
|
||||
{ pair: [1, 9], targetSum: 10 },
|
||||
{ pair: [2, 8], targetSum: 10 },
|
||||
{ pair: [3, 7], targetSum: 10 },
|
||||
{ pair: [4, 6], targetSum: 10 },
|
||||
{ pair: [5, 5], targetSum: 10 },
|
||||
// Additional pairs for higher difficulties
|
||||
{ pair: [6, 4], targetSum: 10 },
|
||||
{ pair: [7, 3], targetSum: 10 },
|
||||
{ pair: [8, 2], targetSum: 10 },
|
||||
{ pair: [9, 1], targetSum: 10 },
|
||||
{ pair: [10, 0], targetSum: 10 },
|
||||
// More challenging pairs (can be used for expert mode)
|
||||
{ pair: [11, 9], targetSum: 20 },
|
||||
{ pair: [12, 8], targetSum: 20 },
|
||||
];
|
||||
// Select the required number of complement pairs
|
||||
const selectedPairs = complementPairs.slice(0, pairs);
|
||||
const cards = [];
|
||||
selectedPairs.forEach(({ pair: [num1, num2], targetSum }, index) => {
|
||||
// First number in the pair
|
||||
cards.push({
|
||||
id: `comp1_${index}_${num1}`,
|
||||
type: 'complement',
|
||||
number: num1,
|
||||
complement: num2,
|
||||
targetSum,
|
||||
matched: false,
|
||||
});
|
||||
// Second number in the pair
|
||||
cards.push({
|
||||
id: `comp2_${index}_${num2}`,
|
||||
type: 'complement',
|
||||
number: num2,
|
||||
complement: num1,
|
||||
targetSum,
|
||||
matched: false,
|
||||
});
|
||||
});
|
||||
return shuffleArray(cards);
|
||||
}
|
||||
// Main card generation function
|
||||
function generateGameCards(gameType, difficulty) {
|
||||
switch (gameType) {
|
||||
case 'abacus-numeral':
|
||||
return generateAbacusNumeralCards(difficulty);
|
||||
case 'complement-pairs':
|
||||
return generateComplementCards(difficulty);
|
||||
default:
|
||||
throw new Error(`Unknown game type: ${gameType}`);
|
||||
}
|
||||
}
|
||||
// Utility function to get responsive grid configuration based on difficulty and screen size
|
||||
function getGridConfiguration(difficulty) {
|
||||
const configs = {
|
||||
6: {
|
||||
totalCards: 12,
|
||||
mobileColumns: 3, // 3x4 grid in portrait
|
||||
tabletColumns: 4, // 4x3 grid on tablet
|
||||
desktopColumns: 4, // 4x3 grid on desktop
|
||||
landscapeColumns: 6, // 6x2 grid in landscape
|
||||
cardSize: { width: '140px', height: '180px' },
|
||||
gridTemplate: 'repeat(3, 1fr)',
|
||||
},
|
||||
8: {
|
||||
totalCards: 16,
|
||||
mobileColumns: 3, // 3x6 grid in portrait (some spillover)
|
||||
tabletColumns: 4, // 4x4 grid on tablet
|
||||
desktopColumns: 4, // 4x4 grid on desktop
|
||||
landscapeColumns: 6, // 6x3 grid in landscape (some spillover)
|
||||
cardSize: { width: '120px', height: '160px' },
|
||||
gridTemplate: 'repeat(3, 1fr)',
|
||||
},
|
||||
12: {
|
||||
totalCards: 24,
|
||||
mobileColumns: 3, // 3x8 grid in portrait
|
||||
tabletColumns: 4, // 4x6 grid on tablet
|
||||
desktopColumns: 6, // 6x4 grid on desktop
|
||||
landscapeColumns: 6, // 6x4 grid in landscape (changed from 8x3)
|
||||
cardSize: { width: '100px', height: '140px' },
|
||||
gridTemplate: 'repeat(3, 1fr)',
|
||||
},
|
||||
15: {
|
||||
totalCards: 30,
|
||||
mobileColumns: 3, // 3x10 grid in portrait
|
||||
tabletColumns: 5, // 5x6 grid on tablet
|
||||
desktopColumns: 6, // 6x5 grid on desktop
|
||||
landscapeColumns: 10, // 10x3 grid in landscape
|
||||
cardSize: { width: '90px', height: '120px' },
|
||||
gridTemplate: 'repeat(3, 1fr)',
|
||||
},
|
||||
};
|
||||
return configs[difficulty];
|
||||
}
|
||||
// Generate a unique ID for cards
|
||||
function generateCardId(type, identifier) {
|
||||
return `${type}_${identifier}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
188
apps/web/src/app/games/matching/utils/matchValidation.js
Normal file
188
apps/web/src/app/games/matching/utils/matchValidation.js
Normal file
@@ -0,0 +1,188 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.validateAbacusNumeralMatch = validateAbacusNumeralMatch;
|
||||
exports.validateComplementMatch = validateComplementMatch;
|
||||
exports.validateMatch = validateMatch;
|
||||
exports.canFlipCard = canFlipCard;
|
||||
exports.getMatchHint = getMatchHint;
|
||||
exports.calculateMatchScore = calculateMatchScore;
|
||||
exports.analyzeGamePerformance = analyzeGamePerformance;
|
||||
// Validate abacus-numeral match (abacus card matches with number card of same value)
|
||||
function validateAbacusNumeralMatch(card1, card2) {
|
||||
// Both cards must have the same number
|
||||
if (card1.number !== card2.number) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Numbers do not match',
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
// Cards must be different types (one abacus, one number)
|
||||
if (card1.type === card2.type) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Both cards are the same type',
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
// One must be abacus, one must be number
|
||||
const hasAbacus = card1.type === 'abacus' || card2.type === 'abacus';
|
||||
const hasNumber = card1.type === 'number' || card2.type === 'number';
|
||||
if (!hasAbacus || !hasNumber) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Must match abacus with number representation',
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
// Neither should be complement type for this game mode
|
||||
if (card1.type === 'complement' || card2.type === 'complement') {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Complement cards not valid in abacus-numeral mode',
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
return {
|
||||
isValid: true,
|
||||
type: 'abacus-numeral',
|
||||
};
|
||||
}
|
||||
// Validate complement match (two numbers that add up to target sum)
|
||||
function validateComplementMatch(card1, card2) {
|
||||
// Both cards must be complement type
|
||||
if (card1.type !== 'complement' || card2.type !== 'complement') {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Both cards must be complement type',
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
// Both cards must have the same target sum
|
||||
if (card1.targetSum !== card2.targetSum) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Cards have different target sums',
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
// Check if the numbers are actually complements
|
||||
if (!card1.complement || !card2.complement) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Complement information missing',
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
// Verify the complement relationship
|
||||
if (card1.number !== card2.complement || card2.number !== card1.complement) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Numbers are not complements of each other',
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
// Verify the sum equals the target
|
||||
const sum = card1.number + card2.number;
|
||||
if (sum !== card1.targetSum) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: `Sum ${sum} does not equal target ${card1.targetSum}`,
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
return {
|
||||
isValid: true,
|
||||
type: 'complement',
|
||||
};
|
||||
}
|
||||
// Main validation function that determines which validation to use
|
||||
function validateMatch(card1, card2) {
|
||||
// Cannot match the same card with itself
|
||||
if (card1.id === card2.id) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Cannot match card with itself',
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
// Cannot match already matched cards
|
||||
if (card1.matched || card2.matched) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Cannot match already matched cards',
|
||||
type: 'invalid',
|
||||
};
|
||||
}
|
||||
// Determine which type of match to validate based on card types
|
||||
const hasComplement = card1.type === 'complement' || card2.type === 'complement';
|
||||
if (hasComplement) {
|
||||
// If either card is complement type, use complement validation
|
||||
return validateComplementMatch(card1, card2);
|
||||
}
|
||||
else {
|
||||
// Otherwise, use abacus-numeral validation
|
||||
return validateAbacusNumeralMatch(card1, card2);
|
||||
}
|
||||
}
|
||||
// Helper function to check if a card can be flipped
|
||||
function canFlipCard(card, flippedCards, isProcessingMove) {
|
||||
// Cannot flip if processing a move
|
||||
if (isProcessingMove)
|
||||
return false;
|
||||
// Cannot flip already matched cards
|
||||
if (card.matched)
|
||||
return false;
|
||||
// Cannot flip if already flipped
|
||||
if (flippedCards.some((c) => c.id === card.id))
|
||||
return false;
|
||||
// Cannot flip if two cards are already flipped
|
||||
if (flippedCards.length >= 2)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
// Get hint for what kind of match the player should look for
|
||||
function getMatchHint(card) {
|
||||
switch (card.type) {
|
||||
case 'abacus':
|
||||
return `Find the number ${card.number}`;
|
||||
case 'number':
|
||||
return `Find the abacus showing ${card.number}`;
|
||||
case 'complement':
|
||||
if (card.complement !== undefined && card.targetSum !== undefined) {
|
||||
return `Find ${card.complement} to make ${card.targetSum}`;
|
||||
}
|
||||
return 'Find the matching complement';
|
||||
default:
|
||||
return 'Find the matching card';
|
||||
}
|
||||
}
|
||||
// Calculate match score based on difficulty and time
|
||||
function calculateMatchScore(difficulty, timeForMatch, isComplementMatch) {
|
||||
const baseScore = isComplementMatch ? 15 : 10; // Complement matches worth more
|
||||
const difficultyMultiplier = difficulty / 6; // Scale with difficulty
|
||||
const timeBonus = Math.max(0, (10000 - timeForMatch) / 1000); // Bonus for speed
|
||||
return Math.round(baseScore * difficultyMultiplier + timeBonus);
|
||||
}
|
||||
// Analyze game performance
|
||||
function analyzeGamePerformance(totalMoves, matchedPairs, totalPairs, gameTime) {
|
||||
const accuracy = totalMoves > 0 ? (matchedPairs / totalMoves) * 100 : 0;
|
||||
const efficiency = totalPairs > 0 ? (matchedPairs / (totalPairs * 2)) * 100 : 0; // Ideal is 100% (each pair found in 2 moves)
|
||||
const averageTimePerMove = totalMoves > 0 ? gameTime / totalMoves : 0;
|
||||
// Calculate grade based on accuracy and efficiency
|
||||
let grade = 'F';
|
||||
if (accuracy >= 90 && efficiency >= 80)
|
||||
grade = 'A';
|
||||
else if (accuracy >= 80 && efficiency >= 70)
|
||||
grade = 'B';
|
||||
else if (accuracy >= 70 && efficiency >= 60)
|
||||
grade = 'C';
|
||||
else if (accuracy >= 60 && efficiency >= 50)
|
||||
grade = 'D';
|
||||
return {
|
||||
accuracy,
|
||||
efficiency,
|
||||
averageTimePerMove,
|
||||
grade,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import React from 'react'
|
||||
import { useGameMode } from '../contexts/GameModeContext'
|
||||
import { useArcadeGuard } from '../hooks/useArcadeGuard'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { useViewerId } from '../hooks/useViewerId'
|
||||
import { AppNavBar } from './AppNavBar'
|
||||
import { GameContextNav } from './nav/GameContextNav'
|
||||
import { PlayerConfigDialog } from './nav/PlayerConfigDialog'
|
||||
@@ -28,6 +31,9 @@ export function PageWithNav({
|
||||
children,
|
||||
}: PageWithNavProps) {
|
||||
const { players, activePlayers, setActive, activePlayerCount } = useGameMode()
|
||||
const { hasActiveSession, activeSession } = useArcadeGuard({ enabled: false }) // Don't redirect, just get info
|
||||
const { roomData, isInRoom } = useRoomData()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
const [configurePlayerId, setConfigurePlayerId] = React.useState<string | null>(null)
|
||||
|
||||
@@ -52,14 +58,14 @@ export function PageWithNav({
|
||||
}
|
||||
|
||||
// Get active and inactive players as arrays
|
||||
// Only show LOCAL players in the active/inactive lists (remote players shown separately in networkPlayers)
|
||||
const activePlayerList = Array.from(activePlayers)
|
||||
.map((id) => players.get(id))
|
||||
.filter((p) => p !== undefined)
|
||||
.map((p) => ({ id: p.id, name: p.name, emoji: p.emoji }))
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined && p.isLocal !== false) // Filter out remote players
|
||||
|
||||
const inactivePlayerList = Array.from(players.values())
|
||||
.filter((p) => !activePlayers.has(p.id))
|
||||
.map((p) => ({ id: p.id, name: p.name, emoji: p.emoji }))
|
||||
const inactivePlayerList = Array.from(players.values()).filter(
|
||||
(p) => !activePlayers.has(p.id) && p.isLocal !== false
|
||||
) // Filter out remote players
|
||||
|
||||
// Compute game mode from active player count
|
||||
const gameMode =
|
||||
@@ -76,6 +82,44 @@ export function PageWithNav({
|
||||
const shouldEmphasize = emphasizeGameContext && mounted
|
||||
const showFullscreenSelection = shouldEmphasize && activePlayerCount === 0
|
||||
|
||||
// Compute arcade session info for display
|
||||
const roomInfo =
|
||||
isInRoom && roomData
|
||||
? {
|
||||
roomName: roomData.name,
|
||||
gameName: roomData.gameName,
|
||||
playerCount: roomData.members.length,
|
||||
}
|
||||
: hasActiveSession && activeSession
|
||||
? {
|
||||
gameName: activeSession.currentGame,
|
||||
playerCount: activePlayerCount,
|
||||
}
|
||||
: undefined
|
||||
|
||||
// Compute network players (other players in the room, excluding current user)
|
||||
const networkPlayers: Array<{
|
||||
id: string
|
||||
emoji?: string
|
||||
name?: string
|
||||
color?: string
|
||||
memberName?: string
|
||||
}> =
|
||||
isInRoom && roomData
|
||||
? roomData.members
|
||||
.filter((member) => member.userId !== viewerId)
|
||||
.flatMap((member) => {
|
||||
const memberPlayerList = roomData.memberPlayers[member.userId] || []
|
||||
return memberPlayerList.map((player) => ({
|
||||
id: player.id,
|
||||
emoji: player.emoji,
|
||||
name: player.name,
|
||||
color: player.color,
|
||||
memberName: member.displayName,
|
||||
}))
|
||||
})
|
||||
: []
|
||||
|
||||
// Create nav content if title is provided
|
||||
const navContent = navTitle ? (
|
||||
<GameContextNav
|
||||
@@ -93,6 +137,8 @@ export function PageWithNav({
|
||||
onSetup={onSetup}
|
||||
onNewGame={onNewGame}
|
||||
canModifyPlayers={canModifyPlayers}
|
||||
roomInfo={roomInfo}
|
||||
networkPlayers={networkPlayers}
|
||||
/>
|
||||
) : null
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import React from 'react'
|
||||
import { PlayerTooltip } from './PlayerTooltip'
|
||||
|
||||
interface Player {
|
||||
id: string
|
||||
name: string
|
||||
emoji: string
|
||||
color?: string
|
||||
createdAt?: Date | number
|
||||
isLocal?: boolean
|
||||
}
|
||||
|
||||
interface ActivePlayersListProps {
|
||||
@@ -24,105 +28,111 @@ export function ActivePlayersList({
|
||||
return (
|
||||
<>
|
||||
{activePlayers.map((player) => (
|
||||
<div
|
||||
<PlayerTooltip
|
||||
key={player.id}
|
||||
style={{
|
||||
position: 'relative',
|
||||
fontSize: shouldEmphasize ? '48px' : '20px',
|
||||
lineHeight: 1,
|
||||
transition: 'font-size 0.4s cubic-bezier(0.4, 0, 0.2, 1), filter 0.4s ease',
|
||||
filter: shouldEmphasize ? 'drop-shadow(0 4px 8px rgba(0,0,0,0.25))' : 'none',
|
||||
cursor: shouldEmphasize ? 'pointer' : 'default',
|
||||
}}
|
||||
title={player.name}
|
||||
onClick={() => shouldEmphasize && onConfigurePlayer(player.id)}
|
||||
onMouseEnter={() => shouldEmphasize && setHoveredPlayerId(player.id)}
|
||||
onMouseLeave={() => shouldEmphasize && setHoveredPlayerId(null)}
|
||||
playerName={player.name}
|
||||
playerColor={player.color}
|
||||
isLocal={player.isLocal !== false}
|
||||
createdAt={player.createdAt}
|
||||
>
|
||||
{player.emoji}
|
||||
{shouldEmphasize && hoveredPlayerId === player.id && (
|
||||
<>
|
||||
{/* Configure button - bottom left */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onConfigurePlayer(player.id)
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-4px',
|
||||
left: '-4px',
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
borderRadius: '50%',
|
||||
border: '2px solid white',
|
||||
background: '#6b7280',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
transition: 'all 0.2s ease',
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#3b82f6'
|
||||
e.currentTarget.style.transform = 'scale(1.15)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#6b7280'
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
}}
|
||||
aria-label={`Configure ${player.name}`}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
fontSize: shouldEmphasize ? '48px' : '20px',
|
||||
lineHeight: 1,
|
||||
transition: 'font-size 0.4s cubic-bezier(0.4, 0, 0.2, 1), filter 0.4s ease',
|
||||
filter: shouldEmphasize ? 'drop-shadow(0 4px 8px rgba(0,0,0,0.25))' : 'none',
|
||||
cursor: shouldEmphasize ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={() => shouldEmphasize && onConfigurePlayer(player.id)}
|
||||
onMouseEnter={() => shouldEmphasize && setHoveredPlayerId(player.id)}
|
||||
onMouseLeave={() => shouldEmphasize && setHoveredPlayerId(null)}
|
||||
>
|
||||
{player.emoji}
|
||||
{shouldEmphasize && hoveredPlayerId === player.id && (
|
||||
<>
|
||||
{/* Configure button - bottom left */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onConfigurePlayer(player.id)
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-4px',
|
||||
left: '-4px',
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
borderRadius: '50%',
|
||||
border: '2px solid white',
|
||||
background: '#6b7280',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
transition: 'all 0.2s ease',
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#3b82f6'
|
||||
e.currentTarget.style.transform = 'scale(1.15)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#6b7280'
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
}}
|
||||
aria-label={`Configure ${player.name}`}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
|
||||
{/* Remove button - top right */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemovePlayer(player.id)
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-4px',
|
||||
right: '-4px',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
border: '2px solid white',
|
||||
background: '#ef4444',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
transition: 'all 0.2s ease',
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#dc2626'
|
||||
e.currentTarget.style.transform = 'scale(1.1)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#ef4444'
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
}}
|
||||
aria-label={`Remove ${player.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Remove button - top right */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemovePlayer(player.id)
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-4px',
|
||||
right: '-4px',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
border: '2px solid white',
|
||||
background: '#ef4444',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
transition: 'all 0.2s ease',
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#dc2626'
|
||||
e.currentTarget.style.transform = 'scale(1.1)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#ef4444'
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
}}
|
||||
aria-label={`Remove ${player.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PlayerTooltip>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,8 @@ import { AddPlayerButton } from './AddPlayerButton'
|
||||
import { FullscreenPlayerSelection } from './FullscreenPlayerSelection'
|
||||
import { GameControlButtons } from './GameControlButtons'
|
||||
import { GameModeIndicator } from './GameModeIndicator'
|
||||
import { NetworkPlayerIndicator } from './NetworkPlayerIndicator'
|
||||
import { RoomInfo } from './RoomInfo'
|
||||
|
||||
type GameMode = 'none' | 'single' | 'battle' | 'tournament'
|
||||
|
||||
@@ -13,6 +15,18 @@ interface Player {
|
||||
emoji: string
|
||||
}
|
||||
|
||||
interface NetworkPlayer {
|
||||
id: string
|
||||
emoji?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
interface ArcadeRoomInfo {
|
||||
roomName?: string
|
||||
gameName: string
|
||||
playerCount: number
|
||||
}
|
||||
|
||||
interface GameContextNavProps {
|
||||
navTitle: string
|
||||
navEmoji?: string
|
||||
@@ -28,6 +42,9 @@ interface GameContextNavProps {
|
||||
onSetup?: () => void
|
||||
onNewGame?: () => void
|
||||
canModifyPlayers?: boolean
|
||||
// Arcade session info
|
||||
networkPlayers?: NetworkPlayer[]
|
||||
roomInfo?: ArcadeRoomInfo
|
||||
}
|
||||
|
||||
export function GameContextNav({
|
||||
@@ -45,6 +62,8 @@ export function GameContextNav({
|
||||
onSetup,
|
||||
onNewGame,
|
||||
canModifyPlayers = true,
|
||||
networkPlayers = [],
|
||||
roomInfo,
|
||||
}: GameContextNavProps) {
|
||||
const [_isTransitioning, setIsTransitioning] = React.useState(false)
|
||||
const [layoutMode, setLayoutMode] = React.useState<'column' | 'row'>(
|
||||
@@ -113,6 +132,35 @@ export function GameContextNav({
|
||||
showFullscreenSelection={showFullscreenSelection}
|
||||
/>
|
||||
|
||||
{/* Room Info - show when in arcade session */}
|
||||
{roomInfo && !showFullscreenSelection && (
|
||||
<RoomInfo
|
||||
roomName={roomInfo.roomName}
|
||||
gameName={roomInfo.gameName}
|
||||
playerCount={roomInfo.playerCount}
|
||||
shouldEmphasize={shouldEmphasize}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Network Players - show other players in the room */}
|
||||
{networkPlayers.length > 0 && !showFullscreenSelection && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: shouldEmphasize ? '12px' : '6px',
|
||||
}}
|
||||
>
|
||||
{networkPlayers.map((player) => (
|
||||
<NetworkPlayerIndicator
|
||||
key={player.id}
|
||||
player={player}
|
||||
shouldEmphasize={shouldEmphasize}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Game Control Buttons - only show during active game */}
|
||||
{!showFullscreenSelection && !canModifyPlayers && (
|
||||
<GameControlButtons onSetup={onSetup} onNewGame={onNewGame} onQuit={onExitSession} />
|
||||
|
||||
130
apps/web/src/components/nav/NetworkPlayerIndicator.tsx
Normal file
130
apps/web/src/components/nav/NetworkPlayerIndicator.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React from 'react'
|
||||
import { PlayerTooltip } from './PlayerTooltip'
|
||||
|
||||
interface NetworkPlayer {
|
||||
id: string
|
||||
emoji?: string
|
||||
name?: string
|
||||
color?: string
|
||||
memberName?: string
|
||||
}
|
||||
|
||||
interface NetworkPlayerIndicatorProps {
|
||||
player: NetworkPlayer
|
||||
shouldEmphasize: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a network player with a special "network" frame border
|
||||
* to distinguish them from local players
|
||||
*/
|
||||
export function NetworkPlayerIndicator({ player, shouldEmphasize }: NetworkPlayerIndicatorProps) {
|
||||
const [isHovered, setIsHovered] = React.useState(false)
|
||||
const playerName = player.name || `Network Player ${player.id.slice(0, 8)}`
|
||||
const extraInfo = player.memberName ? `Controlled by ${player.memberName}` : undefined
|
||||
|
||||
return (
|
||||
<PlayerTooltip
|
||||
playerName={playerName}
|
||||
playerColor={player.color}
|
||||
isLocal={false}
|
||||
extraInfo={extraInfo}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
fontSize: shouldEmphasize ? '48px' : '20px',
|
||||
lineHeight: 1,
|
||||
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
cursor: 'default',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Network frame border */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '-6px',
|
||||
borderRadius: '8px',
|
||||
background: `
|
||||
linear-gradient(135deg,
|
||||
rgba(59, 130, 246, 0.4),
|
||||
rgba(147, 51, 234, 0.4),
|
||||
rgba(236, 72, 153, 0.4))
|
||||
`,
|
||||
opacity: isHovered ? 1 : 0.7,
|
||||
transition: 'opacity 0.2s ease',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Animated network signal indicator */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(34, 197, 94, 0.9)',
|
||||
boxShadow: '0 0 8px rgba(34, 197, 94, 0.6)',
|
||||
animation: 'networkPulse 2s ease-in-out infinite',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Player emoji or fallback */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
filter: shouldEmphasize ? 'drop-shadow(0 4px 8px rgba(0,0,0,0.25))' : 'none',
|
||||
}}
|
||||
>
|
||||
{player.emoji || '🌐'}
|
||||
</div>
|
||||
|
||||
{/* Network icon badge */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-4px',
|
||||
left: '-4px',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
borderRadius: '50%',
|
||||
border: '2px solid white',
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
color: 'white',
|
||||
fontSize: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
📡
|
||||
</div>
|
||||
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes networkPulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PlayerTooltip>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { EmojiPicker } from '../../app/games/matching/components/EmojiPicker'
|
||||
import { useGameMode } from '../../contexts/GameModeContext'
|
||||
|
||||
@@ -11,17 +11,36 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
// All hooks must be called before early return
|
||||
const { getPlayer, updatePlayer, players } = useGameMode()
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
const [localName, setLocalName] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const player = getPlayer(playerId)
|
||||
const [tempName, setTempName] = useState(player?.name || '')
|
||||
|
||||
// Initialize local name from player
|
||||
useEffect(() => {
|
||||
if (player) {
|
||||
setLocalName(player.name)
|
||||
}
|
||||
}, [player])
|
||||
|
||||
if (!player) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
updatePlayer(playerId, { name: tempName })
|
||||
onClose()
|
||||
const handleNameChange = (newName: string) => {
|
||||
setLocalName(newName)
|
||||
|
||||
// Debounce the update to avoid too many API calls
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
updatePlayer(playerId, { name: newName })
|
||||
setIsSaving(false)
|
||||
}, 500) // Wait 500ms after user stops typing
|
||||
}
|
||||
|
||||
const handleEmojiSelect = (emoji: string) => {
|
||||
@@ -30,7 +49,21 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
}
|
||||
|
||||
// Get player number for UI theming (first 4 players get special colors)
|
||||
const allPlayers = Array.from(players.values()).sort((a, b) => a.createdAt - b.createdAt)
|
||||
const allPlayers = Array.from(players.values()).sort((a, b) => {
|
||||
const aTime =
|
||||
typeof a.createdAt === 'number'
|
||||
? a.createdAt
|
||||
: a.createdAt instanceof Date
|
||||
? a.createdAt.getTime()
|
||||
: 0
|
||||
const bTime =
|
||||
typeof b.createdAt === 'number'
|
||||
? b.createdAt
|
||||
: b.createdAt instanceof Date
|
||||
? b.createdAt.getTime()
|
||||
: 0
|
||||
return aTime - bTime
|
||||
})
|
||||
const playerIndex = allPlayers.findIndex((p) => p.id === playerId)
|
||||
const displayNumber = playerIndex + 1
|
||||
|
||||
@@ -81,22 +114,35 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Configure Player
|
||||
</h2>
|
||||
<div>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
margin: 0,
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Player Settings
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: isSaving ? '#f59e0b' : '#10b981',
|
||||
fontWeight: '500',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{isSaving ? '💾 Saving...' : '✓ Changes saved automatically'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
@@ -198,7 +244,7 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
</div>
|
||||
|
||||
{/* Name Input */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
@@ -212,8 +258,8 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tempName}
|
||||
onChange={(e) => setTempName(e.target.value)}
|
||||
value={localName}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="Player Name"
|
||||
maxLength={20}
|
||||
style={{
|
||||
@@ -243,69 +289,9 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{tempName.length}/20 characters
|
||||
{localName.length}/20 characters
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: 'white',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#6b7280',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#f9fafb'
|
||||
e.currentTarget.style.borderColor = '#d1d5db'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'white'
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(0,0,0,0.2)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)'
|
||||
}}
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
164
apps/web/src/components/nav/PlayerTooltip.tsx
Normal file
164
apps/web/src/components/nav/PlayerTooltip.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import type React from 'react'
|
||||
|
||||
interface PlayerTooltipProps {
|
||||
children: React.ReactNode
|
||||
playerName: string
|
||||
playerColor?: string
|
||||
isLocal?: boolean
|
||||
createdAt?: Date | number
|
||||
extraInfo?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Radix-based tooltip for displaying rich player information
|
||||
* Shows player name, type (local/network), color, and other details
|
||||
*/
|
||||
export function PlayerTooltip({
|
||||
children,
|
||||
playerName,
|
||||
playerColor,
|
||||
isLocal = true,
|
||||
createdAt,
|
||||
extraInfo,
|
||||
}: PlayerTooltipProps) {
|
||||
// Format creation time
|
||||
const getCreatedTimeAgo = () => {
|
||||
if (!createdAt) return null
|
||||
|
||||
const now = Date.now()
|
||||
const created =
|
||||
typeof createdAt === 'number'
|
||||
? createdAt
|
||||
: createdAt instanceof Date
|
||||
? createdAt.getTime()
|
||||
: 0
|
||||
const diff = now - created
|
||||
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const days = Math.floor(diff / 86400000)
|
||||
|
||||
if (days > 0) return `${days}d ago`
|
||||
if (hours > 0) return `${hours}h ago`
|
||||
if (minutes > 0) return `${minutes}m ago`
|
||||
return 'just now'
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={200}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, rgba(17, 24, 39, 0.97), rgba(31, 41, 55, 0.97))',
|
||||
backdropFilter: 'blur(8px)',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 16px',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.1)',
|
||||
maxWidth: '280px',
|
||||
zIndex: 9999,
|
||||
animation: 'tooltipFadeIn 0.2s ease-out',
|
||||
}}
|
||||
>
|
||||
{/* Player name with color accent */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{playerColor && (
|
||||
<div
|
||||
style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
background: playerColor,
|
||||
boxShadow: `0 0 8px ${playerColor}50`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: 'white',
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{playerName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Player type badge */}
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
background: isLocal
|
||||
? 'rgba(16, 185, 129, 0.15)'
|
||||
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.15), rgba(147, 51, 234, 0.15))',
|
||||
border: `1px solid ${isLocal ? 'rgba(16, 185, 129, 0.3)' : 'rgba(147, 51, 234, 0.3)'}`,
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: isLocal ? 'rgba(167, 243, 208, 1)' : 'rgba(196, 181, 253, 1)',
|
||||
marginBottom: extraInfo || createdAt ? '8px' : 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '10px' }}>{isLocal ? '●' : '📡'}</span>
|
||||
{isLocal ? 'Your Player' : 'Network Player'}
|
||||
</div>
|
||||
|
||||
{/* Additional info */}
|
||||
{(extraInfo || createdAt) && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'rgba(209, 213, 219, 0.9)',
|
||||
lineHeight: 1.4,
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
{extraInfo && <div>{extraInfo}</div>}
|
||||
{createdAt && <div style={{ opacity: 0.7 }}>Joined {getCreatedTimeAgo()}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tooltip.Arrow
|
||||
style={{
|
||||
fill: 'rgba(17, 24, 39, 0.97)',
|
||||
}}
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes tooltipFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
88
apps/web/src/components/nav/RoomInfo.tsx
Normal file
88
apps/web/src/components/nav/RoomInfo.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
interface RoomInfoProps {
|
||||
roomName?: string
|
||||
gameName: string
|
||||
playerCount: number
|
||||
shouldEmphasize: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays current arcade room/session information
|
||||
*/
|
||||
export function RoomInfo({ roomName, gameName, playerCount, shouldEmphasize }: RoomInfoProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: shouldEmphasize ? '8px 16px' : '4px 12px',
|
||||
background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(147, 51, 234, 0.2))',
|
||||
borderRadius: '12px',
|
||||
border: '2px solid rgba(59, 130, 246, 0.4)',
|
||||
fontSize: shouldEmphasize ? '16px' : '14px',
|
||||
fontWeight: '600',
|
||||
color: 'rgba(255, 255, 255, 0.95)',
|
||||
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.2)',
|
||||
}}
|
||||
title="Active Arcade Session"
|
||||
>
|
||||
{/* Room icon */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: shouldEmphasize ? '20px' : '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
🎮
|
||||
</div>
|
||||
|
||||
{/* Room details */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: shouldEmphasize ? '14px' : '12px',
|
||||
opacity: 0.8,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
{roomName ? 'Room' : 'Arcade Session'}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: shouldEmphasize ? '16px' : '14px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{roomName || gameName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Player count badge */}
|
||||
<div
|
||||
style={{
|
||||
marginLeft: '8px',
|
||||
padding: '4px 8px',
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '8px',
|
||||
fontSize: shouldEmphasize ? '14px' : '12px',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
<span>👥</span>
|
||||
<span>{playerCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
useUpdatePlayer,
|
||||
useUserPlayers,
|
||||
} from '@/hooks/useUserPlayers'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { getNextPlayerColor } from '../types/player'
|
||||
|
||||
// Client-side Player type (compatible with old type)
|
||||
@@ -66,28 +68,72 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
const { mutate: createPlayer } = useCreatePlayer()
|
||||
const { mutate: updatePlayerMutation } = useUpdatePlayer()
|
||||
const { mutate: deletePlayer } = useDeletePlayer()
|
||||
const { roomData, notifyRoomOfPlayerUpdate } = useRoomData()
|
||||
const { data: viewerId } = useViewerId()
|
||||
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
|
||||
// Convert DB players to Map
|
||||
const players = useMemo(() => {
|
||||
// Convert DB players to Map (local players)
|
||||
const localPlayers = useMemo(() => {
|
||||
const map = new Map<string, Player>()
|
||||
dbPlayers.forEach((dbPlayer) => {
|
||||
map.set(dbPlayer.id, toClientPlayer(dbPlayer))
|
||||
map.set(dbPlayer.id, {
|
||||
...toClientPlayer(dbPlayer),
|
||||
isLocal: true,
|
||||
})
|
||||
})
|
||||
return map
|
||||
}, [dbPlayers])
|
||||
|
||||
// Track active players from DB isActive status
|
||||
// When in a room, merge all players from all room members
|
||||
const players = useMemo(() => {
|
||||
const map = new Map<string, Player>(localPlayers)
|
||||
|
||||
if (roomData) {
|
||||
// Add players from other room members (marked as remote)
|
||||
Object.entries(roomData.memberPlayers).forEach(([userId, memberPlayers]) => {
|
||||
// Skip the current user's players (already in localPlayers)
|
||||
if (userId === viewerId) return
|
||||
|
||||
memberPlayers.forEach((roomPlayer) => {
|
||||
map.set(roomPlayer.id, {
|
||||
id: roomPlayer.id,
|
||||
name: roomPlayer.name,
|
||||
emoji: roomPlayer.emoji,
|
||||
color: roomPlayer.color,
|
||||
createdAt: Date.now(),
|
||||
isActive: true, // Players in memberPlayers are active
|
||||
isLocal: false, // Remote player
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return map
|
||||
}, [localPlayers, roomData, viewerId])
|
||||
|
||||
// Track active players (local + room members when in a room)
|
||||
const activePlayers = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
dbPlayers.forEach((player) => {
|
||||
if (player.isActive) {
|
||||
set.add(player.id)
|
||||
}
|
||||
})
|
||||
|
||||
if (roomData) {
|
||||
// In room mode: all players from all members are active
|
||||
Object.values(roomData.memberPlayers).forEach((memberPlayers) => {
|
||||
memberPlayers.forEach((player) => {
|
||||
set.add(player.id)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// Solo mode: only local active players
|
||||
dbPlayers.forEach((player) => {
|
||||
if (player.isActive) {
|
||||
set.add(player.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return set
|
||||
}, [dbPlayers])
|
||||
}, [dbPlayers, roomData])
|
||||
|
||||
// Initialize with default players if none exist
|
||||
useEffect(() => {
|
||||
@@ -121,19 +167,63 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
isActive: playerData?.isActive ?? false,
|
||||
}
|
||||
|
||||
createPlayer(newPlayer)
|
||||
createPlayer(newPlayer, {
|
||||
onSuccess: () => {
|
||||
// Notify room members if in a room
|
||||
notifyRoomOfPlayerUpdate()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const updatePlayer = (id: string, updates: Partial<Player>) => {
|
||||
updatePlayerMutation({ id, updates })
|
||||
const player = players.get(id)
|
||||
// Only allow updating local players
|
||||
if (player?.isLocal) {
|
||||
updatePlayerMutation(
|
||||
{ id, updates },
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Notify room members if in a room
|
||||
notifyRoomOfPlayerUpdate()
|
||||
},
|
||||
}
|
||||
)
|
||||
} else {
|
||||
console.warn('[GameModeContext] Cannot update remote player:', id)
|
||||
}
|
||||
}
|
||||
|
||||
const removePlayer = (id: string) => {
|
||||
deletePlayer(id)
|
||||
const player = players.get(id)
|
||||
// Only allow removing local players
|
||||
if (player?.isLocal) {
|
||||
deletePlayer(id, {
|
||||
onSuccess: () => {
|
||||
// Notify room members if in a room
|
||||
notifyRoomOfPlayerUpdate()
|
||||
},
|
||||
})
|
||||
} else {
|
||||
console.warn('[GameModeContext] Cannot remove remote player:', id)
|
||||
}
|
||||
}
|
||||
|
||||
const setActive = (id: string, active: boolean) => {
|
||||
updatePlayerMutation({ id, updates: { isActive: active } })
|
||||
const player = players.get(id)
|
||||
// Only allow changing active status of local players
|
||||
if (player?.isLocal) {
|
||||
updatePlayerMutation(
|
||||
{ id, updates: { isActive: active } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Notify room members if in a room
|
||||
notifyRoomOfPlayerUpdate()
|
||||
},
|
||||
}
|
||||
)
|
||||
} else {
|
||||
console.warn('[GameModeContext] Cannot change active status of remote player:', id)
|
||||
}
|
||||
}
|
||||
|
||||
const getActivePlayers = (): Player[] => {
|
||||
@@ -163,6 +253,11 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
isActive: index === 0,
|
||||
})
|
||||
})
|
||||
|
||||
// Notify room members after reset (slight delay to ensure mutations complete)
|
||||
setTimeout(() => {
|
||||
notifyRoomOfPlayerUpdate()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const activePlayerCount = activePlayers.size
|
||||
|
||||
80
apps/web/src/db/index.js
Normal file
80
apps/web/src/db/index.js
Normal file
@@ -0,0 +1,80 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.schema = exports.db = void 0;
|
||||
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
||||
const better_sqlite3_2 = require("drizzle-orm/better-sqlite3");
|
||||
const schema = __importStar(require("./schema"));
|
||||
exports.schema = schema;
|
||||
/**
|
||||
* Database connection and client
|
||||
*
|
||||
* Creates a singleton SQLite connection with Drizzle ORM.
|
||||
* Enables foreign key constraints (required for cascading deletes).
|
||||
*
|
||||
* IMPORTANT: The database connection is lazy-loaded to avoid accessing
|
||||
* the database at module import time, which would cause build failures
|
||||
* when the database doesn't exist (e.g., in CI/CD environments).
|
||||
*/
|
||||
const databaseUrl = process.env.DATABASE_URL || './data/sqlite.db';
|
||||
let _sqlite = null;
|
||||
let _db = null;
|
||||
/**
|
||||
* Get the database connection (lazy-loaded singleton)
|
||||
* Only creates the connection when first accessed at runtime
|
||||
*/
|
||||
function getDb() {
|
||||
if (!_db) {
|
||||
_sqlite = new better_sqlite3_1.default(databaseUrl);
|
||||
// Enable foreign keys (SQLite requires explicit enable)
|
||||
_sqlite.pragma('foreign_keys = ON');
|
||||
// Enable WAL mode for better concurrency
|
||||
_sqlite.pragma('journal_mode = WAL');
|
||||
_db = (0, better_sqlite3_2.drizzle)(_sqlite, { schema });
|
||||
}
|
||||
return _db;
|
||||
}
|
||||
/**
|
||||
* Database client instance
|
||||
* Uses a Proxy to lazy-load the connection on first access
|
||||
*/
|
||||
exports.db = new Proxy({}, {
|
||||
get(_target, prop) {
|
||||
return getDb()[prop];
|
||||
},
|
||||
});
|
||||
22
apps/web/src/db/migrate.js
Normal file
22
apps/web/src/db/migrate.js
Normal file
@@ -0,0 +1,22 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const migrator_1 = require("drizzle-orm/better-sqlite3/migrator");
|
||||
const index_1 = require("./index");
|
||||
/**
|
||||
* Migration runner
|
||||
*
|
||||
* Runs all pending migrations in the drizzle/ folder.
|
||||
* Safe to run multiple times (migrations are idempotent).
|
||||
*
|
||||
* Usage: pnpm db:migrate
|
||||
*/
|
||||
try {
|
||||
console.log('🔄 Running migrations...');
|
||||
(0, migrator_1.migrate)(index_1.db, { migrationsFolder: './drizzle' });
|
||||
console.log('✅ Migrations complete');
|
||||
process.exit(0);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('❌ Migration failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
53
apps/web/src/db/schema/abacus-settings.js
Normal file
53
apps/web/src/db/schema/abacus-settings.js
Normal file
@@ -0,0 +1,53 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.abacusSettings = void 0;
|
||||
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
|
||||
const users_1 = require("./users");
|
||||
/**
|
||||
* Abacus display settings table - UI preferences per user
|
||||
*
|
||||
* One-to-one with users table. Stores abacus display configuration.
|
||||
* Deleted when user is deleted (cascade).
|
||||
*/
|
||||
exports.abacusSettings = (0, sqlite_core_1.sqliteTable)('abacus_settings', {
|
||||
/** Primary key and foreign key to users table */
|
||||
userId: (0, sqlite_core_1.text)('user_id')
|
||||
.primaryKey()
|
||||
.references(() => users_1.users.id, { onDelete: 'cascade' }),
|
||||
/** Color scheme for beads */
|
||||
colorScheme: (0, sqlite_core_1.text)('color_scheme', {
|
||||
enum: ['monochrome', 'place-value', 'heaven-earth', 'alternating'],
|
||||
})
|
||||
.notNull()
|
||||
.default('place-value'),
|
||||
/** Bead shape */
|
||||
beadShape: (0, sqlite_core_1.text)('bead_shape', {
|
||||
enum: ['diamond', 'circle', 'square'],
|
||||
})
|
||||
.notNull()
|
||||
.default('diamond'),
|
||||
/** Color palette */
|
||||
colorPalette: (0, sqlite_core_1.text)('color_palette', {
|
||||
enum: ['default', 'colorblind', 'mnemonic', 'grayscale', 'nature'],
|
||||
})
|
||||
.notNull()
|
||||
.default('default'),
|
||||
/** Hide inactive beads */
|
||||
hideInactiveBeads: (0, sqlite_core_1.integer)('hide_inactive_beads', { mode: 'boolean' }).notNull().default(false),
|
||||
/** Color numerals based on place value */
|
||||
coloredNumerals: (0, sqlite_core_1.integer)('colored_numerals', { mode: 'boolean' }).notNull().default(false),
|
||||
/** Scale factor for abacus size */
|
||||
scaleFactor: (0, sqlite_core_1.real)('scale_factor').notNull().default(1.0),
|
||||
/** Show numbers below abacus */
|
||||
showNumbers: (0, sqlite_core_1.integer)('show_numbers', { mode: 'boolean' }).notNull().default(true),
|
||||
/** Enable animations */
|
||||
animated: (0, sqlite_core_1.integer)('animated', { mode: 'boolean' }).notNull().default(true),
|
||||
/** Enable interaction */
|
||||
interactive: (0, sqlite_core_1.integer)('interactive', { mode: 'boolean' }).notNull().default(false),
|
||||
/** Enable gesture controls */
|
||||
gestures: (0, sqlite_core_1.integer)('gestures', { mode: 'boolean' }).notNull().default(false),
|
||||
/** Enable sound effects */
|
||||
soundEnabled: (0, sqlite_core_1.integer)('sound_enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
/** Sound volume (0.0 - 1.0) */
|
||||
soundVolume: (0, sqlite_core_1.real)('sound_volume').notNull().default(0.8),
|
||||
});
|
||||
39
apps/web/src/db/schema/arcade-rooms.js
Normal file
39
apps/web/src/db/schema/arcade-rooms.js
Normal file
@@ -0,0 +1,39 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.arcadeRooms = void 0;
|
||||
const cuid2_1 = require("@paralleldrive/cuid2");
|
||||
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
|
||||
exports.arcadeRooms = (0, sqlite_core_1.sqliteTable)('arcade_rooms', {
|
||||
id: (0, sqlite_core_1.text)('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => (0, cuid2_1.createId)()),
|
||||
// Room identity
|
||||
code: (0, sqlite_core_1.text)('code', { length: 6 }).notNull().unique(), // e.g., "ABC123"
|
||||
name: (0, sqlite_core_1.text)('name', { length: 50 }).notNull(),
|
||||
// Creator info
|
||||
createdBy: (0, sqlite_core_1.text)('created_by').notNull(), // User/guest ID
|
||||
creatorName: (0, sqlite_core_1.text)('creator_name', { length: 50 }).notNull(),
|
||||
createdAt: (0, sqlite_core_1.integer)('created_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
// Lifecycle
|
||||
lastActivity: (0, sqlite_core_1.integer)('last_activity', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
ttlMinutes: (0, sqlite_core_1.integer)('ttl_minutes').notNull().default(60), // Time to live
|
||||
isLocked: (0, sqlite_core_1.integer)('is_locked', { mode: 'boolean' }).notNull().default(false),
|
||||
// Game configuration
|
||||
gameName: (0, sqlite_core_1.text)('game_name', {
|
||||
enum: ['matching', 'memory-quiz', 'complement-race'],
|
||||
}).notNull(),
|
||||
gameConfig: (0, sqlite_core_1.text)('game_config', { mode: 'json' }).notNull(), // Game-specific settings
|
||||
// Current state
|
||||
status: (0, sqlite_core_1.text)('status', {
|
||||
enum: ['lobby', 'playing', 'finished'],
|
||||
})
|
||||
.notNull()
|
||||
.default('lobby'),
|
||||
currentSessionId: (0, sqlite_core_1.text)('current_session_id'), // FK to arcade_sessions (nullable)
|
||||
// Metadata
|
||||
totalGamesPlayed: (0, sqlite_core_1.integer)('total_games_played').notNull().default(0),
|
||||
});
|
||||
30
apps/web/src/db/schema/arcade-sessions.js
Normal file
30
apps/web/src/db/schema/arcade-sessions.js
Normal file
@@ -0,0 +1,30 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.arcadeSessions = void 0;
|
||||
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
|
||||
const arcade_rooms_1 = require("./arcade-rooms");
|
||||
const users_1 = require("./users");
|
||||
exports.arcadeSessions = (0, sqlite_core_1.sqliteTable)('arcade_sessions', {
|
||||
userId: (0, sqlite_core_1.text)('user_id')
|
||||
.primaryKey()
|
||||
.references(() => users_1.users.id, { onDelete: 'cascade' }),
|
||||
// Session metadata
|
||||
currentGame: (0, sqlite_core_1.text)('current_game', {
|
||||
enum: ['matching', 'memory-quiz', 'complement-race'],
|
||||
}).notNull(),
|
||||
gameUrl: (0, sqlite_core_1.text)('game_url').notNull(), // e.g., '/arcade/matching'
|
||||
// Game state (JSON blob)
|
||||
gameState: (0, sqlite_core_1.text)('game_state', { mode: 'json' }).notNull(),
|
||||
// Active players snapshot (for quick access)
|
||||
activePlayers: (0, sqlite_core_1.text)('active_players', { mode: 'json' }).notNull(),
|
||||
// Room association (null for solo play)
|
||||
roomId: (0, sqlite_core_1.text)('room_id').references(() => arcade_rooms_1.arcadeRooms.id, { onDelete: 'set null' }),
|
||||
// Timing & TTL
|
||||
startedAt: (0, sqlite_core_1.integer)('started_at', { mode: 'timestamp' }).notNull(),
|
||||
lastActivityAt: (0, sqlite_core_1.integer)('last_activity_at', { mode: 'timestamp' }).notNull(),
|
||||
expiresAt: (0, sqlite_core_1.integer)('expires_at', { mode: 'timestamp' }).notNull(), // TTL-based
|
||||
// Status
|
||||
isActive: (0, sqlite_core_1.integer)('is_active', { mode: 'boolean' }).notNull().default(true),
|
||||
// Version for optimistic locking
|
||||
version: (0, sqlite_core_1.integer)('version').notNull().default(1),
|
||||
});
|
||||
29
apps/web/src/db/schema/index.js
Normal file
29
apps/web/src/db/schema/index.js
Normal file
@@ -0,0 +1,29 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Database schema exports
|
||||
*
|
||||
* This is the single source of truth for the database schema.
|
||||
* All tables, relations, and types are exported from here.
|
||||
*/
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
__exportStar(require("./abacus-settings"), exports);
|
||||
__exportStar(require("./arcade-rooms"), exports);
|
||||
__exportStar(require("./arcade-sessions"), exports);
|
||||
__exportStar(require("./players"), exports);
|
||||
__exportStar(require("./room-members"), exports);
|
||||
__exportStar(require("./user-stats"), exports);
|
||||
__exportStar(require("./users"), exports);
|
||||
36
apps/web/src/db/schema/players.js
Normal file
36
apps/web/src/db/schema/players.js
Normal file
@@ -0,0 +1,36 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.players = void 0;
|
||||
const cuid2_1 = require("@paralleldrive/cuid2");
|
||||
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
|
||||
const users_1 = require("./users");
|
||||
/**
|
||||
* Players table - user-created player profiles for games
|
||||
*
|
||||
* Each user can have multiple players (for multi-player modes).
|
||||
* Players are scoped to a user and deleted when user is deleted.
|
||||
*/
|
||||
exports.players = (0, sqlite_core_1.sqliteTable)('players', {
|
||||
id: (0, sqlite_core_1.text)('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => (0, cuid2_1.createId)()),
|
||||
/** Foreign key to users table - cascades on delete */
|
||||
userId: (0, sqlite_core_1.text)('user_id')
|
||||
.notNull()
|
||||
.references(() => users_1.users.id, { onDelete: 'cascade' }),
|
||||
/** Player display name */
|
||||
name: (0, sqlite_core_1.text)('name').notNull(),
|
||||
/** Player emoji avatar */
|
||||
emoji: (0, sqlite_core_1.text)('emoji').notNull(),
|
||||
/** Player color (hex) for UI theming */
|
||||
color: (0, sqlite_core_1.text)('color').notNull(),
|
||||
/** Whether this player is currently active in games */
|
||||
isActive: (0, sqlite_core_1.integer)('is_active', { mode: 'boolean' }).notNull().default(false),
|
||||
/** When this player was created */
|
||||
createdAt: (0, sqlite_core_1.integer)('created_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
}, (table) => ({
|
||||
/** Index for fast lookups by userId */
|
||||
userIdIdx: (0, sqlite_core_1.index)('players_user_id_idx').on(table.userId),
|
||||
}));
|
||||
27
apps/web/src/db/schema/room-members.js
Normal file
27
apps/web/src/db/schema/room-members.js
Normal file
@@ -0,0 +1,27 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.roomMembers = void 0;
|
||||
const cuid2_1 = require("@paralleldrive/cuid2");
|
||||
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
|
||||
const arcade_rooms_1 = require("./arcade-rooms");
|
||||
exports.roomMembers = (0, sqlite_core_1.sqliteTable)('room_members', {
|
||||
id: (0, sqlite_core_1.text)('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => (0, cuid2_1.createId)()),
|
||||
roomId: (0, sqlite_core_1.text)('room_id')
|
||||
.notNull()
|
||||
.references(() => arcade_rooms_1.arcadeRooms.id, { onDelete: 'cascade' }),
|
||||
userId: (0, sqlite_core_1.text)('user_id').notNull(), // User/guest ID - UNIQUE: one room per user (enforced by index below)
|
||||
displayName: (0, sqlite_core_1.text)('display_name', { length: 50 }).notNull(),
|
||||
isCreator: (0, sqlite_core_1.integer)('is_creator', { mode: 'boolean' }).notNull().default(false),
|
||||
joinedAt: (0, sqlite_core_1.integer)('joined_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
lastSeen: (0, sqlite_core_1.integer)('last_seen', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
isOnline: (0, sqlite_core_1.integer)('is_online', { mode: 'boolean' }).notNull().default(true),
|
||||
}, (table) => ({
|
||||
// Explicit unique index for clarity and database-level enforcement
|
||||
userIdIdx: (0, sqlite_core_1.uniqueIndex)('idx_room_members_user_id_unique').on(table.userId),
|
||||
}));
|
||||
@@ -1,29 +1,36 @@
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
|
||||
import { arcadeRooms } from './arcade-rooms'
|
||||
|
||||
export const roomMembers = sqliteTable('room_members', {
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => createId()),
|
||||
export const roomMembers = sqliteTable(
|
||||
'room_members',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => createId()),
|
||||
|
||||
roomId: text('room_id')
|
||||
.notNull()
|
||||
.references(() => arcadeRooms.id, { onDelete: 'cascade' }),
|
||||
roomId: text('room_id')
|
||||
.notNull()
|
||||
.references(() => arcadeRooms.id, { onDelete: 'cascade' }),
|
||||
|
||||
userId: text('user_id').notNull(), // User/guest ID
|
||||
displayName: text('display_name', { length: 50 }).notNull(),
|
||||
userId: text('user_id').notNull(), // User/guest ID - UNIQUE: one room per user (enforced by index below)
|
||||
displayName: text('display_name', { length: 50 }).notNull(),
|
||||
|
||||
isCreator: integer('is_creator', { mode: 'boolean' }).notNull().default(false),
|
||||
isCreator: integer('is_creator', { mode: 'boolean' }).notNull().default(false),
|
||||
|
||||
joinedAt: integer('joined_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
lastSeen: integer('last_seen', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
isOnline: integer('is_online', { mode: 'boolean' }).notNull().default(true),
|
||||
})
|
||||
joinedAt: integer('joined_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
lastSeen: integer('last_seen', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
isOnline: integer('is_online', { mode: 'boolean' }).notNull().default(true),
|
||||
},
|
||||
(table) => ({
|
||||
// Explicit unique index for clarity and database-level enforcement
|
||||
userIdIdx: uniqueIndex('idx_room_members_user_id_unique').on(table.userId),
|
||||
})
|
||||
)
|
||||
|
||||
export type RoomMember = typeof roomMembers.$inferSelect
|
||||
export type NewRoomMember = typeof roomMembers.$inferInsert
|
||||
|
||||
29
apps/web/src/db/schema/user-stats.js
Normal file
29
apps/web/src/db/schema/user-stats.js
Normal file
@@ -0,0 +1,29 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.userStats = void 0;
|
||||
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
|
||||
const users_1 = require("./users");
|
||||
/**
|
||||
* User stats table - game statistics per user
|
||||
*
|
||||
* One-to-one with users table. Tracks aggregate game performance.
|
||||
* Deleted when user is deleted (cascade).
|
||||
*/
|
||||
exports.userStats = (0, sqlite_core_1.sqliteTable)('user_stats', {
|
||||
/** Primary key and foreign key to users table */
|
||||
userId: (0, sqlite_core_1.text)('user_id')
|
||||
.primaryKey()
|
||||
.references(() => users_1.users.id, { onDelete: 'cascade' }),
|
||||
/** Total number of games played */
|
||||
gamesPlayed: (0, sqlite_core_1.integer)('games_played').notNull().default(0),
|
||||
/** Total number of games won */
|
||||
totalWins: (0, sqlite_core_1.integer)('total_wins').notNull().default(0),
|
||||
/** User's most-played game type */
|
||||
favoriteGameType: (0, sqlite_core_1.text)('favorite_game_type', {
|
||||
enum: ['abacus-numeral', 'complement-pairs'],
|
||||
}),
|
||||
/** Best completion time in milliseconds */
|
||||
bestTime: (0, sqlite_core_1.integer)('best_time'),
|
||||
/** Highest accuracy percentage (0.0 - 1.0) */
|
||||
highestAccuracy: (0, sqlite_core_1.real)('highest_accuracy').notNull().default(0),
|
||||
});
|
||||
28
apps/web/src/db/schema/users.js
Normal file
28
apps/web/src/db/schema/users.js
Normal file
@@ -0,0 +1,28 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.users = void 0;
|
||||
const cuid2_1 = require("@paralleldrive/cuid2");
|
||||
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
|
||||
/**
|
||||
* Users table - stores both guest and authenticated users
|
||||
*
|
||||
* Guest users are created automatically on first visit via middleware.
|
||||
* They can upgrade to full accounts later while preserving their data.
|
||||
*/
|
||||
exports.users = (0, sqlite_core_1.sqliteTable)('users', {
|
||||
id: (0, sqlite_core_1.text)('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => (0, cuid2_1.createId)()),
|
||||
/** Stable guest ID from HttpOnly cookie - unique per browser session */
|
||||
guestId: (0, sqlite_core_1.text)('guest_id').notNull().unique(),
|
||||
/** When this user record was created */
|
||||
createdAt: (0, sqlite_core_1.integer)('created_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
/** When guest upgraded to full account (null for guests) */
|
||||
upgradedAt: (0, sqlite_core_1.integer)('upgraded_at', { mode: 'timestamp' }),
|
||||
/** Email (only set after upgrade) */
|
||||
email: (0, sqlite_core_1.text)('email').unique(),
|
||||
/** Display name (only set after upgrade) */
|
||||
name: (0, sqlite_core_1.text)('name'),
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import * as nextNavigation from 'next/navigation'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useArcadeGuard } from '../useArcadeGuard'
|
||||
import * as arcadeSocket from '../useArcadeSocket'
|
||||
import * as viewerId from '../useViewerId'
|
||||
|
||||
// Mock Next.js navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
@@ -15,6 +16,11 @@ vi.mock('../useArcadeSocket', () => ({
|
||||
useArcadeSocket: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock useViewerId
|
||||
vi.mock('../useViewerId', () => ({
|
||||
useViewerId: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('useArcadeGuard', () => {
|
||||
const mockRouter = {
|
||||
push: vi.fn(),
|
||||
@@ -36,6 +42,11 @@ describe('useArcadeGuard', () => {
|
||||
vi.spyOn(nextNavigation, 'useRouter').mockReturnValue(mockRouter as any)
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade/matching')
|
||||
vi.spyOn(arcadeSocket, 'useArcadeSocket').mockReturnValue(mockUseArcadeSocket)
|
||||
vi.spyOn(viewerId, 'useViewerId').mockReturnValue({
|
||||
data: 'test-user',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any)
|
||||
global.fetch = vi.fn()
|
||||
})
|
||||
|
||||
@@ -45,11 +56,7 @@ describe('useArcadeGuard', () => {
|
||||
status: 404,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useArcadeGuard({
|
||||
userId: 'test-user',
|
||||
})
|
||||
)
|
||||
const { result } = renderHook(() => useArcadeGuard())
|
||||
|
||||
expect(result.current.loading).toBe(true)
|
||||
expect(result.current.hasActiveSession).toBe(false)
|
||||
@@ -58,9 +65,11 @@ describe('useArcadeGuard', () => {
|
||||
|
||||
it('should fetch active session on mount', async () => {
|
||||
const mockSession = {
|
||||
gameUrl: '/arcade/matching',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
session: {
|
||||
gameUrl: '/arcade/matching',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
@@ -68,11 +77,7 @@ describe('useArcadeGuard', () => {
|
||||
json: async () => mockSession,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useArcadeGuard({
|
||||
userId: 'test-user',
|
||||
})
|
||||
)
|
||||
const { result } = renderHook(() => useArcadeGuard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
@@ -88,9 +93,11 @@ describe('useArcadeGuard', () => {
|
||||
|
||||
it('should redirect to active session if on different page', async () => {
|
||||
const mockSession = {
|
||||
gameUrl: '/arcade/memory-quiz',
|
||||
currentGame: 'memory-quiz',
|
||||
gameState: {},
|
||||
session: {
|
||||
gameUrl: '/arcade/memory-quiz',
|
||||
currentGame: 'memory-quiz',
|
||||
gameState: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
@@ -100,11 +107,7 @@ describe('useArcadeGuard', () => {
|
||||
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade/matching')
|
||||
|
||||
renderHook(() =>
|
||||
useArcadeGuard({
|
||||
userId: 'test-user',
|
||||
})
|
||||
)
|
||||
renderHook(() => useArcadeGuard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/arcade/memory-quiz')
|
||||
@@ -113,9 +116,11 @@ describe('useArcadeGuard', () => {
|
||||
|
||||
it('should NOT redirect if already on active session page', async () => {
|
||||
const mockSession = {
|
||||
gameUrl: '/arcade/matching',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
session: {
|
||||
gameUrl: '/arcade/matching',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
@@ -125,11 +130,7 @@ describe('useArcadeGuard', () => {
|
||||
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade/matching')
|
||||
|
||||
renderHook(() =>
|
||||
useArcadeGuard({
|
||||
userId: 'test-user',
|
||||
})
|
||||
)
|
||||
renderHook(() => useArcadeGuard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalled()
|
||||
@@ -144,11 +145,7 @@ describe('useArcadeGuard', () => {
|
||||
status: 404,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useArcadeGuard({
|
||||
userId: 'test-user',
|
||||
})
|
||||
)
|
||||
const { result } = renderHook(() => useArcadeGuard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
@@ -161,9 +158,11 @@ describe('useArcadeGuard', () => {
|
||||
it('should call onRedirect callback when redirecting', async () => {
|
||||
const onRedirect = vi.fn()
|
||||
const mockSession = {
|
||||
gameUrl: '/arcade/memory-quiz',
|
||||
currentGame: 'memory-quiz',
|
||||
gameState: {},
|
||||
session: {
|
||||
gameUrl: '/arcade/memory-quiz',
|
||||
currentGame: 'memory-quiz',
|
||||
gameState: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
@@ -173,12 +172,7 @@ describe('useArcadeGuard', () => {
|
||||
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade/matching')
|
||||
|
||||
renderHook(() =>
|
||||
useArcadeGuard({
|
||||
userId: 'test-user',
|
||||
onRedirect,
|
||||
})
|
||||
)
|
||||
renderHook(() => useArcadeGuard({ onRedirect }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRedirect).toHaveBeenCalledWith('/arcade/memory-quiz')
|
||||
@@ -186,22 +180,19 @@ describe('useArcadeGuard', () => {
|
||||
})
|
||||
|
||||
it('should not fetch session when disabled', () => {
|
||||
renderHook(() =>
|
||||
useArcadeGuard({
|
||||
userId: 'test-user',
|
||||
enabled: false,
|
||||
})
|
||||
)
|
||||
renderHook(() => useArcadeGuard({ enabled: false }))
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not fetch session when userId is null', () => {
|
||||
renderHook(() =>
|
||||
useArcadeGuard({
|
||||
userId: null,
|
||||
})
|
||||
)
|
||||
it('should not fetch session when viewerId is null', () => {
|
||||
vi.spyOn(viewerId, 'useViewerId').mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any)
|
||||
|
||||
renderHook(() => useArcadeGuard())
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -212,11 +203,7 @@ describe('useArcadeGuard', () => {
|
||||
status: 404,
|
||||
})
|
||||
|
||||
renderHook(() =>
|
||||
useArcadeGuard({
|
||||
userId: 'test-user',
|
||||
})
|
||||
)
|
||||
renderHook(() => useArcadeGuard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseArcadeSocket.joinSession).toHaveBeenCalledWith('test-user')
|
||||
@@ -227,7 +214,7 @@ describe('useArcadeGuard', () => {
|
||||
let onSessionStateCallback: ((data: any) => void) | null = null
|
||||
|
||||
vi.spyOn(arcadeSocket, 'useArcadeSocket').mockImplementation((events) => {
|
||||
onSessionStateCallback = events.onSessionState || null
|
||||
onSessionStateCallback = events?.onSessionState || null
|
||||
return mockUseArcadeSocket
|
||||
})
|
||||
|
||||
@@ -236,11 +223,7 @@ describe('useArcadeGuard', () => {
|
||||
status: 404,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useArcadeGuard({
|
||||
userId: 'test-user',
|
||||
})
|
||||
)
|
||||
const { result } = renderHook(() => useArcadeGuard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
@@ -268,14 +251,16 @@ describe('useArcadeGuard', () => {
|
||||
let onSessionEndedCallback: (() => void) | null = null
|
||||
|
||||
vi.spyOn(arcadeSocket, 'useArcadeSocket').mockImplementation((events) => {
|
||||
onSessionEndedCallback = events.onSessionEnded || null
|
||||
onSessionEndedCallback = events?.onSessionEnded || null
|
||||
return mockUseArcadeSocket
|
||||
})
|
||||
|
||||
const mockSession = {
|
||||
gameUrl: '/arcade/matching',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
session: {
|
||||
gameUrl: '/arcade/matching',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
@@ -283,11 +268,7 @@ describe('useArcadeGuard', () => {
|
||||
json: async () => mockSession,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useArcadeGuard({
|
||||
userId: 'test-user',
|
||||
})
|
||||
)
|
||||
const { result } = renderHook(() => useArcadeGuard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.hasActiveSession).toBe(true)
|
||||
@@ -305,11 +286,7 @@ describe('useArcadeGuard', () => {
|
||||
it('should handle fetch errors gracefully', async () => {
|
||||
;(global.fetch as any).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useArcadeGuard({
|
||||
userId: 'test-user',
|
||||
})
|
||||
)
|
||||
const { result } = renderHook(() => useArcadeGuard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
@@ -318,4 +295,136 @@ describe('useArcadeGuard', () => {
|
||||
// Should not crash, just set loading to false
|
||||
expect(result.current.hasActiveSession).toBe(false)
|
||||
})
|
||||
|
||||
describe('enabled flag behavior', () => {
|
||||
it('should NOT redirect from HTTP check when enabled=false', async () => {
|
||||
const mockSession = {
|
||||
session: {
|
||||
gameUrl: '/arcade/memory-quiz',
|
||||
currentGame: 'memory-quiz',
|
||||
gameState: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockSession,
|
||||
})
|
||||
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade-rooms')
|
||||
|
||||
renderHook(() => useArcadeGuard({ enabled: false }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Should NOT redirect
|
||||
expect(mockRouter.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT redirect from WebSocket when enabled=false', async () => {
|
||||
let onSessionStateCallback: ((data: any) => void) | null = null
|
||||
|
||||
vi.spyOn(arcadeSocket, 'useArcadeSocket').mockImplementation((events) => {
|
||||
onSessionStateCallback = events?.onSessionState || null
|
||||
return mockUseArcadeSocket
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
})
|
||||
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade-rooms')
|
||||
|
||||
const { result } = renderHook(() => useArcadeGuard({ enabled: false }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
// Simulate session-state event from WebSocket
|
||||
onSessionStateCallback?.({
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
activePlayers: [1],
|
||||
version: 1,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
// Should track the session
|
||||
expect(result.current.hasActiveSession).toBe(true)
|
||||
expect(result.current.activeSession).toEqual({
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
})
|
||||
})
|
||||
|
||||
// But should NOT redirect since enabled=false
|
||||
expect(mockRouter.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should STILL redirect from WebSocket when enabled=true', async () => {
|
||||
let onSessionStateCallback: ((data: any) => void) | null = null
|
||||
|
||||
vi.spyOn(arcadeSocket, 'useArcadeSocket').mockImplementation((events) => {
|
||||
onSessionStateCallback = events?.onSessionState || null
|
||||
return mockUseArcadeSocket
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
})
|
||||
|
||||
vi.spyOn(nextNavigation, 'usePathname').mockReturnValue('/arcade-rooms')
|
||||
|
||||
renderHook(() => useArcadeGuard({ enabled: true }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseArcadeSocket.joinSession).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Simulate session-state event from WebSocket
|
||||
onSessionStateCallback?.({
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
activePlayers: [1],
|
||||
version: 1,
|
||||
})
|
||||
|
||||
// Should redirect when enabled=true
|
||||
await waitFor(() => {
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/arcade/room')
|
||||
})
|
||||
})
|
||||
|
||||
it('should track session state even when enabled=false', async () => {
|
||||
const mockSession = {
|
||||
session: {
|
||||
gameUrl: '/arcade/room',
|
||||
currentGame: 'matching',
|
||||
gameState: {},
|
||||
},
|
||||
}
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockSession,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useArcadeGuard({ enabled: false }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
// Should still provide session info even without redirects
|
||||
expect(result.current.hasActiveSession).toBe(false) // No fetch happened
|
||||
expect(result.current.activeSession).toBe(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -73,11 +73,14 @@ export function useArcadeGuard(options: UseArcadeGuardOptions = {}): UseArcadeGu
|
||||
currentGame: data.currentGame,
|
||||
})
|
||||
|
||||
// Redirect if we're not already on the active game page
|
||||
if (pathname !== data.gameUrl) {
|
||||
// Redirect if we're not already on the active game page (only if enabled)
|
||||
const isAlreadyAtTarget = pathname === data.gameUrl
|
||||
if (enabled && !isAlreadyAtTarget) {
|
||||
console.log('[ArcadeGuard] Redirecting to active session:', data.gameUrl)
|
||||
onRedirect?.(data.gameUrl)
|
||||
router.push(data.gameUrl)
|
||||
} else if (isAlreadyAtTarget) {
|
||||
console.log('[ArcadeGuard] Already at target URL, no redirect needed')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -126,11 +129,14 @@ export function useArcadeGuard(options: UseArcadeGuardOptions = {}): UseArcadeGu
|
||||
currentGame: session.currentGame,
|
||||
})
|
||||
|
||||
// Redirect if we're not already on the active game page
|
||||
if (pathname !== session.gameUrl) {
|
||||
// Redirect if we're not already on the active game page (only if enabled)
|
||||
const isAlreadyAtTarget = pathname === session.gameUrl
|
||||
if (enabled && !isAlreadyAtTarget) {
|
||||
console.log('[ArcadeGuard] Redirecting to active session:', session.gameUrl)
|
||||
onRedirect?.(session.gameUrl)
|
||||
router.push(session.gameUrl)
|
||||
} else if (isAlreadyAtTarget) {
|
||||
console.log('[ArcadeGuard] Already at target URL, no redirect needed')
|
||||
}
|
||||
} else if (response.status === 404) {
|
||||
// No active session
|
||||
|
||||
@@ -71,10 +71,13 @@ export function useArcadeRedirect(options: UseArcadeRedirectOptions = {}): UseAr
|
||||
// Determine if we need to redirect
|
||||
const isArcadeLobby = currentGame === null || currentGame === undefined
|
||||
const isWrongGame = currentGame && currentGame !== data.currentGame
|
||||
const isAlreadyAtTarget = _pathname === data.gameUrl
|
||||
|
||||
if (isArcadeLobby || isWrongGame) {
|
||||
if ((isArcadeLobby || isWrongGame) && !isAlreadyAtTarget) {
|
||||
console.log('[ArcadeRedirect] Redirecting to active game:', data.gameUrl)
|
||||
router.push(data.gameUrl)
|
||||
} else if (isAlreadyAtTarget) {
|
||||
console.log('[ArcadeRedirect] Already at target URL, no redirect needed')
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@ export interface UseArcadeSessionOptions<TState> extends UseOptimisticGameStateO
|
||||
*/
|
||||
userId: string
|
||||
|
||||
/**
|
||||
* Room ID for multi-user sync (optional)
|
||||
* If provided, game state will sync across all users in the room
|
||||
*/
|
||||
roomId?: string
|
||||
|
||||
/**
|
||||
* Auto-join session on mount
|
||||
* @default true
|
||||
@@ -42,8 +48,9 @@ export interface UseArcadeSessionReturn<TState> {
|
||||
|
||||
/**
|
||||
* Send a game move (applies optimistically and sends to server)
|
||||
* Note: playerId must be provided by caller (not omitted)
|
||||
*/
|
||||
sendMove: (move: Omit<GameMove, 'playerId' | 'timestamp'>) => void
|
||||
sendMove: (move: Omit<GameMove, 'timestamp'>) => void
|
||||
|
||||
/**
|
||||
* Exit the arcade session
|
||||
@@ -76,7 +83,7 @@ export interface UseArcadeSessionReturn<TState> {
|
||||
export function useArcadeSession<TState>(
|
||||
options: UseArcadeSessionOptions<TState>
|
||||
): UseArcadeSessionReturn<TState> {
|
||||
const { userId, autoJoin = true, ...optimisticOptions } = options
|
||||
const { userId, roomId, autoJoin = true, ...optimisticOptions } = options
|
||||
|
||||
// Optimistic state management
|
||||
const optimistic = useOptimisticGameState<TState>(optimisticOptions)
|
||||
@@ -122,9 +129,9 @@ export function useArcadeSession<TState>(
|
||||
// Auto-join session when connected
|
||||
useEffect(() => {
|
||||
if (connected && autoJoin && userId) {
|
||||
joinSession(userId)
|
||||
joinSession(userId, roomId)
|
||||
}
|
||||
}, [connected, autoJoin, userId, joinSession])
|
||||
}, [connected, autoJoin, userId, roomId, joinSession])
|
||||
|
||||
// Send move with optimistic update
|
||||
const sendMove = useCallback(
|
||||
@@ -143,10 +150,10 @@ export function useArcadeSession<TState>(
|
||||
// Apply optimistically
|
||||
optimistic.applyOptimisticMove(fullMove)
|
||||
|
||||
// Send to server
|
||||
socketSendMove(userId, fullMove)
|
||||
// Send to server with roomId for room-based games
|
||||
socketSendMove(userId, fullMove, roomId)
|
||||
},
|
||||
[userId, optimistic, socketSendMove]
|
||||
[userId, roomId, optimistic, socketSendMove]
|
||||
)
|
||||
|
||||
const exitSession = useCallback(() => {
|
||||
@@ -156,9 +163,9 @@ export function useArcadeSession<TState>(
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (connected && userId) {
|
||||
joinSession(userId)
|
||||
joinSession(userId, roomId)
|
||||
}
|
||||
}, [connected, userId, joinSession])
|
||||
}, [connected, userId, roomId, joinSession])
|
||||
|
||||
return {
|
||||
state: optimistic.state,
|
||||
|
||||
@@ -20,8 +20,8 @@ export interface ArcadeSocketEvents {
|
||||
export interface UseArcadeSocketReturn {
|
||||
socket: Socket | null
|
||||
connected: boolean
|
||||
joinSession: (userId: string) => void
|
||||
sendMove: (userId: string, move: GameMove) => void
|
||||
joinSession: (userId: string, roomId?: string) => void
|
||||
sendMove: (userId: string, move: GameMove, roomId?: string) => void
|
||||
exitSession: (userId: string) => void
|
||||
pingSession: (userId: string) => void
|
||||
}
|
||||
@@ -103,24 +103,28 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
|
||||
}, [])
|
||||
|
||||
const joinSession = useCallback(
|
||||
(userId: string) => {
|
||||
(userId: string, roomId?: string) => {
|
||||
if (!socket) {
|
||||
console.warn('[ArcadeSocket] Cannot join session - socket not connected')
|
||||
return
|
||||
}
|
||||
console.log('[ArcadeSocket] Joining session for user:', userId)
|
||||
socket.emit('join-arcade-session', { userId })
|
||||
console.log(
|
||||
'[ArcadeSocket] Joining session for user:',
|
||||
userId,
|
||||
roomId ? `in room ${roomId}` : '(solo)'
|
||||
)
|
||||
socket.emit('join-arcade-session', { userId, roomId })
|
||||
},
|
||||
[socket]
|
||||
)
|
||||
|
||||
const sendMove = useCallback(
|
||||
(userId: string, move: GameMove) => {
|
||||
(userId: string, move: GameMove, roomId?: string) => {
|
||||
if (!socket) {
|
||||
console.warn('[ArcadeSocket] Cannot send move - socket not connected')
|
||||
return
|
||||
}
|
||||
const payload = { userId, move }
|
||||
const payload = { userId, move, roomId }
|
||||
console.log(
|
||||
'[ArcadeSocket] Sending game-move event with payload:',
|
||||
JSON.stringify(payload, null, 2)
|
||||
|
||||
214
apps/web/src/hooks/useRoomData.ts
Normal file
214
apps/web/src/hooks/useRoomData.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import { useViewerId } from './useViewerId'
|
||||
|
||||
export interface RoomMember {
|
||||
id: string
|
||||
userId: string
|
||||
displayName: string
|
||||
isOnline: boolean
|
||||
isCreator: boolean
|
||||
}
|
||||
|
||||
export interface RoomPlayer {
|
||||
id: string
|
||||
name: string
|
||||
emoji: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface RoomData {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
gameName: string
|
||||
members: RoomMember[]
|
||||
memberPlayers: Record<string, RoomPlayer[]> // userId -> players
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch and subscribe to the user's current room data
|
||||
* Returns null if user is not in any room
|
||||
*/
|
||||
export function useRoomData() {
|
||||
const { data: userId, isPending: isUserIdPending } = useViewerId()
|
||||
const [socket, setSocket] = useState<Socket | null>(null)
|
||||
const [roomData, setRoomData] = useState<RoomData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [hasAttemptedFetch, setHasAttemptedFetch] = useState(false)
|
||||
|
||||
// Fetch the user's current room
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
setRoomData(null)
|
||||
setHasAttemptedFetch(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setHasAttemptedFetch(false)
|
||||
|
||||
// Fetch current room data
|
||||
fetch('/api/arcade/rooms/current')
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to fetch current room')
|
||||
return res.json()
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.room) {
|
||||
const roomData = {
|
||||
id: data.room.id,
|
||||
name: data.room.name,
|
||||
code: data.room.code,
|
||||
gameName: data.room.gameName,
|
||||
members: data.members || [],
|
||||
memberPlayers: data.memberPlayers || {},
|
||||
}
|
||||
setRoomData(roomData)
|
||||
} else {
|
||||
setRoomData(null)
|
||||
}
|
||||
setIsLoading(false)
|
||||
setHasAttemptedFetch(true)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[useRoomData] Failed to fetch room data:', error)
|
||||
setRoomData(null)
|
||||
setIsLoading(false)
|
||||
setHasAttemptedFetch(true)
|
||||
})
|
||||
}, [userId])
|
||||
|
||||
// Initialize socket connection when user has a room
|
||||
useEffect(() => {
|
||||
if (!roomData?.id || !userId) {
|
||||
if (socket) {
|
||||
socket.disconnect()
|
||||
setSocket(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const sock = io({ path: '/api/socket' })
|
||||
|
||||
sock.on('connect', () => {
|
||||
// Join the room to receive updates
|
||||
sock.emit('join-room', { roomId: roomData.id, userId })
|
||||
})
|
||||
|
||||
sock.on('disconnect', () => {
|
||||
// Socket disconnected
|
||||
})
|
||||
|
||||
setSocket(sock)
|
||||
|
||||
return () => {
|
||||
if (sock.connected) {
|
||||
// Leave the room before disconnecting
|
||||
sock.emit('leave-room', { roomId: roomData.id, userId })
|
||||
sock.disconnect()
|
||||
}
|
||||
}
|
||||
}, [roomData?.id, userId])
|
||||
|
||||
// Subscribe to real-time updates via socket
|
||||
useEffect(() => {
|
||||
if (!socket || !roomData?.id) return
|
||||
|
||||
const handleRoomJoined = (data: {
|
||||
roomId: string
|
||||
members: RoomMember[]
|
||||
memberPlayers: Record<string, RoomPlayer[]>
|
||||
}) => {
|
||||
if (data.roomId === roomData.id) {
|
||||
setRoomData((prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
members: data.members,
|
||||
memberPlayers: data.memberPlayers,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleMemberJoined = (data: {
|
||||
roomId: string
|
||||
userId: string
|
||||
members: RoomMember[]
|
||||
memberPlayers: Record<string, RoomPlayer[]>
|
||||
}) => {
|
||||
if (data.roomId === roomData.id) {
|
||||
setRoomData((prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
members: data.members,
|
||||
memberPlayers: data.memberPlayers,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleMemberLeft = (data: {
|
||||
roomId: string
|
||||
userId: string
|
||||
members: RoomMember[]
|
||||
memberPlayers: Record<string, RoomPlayer[]>
|
||||
}) => {
|
||||
if (data.roomId === roomData.id) {
|
||||
setRoomData((prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
members: data.members,
|
||||
memberPlayers: data.memberPlayers,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleRoomPlayersUpdated = (data: {
|
||||
roomId: string
|
||||
memberPlayers: Record<string, RoomPlayer[]>
|
||||
}) => {
|
||||
if (data.roomId === roomData.id) {
|
||||
setRoomData((prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
memberPlayers: data.memberPlayers,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('room-joined', handleRoomJoined)
|
||||
socket.on('member-joined', handleMemberJoined)
|
||||
socket.on('member-left', handleMemberLeft)
|
||||
socket.on('room-players-updated', handleRoomPlayersUpdated)
|
||||
|
||||
return () => {
|
||||
socket.off('room-joined', handleRoomJoined)
|
||||
socket.off('member-joined', handleMemberJoined)
|
||||
socket.off('member-left', handleMemberLeft)
|
||||
socket.off('room-players-updated', handleRoomPlayersUpdated)
|
||||
}
|
||||
}, [socket, roomData?.id])
|
||||
|
||||
// Function to notify room members of player updates
|
||||
const notifyRoomOfPlayerUpdate = () => {
|
||||
if (socket && roomData?.id && userId) {
|
||||
console.log('[useRoomData] Notifying room of player update')
|
||||
socket.emit('players-updated', { roomId: roomData.id, userId })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
roomData,
|
||||
// Loading if: userId is pending, currently fetching, or have userId but haven't tried fetching yet
|
||||
isLoading: isUserIdPending || isLoading || (!!userId && !hasAttemptedFetch),
|
||||
isInRoom: !!roomData,
|
||||
notifyRoomOfPlayerUpdate,
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,7 @@ export function useCreatePlayer() {
|
||||
...newPlayer,
|
||||
createdAt: new Date(),
|
||||
isActive: newPlayer.isActive ?? false,
|
||||
userId: 'temp-user', // Temporary userId, will be replaced by server response
|
||||
}
|
||||
queryClient.setQueryData<Player[]>(playerKeys.list(), [
|
||||
...previousPlayers,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
deleteArcadeSession,
|
||||
getArcadeSession,
|
||||
} from '../session-manager'
|
||||
import { createRoom, deleteRoom } from '../room-manager'
|
||||
|
||||
/**
|
||||
* Integration test for the full arcade session flow
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
describe('Arcade Session Integration', () => {
|
||||
const testUserId = 'integration-test-user'
|
||||
const testGuestId = 'integration-test-guest'
|
||||
let testRoomId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test user
|
||||
@@ -27,11 +29,25 @@ describe('Arcade Session Integration', () => {
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
|
||||
// Create test room
|
||||
const room = await createRoom({
|
||||
name: 'Test Room',
|
||||
createdBy: testGuestId,
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6, gameType: 'abacus-numeral', turnTimer: 30 },
|
||||
ttlMinutes: 60,
|
||||
})
|
||||
testRoomId = room.id
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up
|
||||
await deleteArcadeSession(testUserId)
|
||||
await deleteArcadeSession(testGuestId)
|
||||
if (testRoomId) {
|
||||
await deleteRoom(testRoomId)
|
||||
}
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
||||
})
|
||||
|
||||
@@ -45,12 +61,12 @@ describe('Arcade Session Integration', () => {
|
||||
difficulty: 6,
|
||||
turnTimer: 30,
|
||||
gamePhase: 'setup',
|
||||
currentPlayer: 1,
|
||||
currentPlayer: '1',
|
||||
matchedPairs: 0,
|
||||
totalPairs: 6,
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: [1],
|
||||
activePlayers: ['1'],
|
||||
consecutiveMatches: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
@@ -63,11 +79,12 @@ describe('Arcade Session Integration', () => {
|
||||
}
|
||||
|
||||
const session = await createArcadeSession({
|
||||
userId: testUserId,
|
||||
userId: testGuestId,
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState,
|
||||
activePlayers: [1],
|
||||
activePlayers: ['1'],
|
||||
roomId: testRoomId,
|
||||
})
|
||||
|
||||
expect(session).toBeDefined()
|
||||
@@ -86,7 +103,7 @@ describe('Arcade Session Integration', () => {
|
||||
playerId: testUserId,
|
||||
timestamp: Date.now(),
|
||||
data: {
|
||||
activePlayers: [1],
|
||||
activePlayers: ['1'],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -147,12 +164,12 @@ describe('Arcade Session Integration', () => {
|
||||
difficulty: 6,
|
||||
turnTimer: 30,
|
||||
gamePhase: 'playing',
|
||||
currentPlayer: 1,
|
||||
currentPlayer: '1',
|
||||
matchedPairs: 0,
|
||||
totalPairs: 6,
|
||||
moves: 0,
|
||||
scores: { 1: 0 },
|
||||
activePlayers: [1],
|
||||
activePlayers: ['1'],
|
||||
consecutiveMatches: { 1: 0 },
|
||||
gameStartTime: Date.now(),
|
||||
gameEndTime: null,
|
||||
@@ -165,11 +182,12 @@ describe('Arcade Session Integration', () => {
|
||||
}
|
||||
|
||||
await createArcadeSession({
|
||||
userId: testUserId,
|
||||
userId: testGuestId,
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: playingState,
|
||||
activePlayers: [1],
|
||||
activePlayers: ['1'],
|
||||
roomId: testRoomId,
|
||||
})
|
||||
|
||||
// First move: flip card 1
|
||||
|
||||
281
apps/web/src/lib/arcade/__tests__/modal-rooms.test.ts
Normal file
281
apps/web/src/lib/arcade/__tests__/modal-rooms.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db, schema } from '@/db'
|
||||
import { addRoomMember, getRoomMember, getUserRooms } from '../room-membership'
|
||||
import { createRoom, deleteRoom } from '../room-manager'
|
||||
|
||||
/**
|
||||
* Integration tests for modal room enforcement
|
||||
*
|
||||
* Tests the database-level unique constraint combined with application-level
|
||||
* auto-leave logic to ensure users can only be in one room at a time.
|
||||
*/
|
||||
describe('Modal Room Enforcement', () => {
|
||||
const testGuestId1 = 'modal-test-guest-1'
|
||||
const testGuestId2 = 'modal-test-guest-2'
|
||||
const testUserId1 = 'modal-test-user-1'
|
||||
const testUserId2 = 'modal-test-user-2'
|
||||
let room1Id: string
|
||||
let room2Id: string
|
||||
let room3Id: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test users
|
||||
await db
|
||||
.insert(schema.users)
|
||||
.values([
|
||||
{
|
||||
id: testUserId1,
|
||||
guestId: testGuestId1,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: testUserId2,
|
||||
guestId: testGuestId2,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
])
|
||||
.onConflictDoNothing()
|
||||
|
||||
// Create test rooms
|
||||
const room1 = await createRoom({
|
||||
name: 'Modal Test Room 1',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: 'User 1',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6 },
|
||||
ttlMinutes: 60,
|
||||
})
|
||||
room1Id = room1.id
|
||||
|
||||
const room2 = await createRoom({
|
||||
name: 'Modal Test Room 2',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: 'User 1',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 8 },
|
||||
ttlMinutes: 60,
|
||||
})
|
||||
room2Id = room2.id
|
||||
|
||||
const room3 = await createRoom({
|
||||
name: 'Modal Test Room 3',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: 'User 1',
|
||||
gameName: 'memory-quiz',
|
||||
gameConfig: {},
|
||||
ttlMinutes: 60,
|
||||
})
|
||||
room3Id = room3.id
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up
|
||||
await db.delete(schema.roomMembers).where(eq(schema.roomMembers.userId, testGuestId1))
|
||||
await db.delete(schema.roomMembers).where(eq(schema.roomMembers.userId, testGuestId2))
|
||||
|
||||
try {
|
||||
await deleteRoom(room1Id)
|
||||
await deleteRoom(room2Id)
|
||||
await deleteRoom(room3Id)
|
||||
} catch {
|
||||
// Rooms may have been deleted in test
|
||||
}
|
||||
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId1))
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId2))
|
||||
})
|
||||
|
||||
it('should allow user to join their first room', async () => {
|
||||
const result = await addRoomMember({
|
||||
roomId: room1Id,
|
||||
userId: testGuestId1,
|
||||
displayName: 'Test User',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
expect(result.member).toBeDefined()
|
||||
expect(result.member.roomId).toBe(room1Id)
|
||||
expect(result.member.userId).toBe(testGuestId1)
|
||||
expect(result.autoLeaveResult).toBeUndefined()
|
||||
|
||||
const userRooms = await getUserRooms(testGuestId1)
|
||||
expect(userRooms).toHaveLength(1)
|
||||
expect(userRooms[0]).toBe(room1Id)
|
||||
})
|
||||
|
||||
it('should automatically leave previous room when joining new one', async () => {
|
||||
// Join room 1
|
||||
await addRoomMember({
|
||||
roomId: room1Id,
|
||||
userId: testGuestId1,
|
||||
displayName: 'Test User',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
let userRooms = await getUserRooms(testGuestId1)
|
||||
expect(userRooms).toHaveLength(1)
|
||||
expect(userRooms[0]).toBe(room1Id)
|
||||
|
||||
// Join room 2 (should auto-leave room 1)
|
||||
const result = await addRoomMember({
|
||||
roomId: room2Id,
|
||||
userId: testGuestId1,
|
||||
displayName: 'Test User',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
expect(result.autoLeaveResult).toBeDefined()
|
||||
expect(result.autoLeaveResult?.leftRooms).toHaveLength(1)
|
||||
expect(result.autoLeaveResult?.leftRooms[0]).toBe(room1Id)
|
||||
expect(result.autoLeaveResult?.previousRoomMembers).toHaveLength(1)
|
||||
|
||||
userRooms = await getUserRooms(testGuestId1)
|
||||
expect(userRooms).toHaveLength(1)
|
||||
expect(userRooms[0]).toBe(room2Id)
|
||||
|
||||
// Verify user is no longer in room 1
|
||||
const room1Member = await getRoomMember(room1Id, testGuestId1)
|
||||
expect(room1Member).toBeUndefined()
|
||||
|
||||
// Verify user is in room 2
|
||||
const room2Member = await getRoomMember(room2Id, testGuestId1)
|
||||
expect(room2Member).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle rejoining the same room without auto-leave', async () => {
|
||||
// Join room 1
|
||||
const firstJoin = await addRoomMember({
|
||||
roomId: room1Id,
|
||||
userId: testGuestId1,
|
||||
displayName: 'Test User',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
expect(firstJoin.autoLeaveResult).toBeUndefined()
|
||||
|
||||
// "Rejoin" room 1 (should just update status)
|
||||
const secondJoin = await addRoomMember({
|
||||
roomId: room1Id,
|
||||
userId: testGuestId1,
|
||||
displayName: 'Test User Updated',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
expect(secondJoin.autoLeaveResult).toBeUndefined()
|
||||
expect(secondJoin.member.roomId).toBe(room1Id)
|
||||
|
||||
const userRooms = await getUserRooms(testGuestId1)
|
||||
expect(userRooms).toHaveLength(1)
|
||||
expect(userRooms[0]).toBe(room1Id)
|
||||
})
|
||||
|
||||
it('should allow different users in different rooms simultaneously', async () => {
|
||||
// User 1 joins room 1
|
||||
await addRoomMember({
|
||||
roomId: room1Id,
|
||||
userId: testGuestId1,
|
||||
displayName: 'User 1',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// User 2 joins room 2
|
||||
await addRoomMember({
|
||||
roomId: room2Id,
|
||||
userId: testGuestId2,
|
||||
displayName: 'User 2',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
const user1Rooms = await getUserRooms(testGuestId1)
|
||||
const user2Rooms = await getUserRooms(testGuestId2)
|
||||
|
||||
expect(user1Rooms).toHaveLength(1)
|
||||
expect(user1Rooms[0]).toBe(room1Id)
|
||||
|
||||
expect(user2Rooms).toHaveLength(1)
|
||||
expect(user2Rooms[0]).toBe(room2Id)
|
||||
})
|
||||
|
||||
it('should auto-leave when switching between multiple rooms', async () => {
|
||||
// Join room 1
|
||||
await addRoomMember({
|
||||
roomId: room1Id,
|
||||
userId: testGuestId1,
|
||||
displayName: 'Test User',
|
||||
})
|
||||
|
||||
// Join room 2 (auto-leave room 1)
|
||||
const result2 = await addRoomMember({
|
||||
roomId: room2Id,
|
||||
userId: testGuestId1,
|
||||
displayName: 'Test User',
|
||||
})
|
||||
expect(result2.autoLeaveResult?.leftRooms).toContain(room1Id)
|
||||
|
||||
// Join room 3 (auto-leave room 2)
|
||||
const result3 = await addRoomMember({
|
||||
roomId: room3Id,
|
||||
userId: testGuestId1,
|
||||
displayName: 'Test User',
|
||||
})
|
||||
expect(result3.autoLeaveResult?.leftRooms).toContain(room2Id)
|
||||
|
||||
// Verify only in room 3
|
||||
const userRooms = await getUserRooms(testGuestId1)
|
||||
expect(userRooms).toHaveLength(1)
|
||||
expect(userRooms[0]).toBe(room3Id)
|
||||
})
|
||||
|
||||
it('should provide correct auto-leave metadata', async () => {
|
||||
// Join room 1
|
||||
await addRoomMember({
|
||||
roomId: room1Id,
|
||||
userId: testGuestId1,
|
||||
displayName: 'Original Name',
|
||||
})
|
||||
|
||||
// Join room 2 and check metadata
|
||||
const result = await addRoomMember({
|
||||
roomId: room2Id,
|
||||
userId: testGuestId1,
|
||||
displayName: 'New Name',
|
||||
})
|
||||
|
||||
expect(result.autoLeaveResult).toBeDefined()
|
||||
expect(result.autoLeaveResult?.previousRoomMembers).toHaveLength(1)
|
||||
|
||||
const previousMember = result.autoLeaveResult?.previousRoomMembers[0]
|
||||
expect(previousMember?.roomId).toBe(room1Id)
|
||||
expect(previousMember?.member.userId).toBe(testGuestId1)
|
||||
expect(previousMember?.member.displayName).toBe('Original Name')
|
||||
})
|
||||
|
||||
it('should enforce unique constraint at database level', async () => {
|
||||
// This test verifies the database constraint catches issues even if
|
||||
// application logic fails
|
||||
|
||||
// Join room 1
|
||||
await addRoomMember({
|
||||
roomId: room1Id,
|
||||
userId: testGuestId1,
|
||||
displayName: 'Test User',
|
||||
})
|
||||
|
||||
// Try to directly insert a second membership (bypassing auto-leave logic)
|
||||
const directInsert = async () => {
|
||||
await db.insert(schema.roomMembers).values({
|
||||
roomId: room2Id,
|
||||
userId: testGuestId1,
|
||||
displayName: 'Test User',
|
||||
isCreator: false,
|
||||
joinedAt: new Date(),
|
||||
lastSeen: new Date(),
|
||||
isOnline: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Should fail due to unique constraint
|
||||
await expect(directInsert()).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { db, schema } from '@/db'
|
||||
import { createArcadeSession, deleteArcadeSession, getArcadeSession } from '../session-manager'
|
||||
import { createRoom, deleteRoom } from '../room-manager'
|
||||
|
||||
/**
|
||||
* Integration tests for orphaned session cleanup
|
||||
*
|
||||
* These tests ensure that sessions without valid rooms are properly
|
||||
* cleaned up to prevent the bug where users get redirected to
|
||||
* non-existent games when rooms have been TTL deleted.
|
||||
*/
|
||||
describe('Orphaned Session Cleanup', () => {
|
||||
const testUserId = 'orphan-test-user-id'
|
||||
const testGuestId = 'orphan-test-guest-id'
|
||||
let testRoomId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test user
|
||||
await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
id: testUserId,
|
||||
guestId: testGuestId,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
|
||||
// Create test room
|
||||
const room = await createRoom({
|
||||
name: 'Orphan Test Room',
|
||||
createdBy: testGuestId,
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6, gameType: 'abacus-numeral', turnTimer: 30 },
|
||||
ttlMinutes: 60,
|
||||
})
|
||||
testRoomId = room.id
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up
|
||||
await deleteArcadeSession(testGuestId)
|
||||
if (testRoomId) {
|
||||
try {
|
||||
await deleteRoom(testRoomId)
|
||||
} catch {
|
||||
// Room may have been deleted in test
|
||||
}
|
||||
}
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
||||
})
|
||||
|
||||
it('should return undefined when session has no roomId', async () => {
|
||||
// Create a session with a valid room
|
||||
const session = await createArcadeSession({
|
||||
userId: testGuestId,
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: { gamePhase: 'setup' },
|
||||
activePlayers: ['player-1'],
|
||||
roomId: testRoomId,
|
||||
})
|
||||
|
||||
expect(session).toBeDefined()
|
||||
expect(session.roomId).toBe(testRoomId)
|
||||
|
||||
// Manually set roomId to null to simulate orphaned session
|
||||
await db
|
||||
.update(schema.arcadeSessions)
|
||||
.set({ roomId: null })
|
||||
.where(eq(schema.arcadeSessions.userId, testUserId))
|
||||
|
||||
// Getting the session should auto-delete it and return undefined
|
||||
const result = await getArcadeSession(testGuestId)
|
||||
expect(result).toBeUndefined()
|
||||
|
||||
// Verify session was actually deleted
|
||||
const [directCheck] = await db
|
||||
.select()
|
||||
.from(schema.arcadeSessions)
|
||||
.where(eq(schema.arcadeSessions.userId, testUserId))
|
||||
.limit(1)
|
||||
|
||||
expect(directCheck).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined when session room has been deleted', async () => {
|
||||
// Create a session with a valid room
|
||||
const session = await createArcadeSession({
|
||||
userId: testGuestId,
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: { gamePhase: 'setup' },
|
||||
activePlayers: ['player-1'],
|
||||
roomId: testRoomId,
|
||||
})
|
||||
|
||||
expect(session).toBeDefined()
|
||||
expect(session.roomId).toBe(testRoomId)
|
||||
|
||||
// Delete the room (simulating TTL expiration)
|
||||
await deleteRoom(testRoomId)
|
||||
|
||||
// Getting the session should detect missing room and auto-delete
|
||||
const result = await getArcadeSession(testGuestId)
|
||||
expect(result).toBeUndefined()
|
||||
|
||||
// Verify session was actually deleted
|
||||
const [directCheck] = await db
|
||||
.select()
|
||||
.from(schema.arcadeSessions)
|
||||
.where(eq(schema.arcadeSessions.userId, testUserId))
|
||||
.limit(1)
|
||||
|
||||
expect(directCheck).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return valid session when room exists', async () => {
|
||||
// Create a session with a valid room
|
||||
const session = await createArcadeSession({
|
||||
userId: testGuestId,
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: { gamePhase: 'setup' },
|
||||
activePlayers: ['player-1'],
|
||||
roomId: testRoomId,
|
||||
})
|
||||
|
||||
expect(session).toBeDefined()
|
||||
|
||||
// Getting the session should work fine when room exists
|
||||
const result = await getArcadeSession(testGuestId)
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.roomId).toBe(testRoomId)
|
||||
expect(result?.currentGame).toBe('matching')
|
||||
})
|
||||
|
||||
it('should handle multiple getArcadeSession calls idempotently', async () => {
|
||||
// Create a session with a valid room
|
||||
await createArcadeSession({
|
||||
userId: testGuestId,
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: { gamePhase: 'setup' },
|
||||
activePlayers: ['player-1'],
|
||||
roomId: testRoomId,
|
||||
})
|
||||
|
||||
// Delete the room
|
||||
await deleteRoom(testRoomId)
|
||||
|
||||
// Multiple calls should all return undefined and not error
|
||||
const result1 = await getArcadeSession(testGuestId)
|
||||
const result2 = await getArcadeSession(testGuestId)
|
||||
const result3 = await getArcadeSession(testGuestId)
|
||||
|
||||
expect(result1).toBeUndefined()
|
||||
expect(result2).toBeUndefined()
|
||||
expect(result3).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should prevent orphaned sessions from causing redirect loops', async () => {
|
||||
/**
|
||||
* Regression test for the specific bug:
|
||||
* - Room gets TTL deleted
|
||||
* - Session persists with null/invalid roomId
|
||||
* - User visits /arcade
|
||||
* - useArcadeRedirect finds the orphaned session
|
||||
* - User gets redirected to /arcade/matching
|
||||
* - But there's no valid game to play
|
||||
*
|
||||
* Fix: getArcadeSession should auto-delete orphaned sessions
|
||||
*/
|
||||
|
||||
// 1. Create session with room
|
||||
await createArcadeSession({
|
||||
userId: testGuestId,
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: { gamePhase: 'setup' },
|
||||
activePlayers: ['player-1'],
|
||||
roomId: testRoomId,
|
||||
})
|
||||
|
||||
// 2. Room gets TTL deleted
|
||||
await deleteRoom(testRoomId)
|
||||
|
||||
// 3. User's client checks for active session (like useArcadeRedirect does)
|
||||
const activeSession = await getArcadeSession(testGuestId)
|
||||
|
||||
// 4. Should return undefined, preventing redirect
|
||||
expect(activeSession).toBeUndefined()
|
||||
|
||||
// 5. User can now proceed to arcade lobby normally
|
||||
// (no redirect to non-existent game)
|
||||
})
|
||||
})
|
||||
473
apps/web/src/lib/arcade/__tests__/room-manager.test.ts
Normal file
473
apps/web/src/lib/arcade/__tests__/room-manager.test.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { db, type schema } from '@/db'
|
||||
import {
|
||||
cleanupExpiredRooms,
|
||||
createRoom,
|
||||
deleteRoom,
|
||||
getRoomByCode,
|
||||
getRoomById,
|
||||
isRoomCreator,
|
||||
listActiveRooms,
|
||||
touchRoom,
|
||||
updateRoom,
|
||||
type CreateRoomOptions,
|
||||
} from '../room-manager'
|
||||
import * as roomCode from '../room-code'
|
||||
|
||||
// Mock the database
|
||||
vi.mock('@/db', () => ({
|
||||
db: {
|
||||
query: {
|
||||
arcadeRooms: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
schema: {
|
||||
arcadeRooms: {
|
||||
id: 'id',
|
||||
code: 'code',
|
||||
name: 'name',
|
||||
gameName: 'gameName',
|
||||
isLocked: 'isLocked',
|
||||
status: 'status',
|
||||
lastActivity: 'lastActivity',
|
||||
},
|
||||
arcadeSessions: {
|
||||
userId: 'userId',
|
||||
roomId: 'roomId',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock room-code module
|
||||
vi.mock('../room-code', () => ({
|
||||
generateRoomCode: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('Room Manager', () => {
|
||||
const mockRoom: schema.ArcadeRoom = {
|
||||
id: 'room-123',
|
||||
code: 'ABC123',
|
||||
name: 'Test Room',
|
||||
createdBy: 'user-1',
|
||||
creatorName: 'Test User',
|
||||
createdAt: new Date(),
|
||||
lastActivity: new Date(),
|
||||
ttlMinutes: 60,
|
||||
isLocked: false,
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6 },
|
||||
status: 'lobby',
|
||||
currentSessionId: null,
|
||||
totalGamesPlayed: 0,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('createRoom', () => {
|
||||
it('creates a room with generated code', async () => {
|
||||
const options: CreateRoomOptions = {
|
||||
name: 'Test Room',
|
||||
createdBy: 'user-1',
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6 },
|
||||
}
|
||||
|
||||
// Mock code generation
|
||||
vi.mocked(roomCode.generateRoomCode).mockReturnValue('ABC123')
|
||||
|
||||
// Mock code uniqueness check
|
||||
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined)
|
||||
|
||||
// Mock insert
|
||||
const mockInsert = {
|
||||
values: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn().mockResolvedValue([mockRoom]),
|
||||
}
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert as any)
|
||||
|
||||
const room = await createRoom(options)
|
||||
|
||||
expect(room).toEqual(mockRoom)
|
||||
expect(roomCode.generateRoomCode).toHaveBeenCalled()
|
||||
expect(db.insert).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries code generation on collision', async () => {
|
||||
const options: CreateRoomOptions = {
|
||||
name: 'Test Room',
|
||||
createdBy: 'user-1',
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6 },
|
||||
}
|
||||
|
||||
// First code collides, second is unique
|
||||
vi.mocked(roomCode.generateRoomCode)
|
||||
.mockReturnValueOnce('ABC123')
|
||||
.mockReturnValueOnce('XYZ789')
|
||||
|
||||
// First check finds collision, second check is unique
|
||||
vi.mocked(db.query.arcadeRooms.findFirst)
|
||||
.mockResolvedValueOnce(mockRoom) // Collision
|
||||
.mockResolvedValueOnce(undefined) // Unique
|
||||
|
||||
const mockInsert = {
|
||||
values: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn().mockResolvedValue([{ ...mockRoom, code: 'XYZ789' }]),
|
||||
}
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert as any)
|
||||
|
||||
const room = await createRoom(options)
|
||||
|
||||
expect(room.code).toBe('XYZ789')
|
||||
expect(roomCode.generateRoomCode).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('throws error after max collision attempts', async () => {
|
||||
const options: CreateRoomOptions = {
|
||||
name: 'Test Room',
|
||||
createdBy: 'user-1',
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6 },
|
||||
}
|
||||
|
||||
// All codes collide
|
||||
vi.mocked(roomCode.generateRoomCode).mockReturnValue('ABC123')
|
||||
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom)
|
||||
|
||||
await expect(createRoom(options)).rejects.toThrow('Failed to generate unique room code')
|
||||
})
|
||||
|
||||
it('sets default TTL to 60 minutes', async () => {
|
||||
const options: CreateRoomOptions = {
|
||||
name: 'Test Room',
|
||||
createdBy: 'user-1',
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6 },
|
||||
}
|
||||
|
||||
vi.mocked(roomCode.generateRoomCode).mockReturnValue('ABC123')
|
||||
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined)
|
||||
|
||||
const mockInsert = {
|
||||
values: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn().mockResolvedValue([mockRoom]),
|
||||
}
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert as any)
|
||||
|
||||
const room = await createRoom(options)
|
||||
|
||||
expect(room.ttlMinutes).toBe(60)
|
||||
})
|
||||
|
||||
it('respects custom TTL', async () => {
|
||||
const options: CreateRoomOptions = {
|
||||
name: 'Test Room',
|
||||
createdBy: 'user-1',
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6 },
|
||||
ttlMinutes: 120,
|
||||
}
|
||||
|
||||
vi.mocked(roomCode.generateRoomCode).mockReturnValue('ABC123')
|
||||
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined)
|
||||
|
||||
const mockInsert = {
|
||||
values: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn().mockResolvedValue([{ ...mockRoom, ttlMinutes: 120 }]),
|
||||
}
|
||||
vi.mocked(db.insert).mockReturnValue(mockInsert as any)
|
||||
|
||||
const room = await createRoom(options)
|
||||
|
||||
expect(room.ttlMinutes).toBe(120)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRoomById', () => {
|
||||
it('returns room when found', async () => {
|
||||
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom)
|
||||
|
||||
const room = await getRoomById('room-123')
|
||||
|
||||
expect(room).toEqual(mockRoom)
|
||||
expect(db.query.arcadeRooms.findFirst).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns undefined when not found', async () => {
|
||||
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined)
|
||||
|
||||
const room = await getRoomById('nonexistent')
|
||||
|
||||
expect(room).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRoomByCode', () => {
|
||||
it('returns room when found', async () => {
|
||||
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom)
|
||||
|
||||
const room = await getRoomByCode('ABC123')
|
||||
|
||||
expect(room).toEqual(mockRoom)
|
||||
})
|
||||
|
||||
it('converts code to uppercase', async () => {
|
||||
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom)
|
||||
|
||||
await getRoomByCode('abc123')
|
||||
|
||||
// Check that the where clause used uppercase
|
||||
const call = vi.mocked(db.query.arcadeRooms.findFirst).mock.calls[0][0]
|
||||
expect(call).toBeDefined()
|
||||
})
|
||||
|
||||
it('returns undefined when not found', async () => {
|
||||
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined)
|
||||
|
||||
const room = await getRoomByCode('NONEXISTENT')
|
||||
|
||||
expect(room).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateRoom', () => {
|
||||
it('updates room and returns updated data', async () => {
|
||||
const updates = { name: 'Updated Room', isLocked: true }
|
||||
|
||||
const mockUpdate = {
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn().mockResolvedValue([{ ...mockRoom, ...updates }]),
|
||||
}
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate as any)
|
||||
|
||||
const room = await updateRoom('room-123', updates)
|
||||
|
||||
expect(room?.name).toBe('Updated Room')
|
||||
expect(room?.isLocked).toBe(true)
|
||||
expect(db.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates lastActivity timestamp', async () => {
|
||||
const mockUpdate = {
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn().mockResolvedValue([mockRoom]),
|
||||
}
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate as any)
|
||||
|
||||
await updateRoom('room-123', { name: 'Updated' })
|
||||
|
||||
const setCall = mockUpdate.set.mock.calls[0][0]
|
||||
expect(setCall).toHaveProperty('lastActivity')
|
||||
expect(setCall.lastActivity).toBeInstanceOf(Date)
|
||||
})
|
||||
})
|
||||
|
||||
describe('touchRoom', () => {
|
||||
it('updates lastActivity timestamp', async () => {
|
||||
const mockUpdate = {
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
}
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate as any)
|
||||
|
||||
await touchRoom('room-123')
|
||||
|
||||
expect(db.update).toHaveBeenCalled()
|
||||
const setCall = mockUpdate.set.mock.calls[0][0]
|
||||
expect(setCall).toHaveProperty('lastActivity')
|
||||
expect(setCall.lastActivity).toBeInstanceOf(Date)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteRoom', () => {
|
||||
it('deletes room from database', async () => {
|
||||
const mockDelete = {
|
||||
where: vi.fn().mockReturnThis(),
|
||||
}
|
||||
vi.mocked(db.delete).mockReturnValue(mockDelete as any)
|
||||
|
||||
await deleteRoom('room-123')
|
||||
|
||||
expect(db.delete).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('listActiveRooms', () => {
|
||||
const activeRooms = [mockRoom, { ...mockRoom, id: 'room-456', name: 'Another Room' }]
|
||||
|
||||
it('returns active rooms', async () => {
|
||||
vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue(activeRooms)
|
||||
|
||||
const rooms = await listActiveRooms()
|
||||
|
||||
expect(rooms).toEqual(activeRooms)
|
||||
expect(rooms).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('filters by game name when provided', async () => {
|
||||
vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue([mockRoom])
|
||||
|
||||
const rooms = await listActiveRooms('matching')
|
||||
|
||||
expect(rooms).toHaveLength(1)
|
||||
expect(db.query.arcadeRooms.findMany).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('excludes locked rooms', async () => {
|
||||
vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue(activeRooms)
|
||||
|
||||
await listActiveRooms()
|
||||
|
||||
// Verify the where clause excludes locked rooms
|
||||
const call = vi.mocked(db.query.arcadeRooms.findMany).mock.calls[0][0]
|
||||
expect(call).toBeDefined()
|
||||
})
|
||||
|
||||
it('limits results to 50 rooms', async () => {
|
||||
vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue(activeRooms)
|
||||
|
||||
await listActiveRooms()
|
||||
|
||||
const call = vi.mocked(db.query.arcadeRooms.findMany).mock.calls[0][0]
|
||||
expect(call?.limit).toBe(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanupExpiredRooms', () => {
|
||||
it('deletes expired rooms', async () => {
|
||||
const now = new Date()
|
||||
const expiredRoom = {
|
||||
...mockRoom,
|
||||
lastActivity: new Date(now.getTime() - 2 * 60 * 60 * 1000), // 2 hours ago
|
||||
ttlMinutes: 60, // 1 hour TTL = expired
|
||||
}
|
||||
|
||||
vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue([expiredRoom])
|
||||
|
||||
const mockUpdate = {
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
}
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate as any)
|
||||
|
||||
const mockDelete = {
|
||||
where: vi.fn().mockReturnThis(),
|
||||
}
|
||||
vi.mocked(db.delete).mockReturnValue(mockDelete as any)
|
||||
|
||||
const count = await cleanupExpiredRooms()
|
||||
|
||||
expect(count).toBe(1)
|
||||
expect(db.update).toHaveBeenCalled() // Should clear roomId from sessions first
|
||||
expect(db.delete).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not delete active rooms', async () => {
|
||||
const now = new Date()
|
||||
const activeRoom = {
|
||||
...mockRoom,
|
||||
lastActivity: new Date(now.getTime() - 30 * 60 * 1000), // 30 min ago
|
||||
ttlMinutes: 60, // 1 hour TTL = still active
|
||||
}
|
||||
|
||||
vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue([activeRoom])
|
||||
|
||||
const count = await cleanupExpiredRooms()
|
||||
|
||||
expect(count).toBe(0)
|
||||
expect(db.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles mixed expired and active rooms', async () => {
|
||||
const now = new Date()
|
||||
const mockUpdate = {
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
}
|
||||
vi.mocked(db.update).mockReturnValue(mockUpdate as any)
|
||||
|
||||
const rooms = [
|
||||
{
|
||||
...mockRoom,
|
||||
id: 'expired-1',
|
||||
lastActivity: new Date(now.getTime() - 2 * 60 * 60 * 1000),
|
||||
ttlMinutes: 60,
|
||||
},
|
||||
{
|
||||
...mockRoom,
|
||||
id: 'active-1',
|
||||
lastActivity: new Date(now.getTime() - 30 * 60 * 1000),
|
||||
ttlMinutes: 60,
|
||||
},
|
||||
{
|
||||
...mockRoom,
|
||||
id: 'expired-2',
|
||||
lastActivity: new Date(now.getTime() - 3 * 60 * 60 * 1000),
|
||||
ttlMinutes: 120,
|
||||
},
|
||||
]
|
||||
|
||||
vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue(rooms)
|
||||
|
||||
const mockDelete = {
|
||||
where: vi.fn().mockReturnThis(),
|
||||
}
|
||||
vi.mocked(db.delete).mockReturnValue(mockDelete as any)
|
||||
|
||||
const count = await cleanupExpiredRooms()
|
||||
|
||||
expect(count).toBe(2) // Only 2 expired rooms
|
||||
expect(db.delete).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 0 when no rooms exist', async () => {
|
||||
vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue([])
|
||||
|
||||
const count = await cleanupExpiredRooms()
|
||||
|
||||
expect(count).toBe(0)
|
||||
expect(db.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRoomCreator', () => {
|
||||
it('returns true for room creator', async () => {
|
||||
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom)
|
||||
|
||||
const isCreator = await isRoomCreator('room-123', 'user-1')
|
||||
|
||||
expect(isCreator).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for non-creator', async () => {
|
||||
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom)
|
||||
|
||||
const isCreator = await isRoomCreator('room-123', 'user-2')
|
||||
|
||||
expect(isCreator).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when room not found', async () => {
|
||||
vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined)
|
||||
|
||||
const isCreator = await isRoomCreator('nonexistent', 'user-1')
|
||||
|
||||
expect(isCreator).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user