Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d14dd8b47 | ||
|
|
0ee7739091 | ||
|
|
5c135358fc | ||
|
|
74554c3669 | ||
|
|
a89d3a9701 | ||
|
|
180e213d00 | ||
|
|
c33698ce52 | ||
|
|
5b4cb7d35a | ||
|
|
eacbafb1ea | ||
|
|
08fe4326a6 | ||
|
|
fabb33252c | ||
|
|
00dcb872b7 | ||
|
|
ea23651cb6 | ||
|
|
2273c71a87 | ||
|
|
9cb5fdd2fa | ||
|
|
73c54a7ebc | ||
|
|
7cea297095 | ||
|
|
019d36a0ab | ||
|
|
1922b2122b | ||
|
|
3dfe54f1cb | ||
|
|
5f04a3b622 | ||
|
|
05a8e0a842 | ||
|
|
9dac9b7a36 | ||
|
|
b99e754395 | ||
|
|
3eaa84d157 | ||
|
|
51676fc15f | ||
|
|
82ca31029c | ||
|
|
472f201088 | ||
|
|
86b75cba5a | ||
|
|
a93d981d1a | ||
|
|
05bd11a133 | ||
|
|
1cf44696c2 | ||
|
|
297927401c | ||
|
|
b45139b588 | ||
|
|
a57ebdf142 | ||
|
|
98a3a2573d | ||
|
|
0fd680396c | ||
|
|
4afa171af2 | ||
|
|
f37733bff6 | ||
|
|
2ffeade437 | ||
|
|
d8b5201af9 | ||
|
|
554cc4063b | ||
|
|
6bb7016eea | ||
|
|
4124f1cc08 | ||
|
|
ee39241e3c | ||
|
|
f07b96d26e | ||
|
|
a9a6cefafc | ||
|
|
710e93c997 | ||
|
|
b419e5e3ad | ||
|
|
245ed8a625 | ||
|
|
2b68ddc732 | ||
|
|
1c55f3630c | ||
|
|
1e34d57ad6 | ||
|
|
21e6e33173 | ||
|
|
6d16436133 | ||
|
|
6b489238c8 | ||
|
|
8320d9e730 | ||
|
|
a4251e660d | ||
|
|
040d7495a0 | ||
|
|
87ef35682e | ||
|
|
2fb6ead4f2 | ||
|
|
bc571e3d0d | ||
|
|
eed7c9b938 | ||
|
|
654ba19ccc | ||
|
|
f5469cda0c | ||
|
|
86e3d41996 | ||
|
|
cb11bec975 | ||
|
|
2580e474d0 | ||
|
|
55e0be8e42 | ||
|
|
dd9e657db8 | ||
|
|
51d9a37f9b | ||
|
|
07212e4df0 | ||
|
|
97daad9abb | ||
|
|
225104c3a7 | ||
|
|
249257c6c7 | ||
|
|
b37e29e53e | ||
|
|
c6886a0e59 | ||
|
|
cb2fec1da5 | ||
|
|
6beb58a7b8 | ||
|
|
544b06e290 | ||
|
|
a7c3c1f4cd | ||
|
|
090d4dac2b | ||
|
|
f865ce16ec | ||
|
|
50f45ab08e | ||
|
|
a2d53680f2 | ||
|
|
b9e7267f15 | ||
|
|
57bf8460c8 | ||
|
|
059a9fe750 | ||
|
|
036da6de66 | ||
|
|
556e5e4ca0 | ||
|
|
1ddf985938 | ||
|
|
8c851462de | ||
|
|
85b2cf9816 | ||
|
|
4c6eb01f1e | ||
|
|
7d08fdd906 | ||
|
|
0d4f400dca | ||
|
|
396b6c07c7 | ||
|
|
35b4a72c8b | ||
|
|
ba916e0f65 | ||
|
|
e5d0672059 | ||
|
|
5b4c69693d | ||
|
|
f9b0429a2e | ||
|
|
34998d6b27 | ||
|
|
d3e5cdfc54 |
353
CHANGELOG.md
353
CHANGELOG.md
@@ -1,3 +1,356 @@
|
||||
## [3.17.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.7...v3.17.8) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** preserve game settings when returning to game selection ([0ee7739](https://github.com/antialias/soroban-abacus-flashcards/commit/0ee7739091d60580d2f98cfe288b8586b03348f3))
|
||||
|
||||
## [3.17.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.6...v3.17.7) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** prevent gameConfig from being overwritten when switching games ([a89d3a9](https://github.com/antialias/soroban-abacus-flashcards/commit/a89d3a970137471e2652de992c45370dbb97416d))
|
||||
|
||||
## [3.17.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.5...v3.17.6) (2025-10-15)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **logging:** use JSON.stringify for all object logging ([c33698c](https://github.com/antialias/soroban-abacus-flashcards/commit/c33698ce52ebdc18ce3a0d856f9241c7389ed651))
|
||||
|
||||
## [3.17.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.4...v3.17.5) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** implement settings persistence for matching game ([08fe432](https://github.com/antialias/soroban-abacus-flashcards/commit/08fe4326a6a7c484b9058a241f4ff79b3fb5125f))
|
||||
|
||||
## [3.17.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.3...v3.17.4) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **matching:** add settings persistence to matching game ([00dcb87](https://github.com/antialias/soroban-abacus-flashcards/commit/00dcb872b7e70bdb7de301b56fe42195e6ee923f))
|
||||
|
||||
## [3.17.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.2...v3.17.3) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** preserve gameConfig when switching games ([2273c71](https://github.com/antialias/soroban-abacus-flashcards/commit/2273c71a872a5122d0b2023835fe30640106048e))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* remove verbose console logging for cleaner debugging ([9cb5fdd](https://github.com/antialias/soroban-abacus-flashcards/commit/9cb5fdd2fa43560adc32dd052f47a7b06b2c5b69))
|
||||
|
||||
## [3.17.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.1...v3.17.2) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **room-data:** update query cache when gameConfig changes ([7cea297](https://github.com/antialias/soroban-abacus-flashcards/commit/7cea297095b78d74f5b77ca83489ec1be684a486))
|
||||
|
||||
## [3.17.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.0...v3.17.1) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade-rooms:** navigate to invite link after room creation ([1922b21](https://github.com/antialias/soroban-abacus-flashcards/commit/1922b2122bb1bc4aeada7526d8c46aa89024bb00))
|
||||
* **memory-quiz:** scope game settings by game name for proper persistence ([3dfe54f](https://github.com/antialias/soroban-abacus-flashcards/commit/3dfe54f1cb89bd636e763e1c5acb03776f97c011))
|
||||
|
||||
## [3.17.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.16.0...v3.17.0) (2025-10-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **memory-quiz:** persist game settings per-game across sessions ([05a8e0a](https://github.com/antialias/soroban-abacus-flashcards/commit/05a8e0a84272c6c45a4014413ee00726eb88b76a))
|
||||
|
||||
## [3.16.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.15.2...v3.16.0) (2025-10-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **arcade:** broadcast game selection changes to all room members ([b99e754](https://github.com/antialias/soroban-abacus-flashcards/commit/b99e7543952bb0d47f42e79dc4226b3c1280a0ee))
|
||||
|
||||
## [3.15.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.15.1...v3.15.2) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **memory-quiz:** prevent duplicate card processing from optimistic updates ([51676fc](https://github.com/antialias/soroban-abacus-flashcards/commit/51676fc15f5bc15cdb43393d3e66f7c5a0667868))
|
||||
|
||||
## [3.15.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.15.0...v3.15.1) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **memory-quiz:** synchronize card display across all players in multiplayer ([472f201](https://github.com/antialias/soroban-abacus-flashcards/commit/472f201088d82f92030273fadaf8a8e488820d6c))
|
||||
|
||||
## [3.15.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.4...v3.15.0) (2025-10-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **memory-quiz:** add multiplayer support with redesigned scoreboards ([1cf4469](https://github.com/antialias/soroban-abacus-flashcards/commit/1cf44696c26473ce4ab2fc2039ff42f08c20edb6))
|
||||
* **memory-quiz:** show player emojis on cards to indicate who found them ([05bd11a](https://github.com/antialias/soroban-abacus-flashcards/commit/05bd11a133706c9ed8c09c744da7ca8955fa979a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** add defensive checks and update test fixtures ([a93d981](https://github.com/antialias/soroban-abacus-flashcards/commit/a93d981d1ab3abed019b28cebe87525191313cc7))
|
||||
|
||||
## [3.14.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.3...v3.14.4) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **memory-quiz:** prevent input lag during rapid typing in room mode ([b45139b](https://github.com/antialias/soroban-abacus-flashcards/commit/b45139b588d0ab6df4d6c1003c1b65b634e2b041))
|
||||
|
||||
## [3.14.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.2...v3.14.3) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** delete old session when room game changes ([98a3a25](https://github.com/antialias/soroban-abacus-flashcards/commit/98a3a2573db51899c41ba02796895d676c4e16ef))
|
||||
|
||||
## [3.14.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.1...v3.14.2) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **room:** update GAME_TYPE_TO_NAME mapping for memory-quiz ([4afa171](https://github.com/antialias/soroban-abacus-flashcards/commit/4afa171af212902120599b3d68f58cfbdf7820b0))
|
||||
|
||||
## [3.14.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.0...v3.14.1) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve Memory Quiz room-based multiplayer validation issues ([2ffeade](https://github.com/antialias/soroban-abacus-flashcards/commit/2ffeade43710b5f3fff9991cc84763bbdbf97010))
|
||||
|
||||
## [3.14.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.7...v3.14.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **arcade:** add Change Game functionality for room hosts ([ee39241](https://github.com/antialias/soroban-abacus-flashcards/commit/ee39241e3c9e04202592497d9987eafcb89c00c9))
|
||||
* **arcade:** add game selection screen with navigation to room page ([4124f1c](https://github.com/antialias/soroban-abacus-flashcards/commit/4124f1cc081f5cb9d6f450f3c2e0cca8a247deba))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **player-config:** correct label positioning in player settings dialog ([554cc40](https://github.com/antialias/soroban-abacus-flashcards/commit/554cc4063bc756c9c9cd1adf0c1964d3f2f6151b))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* implement in-room game selection UI ([f07b96d](https://github.com/antialias/soroban-abacus-flashcards/commit/f07b96d26eb9f63f3ee55f721139c37ccc34c3df))
|
||||
* make game_name nullable to support in-room game selection ([a9a6cef](https://github.com/antialias/soroban-abacus-flashcards/commit/a9a6cefafcaf7340902328ef1cb02eb3fdd3aa84))
|
||||
* **nav:** rename emphasizeGameContext to emphasizePlayerSelection ([6bb7016](https://github.com/antialias/soroban-abacus-flashcards/commit/6bb7016eea1e8ca40204a921db4a8b8fb9a06f73))
|
||||
|
||||
## [3.13.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.6...v3.13.7) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **toast:** scope animations to prevent affecting other UI elements ([245ed8a](https://github.com/antialias/soroban-abacus-flashcards/commit/245ed8a625ba848f8cd79d51bfd88600cd77f0b9))
|
||||
|
||||
## [3.13.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.5...v3.13.6) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **nav:** navigate to /arcade/room (not /arcade/rooms/{id}) ([1c55f36](https://github.com/antialias/soroban-abacus-flashcards/commit/1c55f3630cb5f07b685555e41baa5a49314f15a3))
|
||||
|
||||
## [3.13.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.4...v3.13.5) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **nav:** navigate to room after creation from (+) menu ([21e6e33](https://github.com/antialias/soroban-abacus-flashcards/commit/21e6e33173e7939102a7e6d6a7bd5168a97a49d6))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add production deployment guide ([6d16436](https://github.com/antialias/soroban-abacus-flashcards/commit/6d164361331fae2135afd84ab6e6f38a241b9170))
|
||||
|
||||
## [3.13.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.3...v3.13.4) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** include members and memberPlayers in room creation response ([8320d9e](https://github.com/antialias/soroban-abacus-flashcards/commit/8320d9e730e2b9964e509847dfa504a78b721b5a))
|
||||
|
||||
## [3.13.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.2...v3.13.3) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **migrations:** add migration 0009 for display_password column ([040d749](https://github.com/antialias/soroban-abacus-flashcards/commit/040d7495a0801076b252d2574023f5323540db1a))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* replace browser alert() calls with toast notifications ([87ef356](https://github.com/antialias/soroban-abacus-flashcards/commit/87ef35682e5c129033f21b91987fc84a45f43ad3))
|
||||
|
||||
## [3.13.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.1...v3.13.2) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** only notify room creator of join requests ([bc571e3](https://github.com/antialias/soroban-abacus-flashcards/commit/bc571e3d0d11fe4142680132d551e25ca626d950))
|
||||
|
||||
## [3.13.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.0...v3.13.1) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** allow room creator to rejoin restricted/approval rooms ([654ba19](https://github.com/antialias/soroban-abacus-flashcards/commit/654ba19ccca595d34ad205c036c18afb99a494c7))
|
||||
|
||||
## [3.13.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.12.0...v3.13.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **moderation:** add inline feedback and persistent password display ([86e3d41](https://github.com/antialias/soroban-abacus-flashcards/commit/86e3d4199628f95048b9265c9de0adfdc2934f93))
|
||||
|
||||
## [3.12.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.11.1...v3.12.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **moderation:** improve password input with copy button ([2580e47](https://github.com/antialias/soroban-abacus-flashcards/commit/2580e474d08bf91477339e998b2c70962a633f41))
|
||||
|
||||
## [3.11.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.11.0...v3.11.1) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **moderation:** improve access mode settings UX ([dd9e657](https://github.com/antialias/soroban-abacus-flashcards/commit/dd9e657db85752b32ff91ae1b33a0bf7a7628e07))
|
||||
|
||||
## [3.11.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.10.0...v3.11.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add name generator button and abacus emoji ([07212e4](https://github.com/antialias/soroban-abacus-flashcards/commit/07212e4df0c7fd4b8cccf935c48b14164df6961d))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* make player names abacus and arithmetic themed ([97daad9](https://github.com/antialias/soroban-abacus-flashcards/commit/97daad9abb40a6f4d59ca8a4d4b671822b7b0955))
|
||||
|
||||
## [3.10.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.2...v3.10.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add fun automatic player naming system ([249257c](https://github.com/antialias/soroban-abacus-flashcards/commit/249257c6c77d503b48479065664c96c5de36a234))
|
||||
|
||||
## [3.9.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.1...v3.9.2) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove duplicate ModerationNotifications causing double toasts ([c6886a0](https://github.com/antialias/soroban-abacus-flashcards/commit/c6886a0e59b3cbf051a828e0157495101cd8c823))
|
||||
|
||||
## [3.9.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.0...v3.9.1) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* reset join request toast state when moderation event cleared ([6beb58a](https://github.com/antialias/soroban-abacus-flashcards/commit/6beb58a7b8f8e1841c71729a3517ab459e924aa9))
|
||||
|
||||
## [3.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.1...v3.9.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* prevent invitations to retired rooms ([a7c3c1f](https://github.com/antialias/soroban-abacus-flashcards/commit/a7c3c1f4cd802985c8f040bc1cdf3ea4482a2fce))
|
||||
|
||||
## [3.8.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.0...v3.8.1) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve kicked modal message for retired room ejections ([f865ce1](https://github.com/antialias/soroban-abacus-flashcards/commit/f865ce16ecf7648e41549795c8137f4fc33e34ac))
|
||||
|
||||
## [3.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.1...v3.8.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement proper retired room behavior with member expulsion ([a2d5368](https://github.com/antialias/soroban-abacus-flashcards/commit/a2d53680f27db04b2cd09973e62a76c5a7d4ce06))
|
||||
|
||||
## [3.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.0...v3.7.1) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve join request approval error handling with actionable messages ([57bf846](https://github.com/antialias/soroban-abacus-flashcards/commit/57bf8460c8ecff374355bfb93f4b06dfbb148273))
|
||||
|
||||
## [3.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.3...v3.7.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add prominent join request approval notifications for room moderators ([036da6d](https://github.com/antialias/soroban-abacus-flashcards/commit/036da6de66ca7d3f459c55df657b04a9e88d9cd3))
|
||||
|
||||
## [3.6.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.2...v3.6.3) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update locked room terminology and allow existing members ([1ddf985](https://github.com/antialias/soroban-abacus-flashcards/commit/1ddf985938d9542fe26e44da58234f3d4e3c9543))
|
||||
|
||||
## [3.6.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.1...v3.6.2) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow join with pending invitation for restricted rooms ([85b2cf9](https://github.com/antialias/soroban-abacus-flashcards/commit/85b2cf98167ccf632ab634a94eb436e1eb584614))
|
||||
|
||||
## [3.6.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.0...v3.6.1) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* join user socket channel to receive approval notifications ([7d08fdd](https://github.com/antialias/soroban-abacus-flashcards/commit/7d08fdd90643920857eda09998ac01afbae74154))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* remove redundant polling from approval notifications ([0d4f400](https://github.com/antialias/soroban-abacus-flashcards/commit/0d4f400dca02ad9497522c24fded8b6d07d85fd2))
|
||||
|
||||
## [3.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.5.0...v3.6.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add socket listener and polling for approval notifications ([35b4a72](https://github.com/antialias/soroban-abacus-flashcards/commit/35b4a72c8b2f80a74b5d2fe02b048d4ec4d1d6f2))
|
||||
|
||||
## [3.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.4.0...v3.5.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* replace access mode dropdown with visual button grid ([e5d0672](https://github.com/antialias/soroban-abacus-flashcards/commit/e5d067205989d7c3105998dcd7d67fd0408f332c))
|
||||
|
||||
## [3.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.3.1...v3.4.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add waiting state for approval requests in JoinRoomModal ([f9b0429](https://github.com/antialias/soroban-abacus-flashcards/commit/f9b0429a2e2d22944acba66009dd87a9d9eb28c2))
|
||||
|
||||
## [3.3.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.3.0...v3.3.1) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add POST handler for join requests API endpoint ([d3e5cdf](https://github.com/antialias/soroban-abacus-flashcards/commit/d3e5cdfc54f2749f27c6f8b8db854a8d0b6029f8))
|
||||
|
||||
## [3.3.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.2.1...v3.3.0) (2025-10-14)
|
||||
|
||||
|
||||
|
||||
@@ -89,3 +89,22 @@ npm run check # Biome check (format + lint + organize imports)
|
||||
---
|
||||
|
||||
**Remember: Always run `npm run pre-commit` before creating commits.**
|
||||
|
||||
## Known Issues
|
||||
|
||||
### @soroban/abacus-react TypeScript Module Resolution
|
||||
|
||||
**Issue:** TypeScript reports that `AbacusReact`, `useAbacusConfig`, and other exports do not exist from the `@soroban/abacus-react` package, even though:
|
||||
- The package builds successfully
|
||||
- The exports are correctly defined in `dist/index.d.ts`
|
||||
- The imports work at runtime
|
||||
- 20+ files across the codebase use these same imports without issue
|
||||
|
||||
**Impact:** `npm run type-check` will report errors for any files importing from `@soroban/abacus-react`.
|
||||
|
||||
**Workaround:** This is a known pre-existing issue. When running pre-commit checks, TypeScript errors related to `@soroban/abacus-react` imports can be ignored. Focus on:
|
||||
- New TypeScript errors in your changed files (excluding @soroban/abacus-react imports)
|
||||
- Format checks
|
||||
- Lint checks
|
||||
|
||||
**Status:** Known issue, does not block development or deployment.
|
||||
|
||||
191
apps/web/.claude/DEPLOYMENT.md
Normal file
191
apps/web/.claude/DEPLOYMENT.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Production Deployment Guide
|
||||
|
||||
This document describes the production deployment infrastructure and procedures for the abaci.one web application.
|
||||
|
||||
## Infrastructure Overview
|
||||
|
||||
### Production Server
|
||||
- **Host**: `nas.home.network` (Synology NAS DS923+)
|
||||
- **Access**: SSH access required
|
||||
- Must be connected to network at **730 N. Oak Park Ave**
|
||||
- Server is not accessible from external networks
|
||||
- **Project Directory**: `/volume1/homes/antialias/projects/abaci.one`
|
||||
|
||||
### Docker Configuration
|
||||
- **Docker binary**: `/usr/local/bin/docker`
|
||||
- **Docker Compose binary**: `/usr/local/bin/docker-compose`
|
||||
- **Container name**: `soroban-abacus-flashcards`
|
||||
- **Image**: `ghcr.io/antialias/soroban-abacus-flashcards:latest`
|
||||
|
||||
### Auto-Deployment
|
||||
- **Watchtower** monitors and auto-updates containers
|
||||
- **Update frequency**: Every **5 minutes**
|
||||
- Watchtower pulls latest images and restarts containers automatically
|
||||
- No manual intervention required for deployments after pushing to main
|
||||
|
||||
## Database Management
|
||||
|
||||
### Location
|
||||
- **Database path**: `data/sqlite.db` (relative to project directory)
|
||||
- **WAL files**: `data/sqlite.db-shm` and `data/sqlite.db-wal`
|
||||
|
||||
### Migrations
|
||||
- **Automatic**: Migrations run on server startup via `server.js`
|
||||
- **Migration folder**: `./drizzle`
|
||||
- **Process**:
|
||||
1. Server starts
|
||||
2. Logs: `🔄 Running database migrations...`
|
||||
3. Drizzle migrator runs all pending migrations
|
||||
4. Logs: `✅ Migrations complete` (on success)
|
||||
5. Logs: `❌ Migration failed: [error]` (on failure, process exits)
|
||||
|
||||
### Nuke and Rebuild Database
|
||||
If you need to completely reset the production database:
|
||||
|
||||
```bash
|
||||
# SSH into the server
|
||||
ssh nas.home.network
|
||||
|
||||
# Navigate to project directory
|
||||
cd /volume1/homes/antialias/projects/abaci.one
|
||||
|
||||
# Stop the container
|
||||
/usr/local/bin/docker-compose down
|
||||
|
||||
# Remove database files
|
||||
rm -f data/sqlite.db data/sqlite.db-shm data/sqlite.db-wal
|
||||
|
||||
# Restart container (migrations will rebuild DB)
|
||||
/usr/local/bin/docker-compose up -d
|
||||
|
||||
# Check logs to verify migration success
|
||||
/usr/local/bin/docker logs soroban-abacus-flashcards | grep -E '(Migration|Starting)'
|
||||
```
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
### GitHub Actions
|
||||
When code is pushed to `main` branch:
|
||||
|
||||
1. **Workflows triggered**:
|
||||
- `Build and Deploy` - Builds Docker image and pushes to GHCR
|
||||
- `Release` - Manages semantic versioning and releases
|
||||
- `Verify Examples` - Runs example tests
|
||||
- `Deploy Storybooks to GitHub Pages` - Publishes Storybook
|
||||
|
||||
2. **Image build**:
|
||||
- Built image is tagged as `latest`
|
||||
- Pushed to GitHub Container Registry (ghcr.io)
|
||||
- Typically completes within 1-2 minutes
|
||||
|
||||
3. **Deployment**:
|
||||
- Watchtower detects new image (within 5 minutes)
|
||||
- Pulls latest image
|
||||
- Recreates and restarts container
|
||||
- Total deployment time: ~5-7 minutes from push to production
|
||||
|
||||
## Manual Deployment Procedures
|
||||
|
||||
### Force Pull Latest Image
|
||||
If you need to immediately deploy without waiting for Watchtower:
|
||||
|
||||
```bash
|
||||
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && /usr/local/bin/docker-compose pull && /usr/local/bin/docker-compose up -d"
|
||||
```
|
||||
|
||||
### Check Container Status
|
||||
```bash
|
||||
ssh nas.home.network "/usr/local/bin/docker ps | grep -E '(soroban|abaci)'"
|
||||
```
|
||||
|
||||
### View Logs
|
||||
```bash
|
||||
# Recent logs
|
||||
ssh nas.home.network "/usr/local/bin/docker logs --tail 100 soroban-abacus-flashcards"
|
||||
|
||||
# Follow logs in real-time
|
||||
ssh nas.home.network "/usr/local/bin/docker logs -f soroban-abacus-flashcards"
|
||||
|
||||
# Search for specific patterns
|
||||
ssh nas.home.network "/usr/local/bin/docker logs soroban-abacus-flashcards" | grep -i "error"
|
||||
```
|
||||
|
||||
### Restart Container
|
||||
```bash
|
||||
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && /usr/local/bin/docker-compose restart"
|
||||
```
|
||||
|
||||
## Deployment Script
|
||||
|
||||
The project includes a deployment script at `nas-deployment/deploy.sh` for manual deployments.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. Migration Failures
|
||||
**Symptom**: Container keeps restarting, logs show migration errors
|
||||
|
||||
**Solution**:
|
||||
1. Check migration files in `drizzle/` directory
|
||||
2. Verify `drizzle/meta/_journal.json` is up to date
|
||||
3. If migrations are corrupted, may need to nuke database (see above)
|
||||
|
||||
#### 2. Container Not Updating
|
||||
**Symptom**: Changes pushed but production still shows old code
|
||||
|
||||
**Possible causes**:
|
||||
- GitHub Actions build failed - check workflow status with `gh run list`
|
||||
- Watchtower not running - check with `docker ps | grep watchtower`
|
||||
- Image not pulled - manually pull with `docker-compose pull`
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Force pull and restart
|
||||
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && /usr/local/bin/docker-compose pull && /usr/local/bin/docker-compose up -d"
|
||||
```
|
||||
|
||||
#### 3. Missing Database Columns
|
||||
**Symptom**: Errors like `SqliteError: no such column: "column_name"`
|
||||
|
||||
**Cause**: Migration not registered or not run
|
||||
|
||||
**Solution**:
|
||||
1. Verify migration exists in `drizzle/` directory
|
||||
2. Check migration is registered in `drizzle/meta/_journal.json`
|
||||
3. If migration is new, restart container to run migrations
|
||||
4. If migration is malformed, fix it and nuke database
|
||||
|
||||
#### 4. API Returns Unexpected Response
|
||||
**Symptom**: Client shows errors but API appears to work
|
||||
|
||||
**Debugging**:
|
||||
1. Test API directly with curl: `curl -X POST 'https://abaci.one/api/arcade/rooms' -H 'Content-Type: application/json' -d '...'`
|
||||
2. Check production logs for errors
|
||||
3. Verify container is running latest image:
|
||||
```bash
|
||||
ssh nas.home.network "/usr/local/bin/docker inspect soroban-abacus-flashcards --format '{{.Created}}'"
|
||||
```
|
||||
4. Compare with commit timestamp: `git log --format="%ci" -1`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Production environment variables are configured in the docker-compose.yml file on the server. Common variables:
|
||||
|
||||
- `NEXT_PUBLIC_URL` - Base URL for the application
|
||||
- `DATABASE_URL` - SQLite database path
|
||||
- Additional variables may be set in `.env.production` or docker-compose.yml
|
||||
|
||||
## Network Configuration
|
||||
|
||||
- **Reverse Proxy**: Traefik
|
||||
- **HTTPS**: Automatic via Traefik with Let's Encrypt
|
||||
- **Domain**: abaci.one
|
||||
- **Exposed Port**: 3000 (internal to Docker network)
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Production database contains user data and should be handled carefully
|
||||
- SSH access is restricted to local network only
|
||||
- Docker container runs with appropriate user permissions
|
||||
- Secrets are managed via environment variables, not committed to repo
|
||||
@@ -57,7 +57,20 @@
|
||||
"Bash(fi)",
|
||||
"Bash(then echo \"TypeScript errors found\")",
|
||||
"Bash(else echo \"✓ No TypeScript errors in join page\")",
|
||||
"Bash(npx @biomejs/biome format:*)"
|
||||
"Bash(npx @biomejs/biome format:*)",
|
||||
"Bash(npx drizzle-kit generate:*)",
|
||||
"Bash(ssh nas.home.network \"docker ps | grep -E ''soroban|abaci|web''\")",
|
||||
"Bash(ssh:*)",
|
||||
"Bash(printf \"\\n\\n\")",
|
||||
"Bash(timeout 10 npx drizzle-kit generate:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(git reset:*)",
|
||||
"Bash(lsof:*)",
|
||||
"Bash(killall:*)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(git restore:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
0
apps/web/data/db.sqlite
Normal file
0
apps/web/data/db.sqlite
Normal file
2
apps/web/drizzle/0009_add_display_password.sql
Normal file
2
apps/web/drizzle/0009_add_display_password.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Add display_password column to arcade_rooms for showing plain text passwords to room owners
|
||||
ALTER TABLE `arcade_rooms` ADD `display_password` text(100);
|
||||
42
apps/web/drizzle/0010_make_game_name_nullable.sql
Normal file
42
apps/web/drizzle/0010_make_game_name_nullable.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- Make game_name and game_config nullable to support game selection in room
|
||||
-- SQLite doesn't support ALTER COLUMN, so we need to recreate the table
|
||||
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
|
||||
-- Create temporary table with correct schema
|
||||
CREATE TABLE `arcade_rooms_new` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`code` text(6) NOT NULL,
|
||||
`name` text(50),
|
||||
`created_by` text NOT NULL,
|
||||
`creator_name` text(50) NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`last_activity` integer NOT NULL,
|
||||
`ttl_minutes` integer DEFAULT 60 NOT NULL,
|
||||
`access_mode` text DEFAULT 'open' NOT NULL,
|
||||
`password` text(255),
|
||||
`display_password` text(100),
|
||||
`game_name` text,
|
||||
`game_config` text,
|
||||
`status` text DEFAULT 'lobby' NOT NULL,
|
||||
`current_session_id` text,
|
||||
`total_games_played` integer DEFAULT 0 NOT NULL
|
||||
);--> statement-breakpoint
|
||||
|
||||
-- Copy all data
|
||||
INSERT INTO `arcade_rooms_new`
|
||||
SELECT `id`, `code`, `name`, `created_by`, `creator_name`, `created_at`,
|
||||
`last_activity`, `ttl_minutes`, `access_mode`, `password`, `display_password`,
|
||||
`game_name`, `game_config`, `status`, `current_session_id`, `total_games_played`
|
||||
FROM `arcade_rooms`;--> statement-breakpoint
|
||||
|
||||
-- Drop old table
|
||||
DROP TABLE `arcade_rooms`;--> statement-breakpoint
|
||||
|
||||
-- Rename new table
|
||||
ALTER TABLE `arcade_rooms_new` RENAME TO `arcade_rooms`;--> statement-breakpoint
|
||||
|
||||
-- Recreate index
|
||||
CREATE UNIQUE INDEX `arcade_rooms_code_unique` ON `arcade_rooms` (`code`);--> statement-breakpoint
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -64,6 +64,20 @@
|
||||
"when": 1760548800000,
|
||||
"tag": "0008_make_room_name_nullable",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1760600000000,
|
||||
"tag": "0009_add_display_password",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1760700000000,
|
||||
"tag": "0010_make_game_name_nullable",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import {
|
||||
createInvitation,
|
||||
declineInvitation,
|
||||
getInvitation,
|
||||
getRoomInvitations,
|
||||
} from '@/lib/arcade/room-invitations'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getRoomById } from '@/lib/arcade/room-manager'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
@@ -35,6 +36,20 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
)
|
||||
}
|
||||
|
||||
// Get room to check access mode
|
||||
const room = await getRoomById(roomId)
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Cannot invite to retired rooms
|
||||
if (room.accessMode === 'retired') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot send invitations to retired rooms' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user is the host
|
||||
const members = await getRoomMembers(roomId)
|
||||
const currentMember = members.find((m) => m.userId === viewerId)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createJoinRequest, getPendingJoinRequests } from '@/lib/arcade/room-join-requests'
|
||||
import { getRoomById } from '@/lib/arcade/room-manager'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getPendingJoinRequests } from '@/lib/arcade/room-join-requests'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
type RouteContext = {
|
||||
@@ -37,3 +39,82 @@ export async function GET(req: NextRequest, context: RouteContext) {
|
||||
return NextResponse.json({ error: 'Failed to get join requests' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/arcade/rooms/:roomId/join-requests
|
||||
* Create a join request for an approval-only 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 to verify it exists
|
||||
const room = await getRoomById(roomId)
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify room is approval-only
|
||||
if (room.accessMode !== 'approval-only') {
|
||||
return NextResponse.json(
|
||||
{ error: 'This room does not require approval to join' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 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 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create join request
|
||||
const request = await createJoinRequest({
|
||||
roomId,
|
||||
userId: viewerId,
|
||||
userName: displayName,
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[Join Requests] Created request for user ${viewerId} (${displayName}) to join room ${roomId}`
|
||||
)
|
||||
|
||||
// Broadcast to the room host (creator) only via socket
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
// Send notification only to the room creator's user channel
|
||||
io.to(`user:${room.createdBy}`).emit('join-request-submitted', {
|
||||
roomId,
|
||||
request: {
|
||||
id: request.id,
|
||||
userId: request.userId,
|
||||
userName: request.userName,
|
||||
createdAt: request.requestedAt,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[Join Requests] Broadcasted join-request-submitted to room creator ${room.createdBy}`
|
||||
)
|
||||
} catch (socketError) {
|
||||
// Log but don't fail the request if socket broadcast fails
|
||||
console.error('[Join Requests] Failed to broadcast join-request-submitted:', socketError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ request }, { status: 201 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create join request:', error)
|
||||
return NextResponse.json({ error: 'Failed to create join request' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
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 { isUserBanned } from '@/lib/arcade/room-moderation'
|
||||
import { getInvitation } from '@/lib/arcade/room-invitations'
|
||||
import { getJoinRequest } from '@/lib/arcade/room-join-requests'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getRoomById, touchRoom } from '@/lib/arcade/room-manager'
|
||||
import { addRoomMember, getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { isUserBanned } from '@/lib/arcade/room-moderation'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
@@ -38,13 +38,32 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
return NextResponse.json({ error: 'You are banned from this room' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if user is already a member (for locked/retired room access)
|
||||
const members = await getRoomMembers(roomId)
|
||||
const isExistingMember = members.some((m) => m.userId === viewerId)
|
||||
const isRoomCreator = room.createdBy === viewerId
|
||||
|
||||
// Validate access mode
|
||||
switch (room.accessMode) {
|
||||
case 'locked':
|
||||
return NextResponse.json({ error: 'This room is locked' }, { status: 403 })
|
||||
// Allow existing members to continue using the room, but block new members
|
||||
if (!isExistingMember) {
|
||||
return NextResponse.json(
|
||||
{ error: 'This room is locked and not accepting new members' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
break
|
||||
|
||||
case 'retired':
|
||||
return NextResponse.json({ error: 'This room has been retired' }, { status: 410 })
|
||||
// Only the room creator can access retired rooms
|
||||
if (!isRoomCreator) {
|
||||
return NextResponse.json(
|
||||
{ error: 'This room has been retired and is only accessible to the owner' },
|
||||
{ status: 410 }
|
||||
)
|
||||
}
|
||||
break
|
||||
|
||||
case 'password': {
|
||||
if (!body.password) {
|
||||
@@ -64,30 +83,34 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
}
|
||||
|
||||
case 'restricted': {
|
||||
// Check for valid pending invitation
|
||||
const invitation = await getInvitation(roomId, viewerId)
|
||||
if (!invitation || invitation.status !== 'pending') {
|
||||
return NextResponse.json(
|
||||
{ error: 'You need a valid invitation to join this room' },
|
||||
{ status: 403 }
|
||||
)
|
||||
// Room creator can always rejoin their own room
|
||||
if (!isRoomCreator) {
|
||||
// Check for valid pending invitation
|
||||
const invitation = await getInvitation(roomId, viewerId)
|
||||
if (!invitation || invitation.status !== 'pending') {
|
||||
return NextResponse.json(
|
||||
{ error: 'You need a valid invitation to join this room' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'approval-only': {
|
||||
// Check for approved join request
|
||||
const joinRequest = await getJoinRequest(roomId, viewerId)
|
||||
if (!joinRequest || joinRequest.status !== 'approved') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Your join request must be approved by the host' },
|
||||
{ status: 403 }
|
||||
)
|
||||
// Room creator can always rejoin their own room without approval
|
||||
if (!isRoomCreator) {
|
||||
// Check for approved join request
|
||||
const joinRequest = await getJoinRequest(roomId, viewerId)
|
||||
if (!joinRequest || joinRequest.status !== 'approved') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Your join request must be approved by the host' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'open':
|
||||
default:
|
||||
// No additional checks needed
|
||||
break
|
||||
|
||||
@@ -42,8 +42,13 @@ export async function GET(_req: NextRequest, context: RouteContext) {
|
||||
// Update room activity when viewing (keeps active rooms fresh)
|
||||
await touchRoom(roomId)
|
||||
|
||||
// Prepare room data - include displayPassword only for room creator
|
||||
const roomData = canModerate
|
||||
? room // Creator gets full room data including displayPassword
|
||||
: { ...room, displayPassword: undefined } // Others don't see displayPassword
|
||||
|
||||
return NextResponse.json({
|
||||
room,
|
||||
room: roomData,
|
||||
members,
|
||||
memberPlayers, // Map of userId -> active Player[] for each member
|
||||
canModerate,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { recordRoomMemberHistory } from '@/lib/arcade/room-member-history'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
@@ -15,6 +18,8 @@ type RouteContext = {
|
||||
* Body:
|
||||
* - accessMode?: 'open' | 'locked' | 'retired' | 'password' | 'restricted' | 'approval-only'
|
||||
* - password?: string (plain text, will be hashed)
|
||||
* - gameName?: 'matching' | 'memory-quiz' | 'complement-race' | null (select game for room)
|
||||
* - gameConfig?: object (game-specific settings)
|
||||
*/
|
||||
export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
@@ -22,6 +27,36 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
console.log(
|
||||
'[Settings API] PATCH request received:',
|
||||
JSON.stringify(
|
||||
{
|
||||
roomId,
|
||||
body,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
// Read current room state from database BEFORE any changes
|
||||
const [currentRoom] = await db
|
||||
.select()
|
||||
.from(schema.arcadeRooms)
|
||||
.where(eq(schema.arcadeRooms.id, roomId))
|
||||
|
||||
console.log(
|
||||
'[Settings API] Current room state in database BEFORE update:',
|
||||
JSON.stringify(
|
||||
{
|
||||
gameName: currentRoom?.gameName,
|
||||
gameConfig: currentRoom?.gameConfig,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
// Check if user is the host
|
||||
const members = await getRoomMembers(roomId)
|
||||
const currentMember = members.find((m) => m.userId === viewerId)
|
||||
@@ -55,6 +90,14 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
)
|
||||
}
|
||||
|
||||
// Validate gameName if provided
|
||||
if (body.gameName !== undefined && body.gameName !== null) {
|
||||
const validGames = ['matching', 'memory-quiz', 'complement-race']
|
||||
if (!validGames.includes(body.gameName)) {
|
||||
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: Record<string, any> = {}
|
||||
|
||||
@@ -66,12 +109,36 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
if (body.password !== undefined) {
|
||||
if (body.password === null || body.password === '') {
|
||||
updateData.password = null // Clear password
|
||||
updateData.displayPassword = null // Also clear display password
|
||||
} else {
|
||||
const hashedPassword = await bcrypt.hash(body.password, 10)
|
||||
updateData.password = hashedPassword
|
||||
updateData.displayPassword = body.password // Store plain text for display
|
||||
}
|
||||
}
|
||||
|
||||
// Update game selection if provided
|
||||
if (body.gameName !== undefined) {
|
||||
updateData.gameName = body.gameName
|
||||
}
|
||||
|
||||
// Update game config if provided
|
||||
if (body.gameConfig !== undefined) {
|
||||
updateData.gameConfig = body.gameConfig
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[Settings API] Update data to be written to database:',
|
||||
JSON.stringify(updateData, null, 2)
|
||||
)
|
||||
|
||||
// If game is being changed (or cleared), delete the existing arcade session
|
||||
// This ensures a fresh session will be created with the new game settings
|
||||
if (body.gameName !== undefined) {
|
||||
console.log(`[Settings API] Deleting existing arcade session for room ${roomId}`)
|
||||
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, roomId))
|
||||
}
|
||||
|
||||
// Update room settings
|
||||
const [updatedRoom] = await db
|
||||
.update(schema.arcadeRooms)
|
||||
@@ -79,6 +146,111 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
.where(eq(schema.arcadeRooms.id, roomId))
|
||||
.returning()
|
||||
|
||||
console.log(
|
||||
'[Settings API] Room state in database AFTER update:',
|
||||
JSON.stringify(
|
||||
{
|
||||
gameName: updatedRoom.gameName,
|
||||
gameConfig: updatedRoom.gameConfig,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
// Broadcast game change to all room members
|
||||
if (body.gameName !== undefined) {
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
console.log(`[Settings API] Broadcasting game change to room ${roomId}: ${body.gameName}`)
|
||||
const broadcastData: {
|
||||
roomId: string
|
||||
gameName: string | null
|
||||
gameConfig?: Record<string, unknown>
|
||||
} = {
|
||||
roomId,
|
||||
gameName: body.gameName,
|
||||
}
|
||||
|
||||
// Only include gameConfig if it was explicitly provided
|
||||
if (body.gameConfig !== undefined) {
|
||||
broadcastData.gameConfig = body.gameConfig
|
||||
}
|
||||
|
||||
io.to(`room:${roomId}`).emit('room-game-changed', broadcastData)
|
||||
} catch (socketError) {
|
||||
console.error('[Settings API] Failed to broadcast game change:', socketError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If setting to retired, expel all non-owner members
|
||||
if (body.accessMode === 'retired') {
|
||||
const nonOwnerMembers = members.filter((m) => !m.isCreator)
|
||||
|
||||
if (nonOwnerMembers.length > 0) {
|
||||
// Remove all non-owner members from the room
|
||||
await db.delete(schema.roomMembers).where(
|
||||
and(
|
||||
eq(schema.roomMembers.roomId, roomId),
|
||||
// Delete all members except the creator
|
||||
eq(schema.roomMembers.isCreator, false)
|
||||
)
|
||||
)
|
||||
|
||||
// Record in history for each expelled member
|
||||
for (const member of nonOwnerMembers) {
|
||||
await recordRoomMemberHistory({
|
||||
roomId,
|
||||
userId: member.userId,
|
||||
displayName: member.displayName,
|
||||
action: 'left',
|
||||
})
|
||||
}
|
||||
|
||||
// Broadcast updates via socket
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
// Get updated member list (should only be the owner now)
|
||||
const updatedMembers = 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
|
||||
}
|
||||
|
||||
// Notify each expelled member
|
||||
for (const member of nonOwnerMembers) {
|
||||
io.to(`user:${member.userId}`).emit('kicked-from-room', {
|
||||
roomId,
|
||||
kickedBy: currentMember.displayName,
|
||||
reason: 'Room has been retired',
|
||||
})
|
||||
}
|
||||
|
||||
// Notify the owner that members were expelled
|
||||
io.to(`room:${roomId}`).emit('member-left', {
|
||||
roomId,
|
||||
userId: nonOwnerMembers.map((m) => m.userId),
|
||||
members: updatedMembers,
|
||||
memberPlayers: memberPlayersObj,
|
||||
reason: 'room-retired',
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[Settings API] Expelled ${nonOwnerMembers.length} members from retired room ${roomId}`
|
||||
)
|
||||
} catch (socketError) {
|
||||
console.error('[Settings API] Failed to broadcast member expulsion:', socketError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ room: updatedRoom }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update room settings:', error)
|
||||
|
||||
@@ -28,6 +28,19 @@ export async function GET() {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[Current Room API] Room data READ from database:',
|
||||
JSON.stringify(
|
||||
{
|
||||
roomId,
|
||||
gameName: room.gameName,
|
||||
gameConfig: room.gameConfig,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
// Get members
|
||||
const members = await getRoomMembers(roomId)
|
||||
|
||||
|
||||
@@ -70,15 +70,12 @@ export async function POST(req: NextRequest) {
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Validate required fields (name is optional, gameName is required)
|
||||
if (!body.gameName) {
|
||||
return NextResponse.json({ error: 'Missing required field: 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 game name if provided (gameName is now optional)
|
||||
if (body.gameName) {
|
||||
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 provided)
|
||||
@@ -120,8 +117,8 @@ export async function POST(req: NextRequest) {
|
||||
name: roomName,
|
||||
createdBy: viewerId,
|
||||
creatorName: displayName,
|
||||
gameName: body.gameName,
|
||||
gameConfig: body.gameConfig || {},
|
||||
gameName: body.gameName || null,
|
||||
gameConfig: body.gameConfig || null,
|
||||
ttlMinutes: body.ttlMinutes,
|
||||
accessMode: body.accessMode,
|
||||
password: body.password,
|
||||
@@ -135,6 +132,16 @@ export async function POST(req: NextRequest) {
|
||||
isCreator: true,
|
||||
})
|
||||
|
||||
// Get members and active players for the response
|
||||
const members = await getRoomMembers(room.id)
|
||||
const memberPlayers = await getRoomActivePlayers(room.id)
|
||||
|
||||
// Convert Map to object for JSON serialization
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
// Generate join URL
|
||||
const baseUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:3000'
|
||||
const joinUrl = `${baseUrl}/arcade/rooms/${room.id}`
|
||||
@@ -142,6 +149,8 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
room,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
joinUrl,
|
||||
},
|
||||
{ status: 201 }
|
||||
|
||||
@@ -48,9 +48,26 @@ describe('PATCH /api/players/[id] - Arcade Session Validation', () => {
|
||||
})
|
||||
|
||||
it('should return 403 when trying to change isActive with active arcade session', async () => {
|
||||
// Create an arcade room first
|
||||
const [room] = await db
|
||||
.insert(schema.arcadeRooms)
|
||||
.values({
|
||||
code: 'TEST01',
|
||||
createdBy: testGuestId,
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: JSON.stringify({
|
||||
difficulty: 6,
|
||||
gameType: 'abacus-numeral',
|
||||
turnTimer: 30,
|
||||
}),
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Create an active arcade session
|
||||
const now = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
roomId: room.id,
|
||||
userId: testGuestId,
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
@@ -117,9 +134,26 @@ describe('PATCH /api/players/[id] - Arcade Session Validation', () => {
|
||||
})
|
||||
|
||||
it('should allow non-isActive changes even with active arcade session', async () => {
|
||||
// Create an arcade room first
|
||||
const [room] = await db
|
||||
.insert(schema.arcadeRooms)
|
||||
.values({
|
||||
code: 'TEST02',
|
||||
createdBy: testGuestId,
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: JSON.stringify({
|
||||
difficulty: 6,
|
||||
gameType: 'abacus-numeral',
|
||||
turnTimer: 30,
|
||||
}),
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Create an active arcade session
|
||||
const now = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
roomId: room.id,
|
||||
userId: testGuestId,
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
@@ -164,9 +198,26 @@ describe('PATCH /api/players/[id] - Arcade Session Validation', () => {
|
||||
})
|
||||
|
||||
it('should allow isActive change after arcade session ends', async () => {
|
||||
// Create an arcade room first
|
||||
const [room] = await db
|
||||
.insert(schema.arcadeRooms)
|
||||
.values({
|
||||
code: 'TEST03',
|
||||
createdBy: testGuestId,
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: JSON.stringify({
|
||||
difficulty: 6,
|
||||
gameType: 'abacus-numeral',
|
||||
turnTimer: 30,
|
||||
}),
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Create an active arcade session
|
||||
const now = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
roomId: room.id,
|
||||
userId: testGuestId,
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
@@ -179,7 +230,7 @@ describe('PATCH /api/players/[id] - Arcade Session Validation', () => {
|
||||
})
|
||||
|
||||
// End the session
|
||||
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testGuestId))
|
||||
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, room.id))
|
||||
|
||||
// Mock request to change isActive
|
||||
const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
|
||||
@@ -212,9 +263,26 @@ describe('PATCH /api/players/[id] - Arcade Session Validation', () => {
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Create an arcade room first
|
||||
const [room] = await db
|
||||
.insert(schema.arcadeRooms)
|
||||
.values({
|
||||
code: 'TEST04',
|
||||
createdBy: testGuestId,
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: JSON.stringify({
|
||||
difficulty: 6,
|
||||
gameType: 'abacus-numeral',
|
||||
turnTimer: 30,
|
||||
}),
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Create arcade session
|
||||
const now2 = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
roomId: room.id,
|
||||
userId: testGuestId,
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { useToast } from '@/components/common/ToastContext'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
|
||||
@@ -40,6 +41,7 @@ interface Player {
|
||||
export default function RoomDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { showError } = useToast()
|
||||
const roomId = params.roomId as string
|
||||
const { data: guestId } = useViewerId()
|
||||
|
||||
@@ -172,7 +174,7 @@ export default function RoomDetailPage() {
|
||||
|
||||
// Handle specific room membership conflict
|
||||
if (errorData.code === 'ROOM_MEMBERSHIP_CONFLICT') {
|
||||
alert(errorData.userMessage || errorData.message)
|
||||
showError('Already in Another Room', errorData.userMessage || errorData.message)
|
||||
// Refresh the page to update room state
|
||||
await fetchRoom()
|
||||
return
|
||||
@@ -193,7 +195,7 @@ export default function RoomDetailPage() {
|
||||
await fetchRoom()
|
||||
} catch (err) {
|
||||
console.error('Failed to join room:', err)
|
||||
alert('Failed to join room')
|
||||
showError('Failed to join room', err instanceof Error ? err.message : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +215,7 @@ export default function RoomDetailPage() {
|
||||
router.push('/arcade')
|
||||
} catch (err) {
|
||||
console.error('Failed to leave room:', err)
|
||||
alert('Failed to leave room')
|
||||
showError('Failed to leave room', err instanceof Error ? err.message : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { useToast } from '@/components/common/ToastContext'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
|
||||
|
||||
@@ -23,6 +24,7 @@ interface Room {
|
||||
|
||||
export default function RoomBrowserPage() {
|
||||
const router = useRouter()
|
||||
const { showError, showInfo } = useToast()
|
||||
const [rooms, setRooms] = useState<Room[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -68,10 +70,10 @@ export default function RoomBrowserPage() {
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
router.push(`/arcade-rooms/${data.room.id}`)
|
||||
router.push(`/join/${data.room.code}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to create room:', err)
|
||||
alert('Failed to create room')
|
||||
showError('Failed to create room', err instanceof Error ? err.message : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +92,7 @@ export default function RoomBrowserPage() {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
alert(errorData.error || 'Failed to join room')
|
||||
showError('Failed to join room', errorData.error)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -99,12 +101,18 @@ export default function RoomBrowserPage() {
|
||||
}
|
||||
|
||||
if (room.accessMode === 'approval-only') {
|
||||
alert('This room requires host approval. Please use the Join Room modal to request access.')
|
||||
showInfo(
|
||||
'Approval Required',
|
||||
'This room requires host approval. Please use the Join Room modal to request access.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (room.accessMode === 'restricted') {
|
||||
alert('This room is invitation-only. Please ask the host for an invitation.')
|
||||
showInfo(
|
||||
'Invitation Only',
|
||||
'This room is invitation-only. Please ask the host for an invitation.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,7 +128,7 @@ export default function RoomBrowserPage() {
|
||||
|
||||
// Handle specific room membership conflict
|
||||
if (errorData.code === 'ROOM_MEMBERSHIP_CONFLICT') {
|
||||
alert(errorData.userMessage || errorData.message)
|
||||
showError('Already in Another Room', errorData.userMessage || errorData.message)
|
||||
// Refresh the page to update room list state
|
||||
await fetchRooms()
|
||||
return
|
||||
@@ -140,7 +148,7 @@ export default function RoomBrowserPage() {
|
||||
router.push(`/arcade-rooms/${room.id}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to join room:', err)
|
||||
alert('Failed to join room')
|
||||
showError('Failed to join room', err instanceof Error ? err.message : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export function MemoryPairsGame() {
|
||||
<PageWithNav
|
||||
navTitle={navTitle}
|
||||
navEmoji={navEmoji}
|
||||
emphasizeGameContext={state.gamePhase === 'setup'}
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
onExitSession={() => {
|
||||
exitSession()
|
||||
router.push('/arcade')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
|
||||
import { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import {
|
||||
buildPlayerMetadata as buildPlayerMetadataUtil,
|
||||
@@ -90,16 +90,19 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
|
||||
|
||||
case 'FLIP_CARD': {
|
||||
// Optimistically flip the card
|
||||
const card = state.gameCards.find((c) => c.id === move.data.cardId)
|
||||
// Defensive check: ensure arrays exist
|
||||
const gameCards = state.gameCards || []
|
||||
const flippedCards = state.flippedCards || []
|
||||
|
||||
const card = gameCards.find((c) => c.id === move.data.cardId)
|
||||
if (!card) return state
|
||||
|
||||
const newFlippedCards = [...state.flippedCards, card]
|
||||
const newFlippedCards = [...flippedCards, card]
|
||||
|
||||
return {
|
||||
...state,
|
||||
flippedCards: newFlippedCards,
|
||||
currentMoveStartTime:
|
||||
state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
|
||||
currentMoveStartTime: flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
|
||||
isProcessingMove: newFlippedCards.length === 2, // Processing if 2 cards flipped
|
||||
showMismatchFeedback: false,
|
||||
}
|
||||
@@ -237,6 +240,7 @@ 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()
|
||||
const { mutate: updateGameConfig } = useUpdateGameConfig()
|
||||
|
||||
// Get active player IDs directly as strings (UUIDs)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
@@ -244,8 +248,77 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
// 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
|
||||
// Track roomData.gameConfig changes
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
'[RoomMemoryPairsProvider] roomData.gameConfig changed:',
|
||||
JSON.stringify(
|
||||
{
|
||||
gameConfig: roomData?.gameConfig,
|
||||
roomId: roomData?.id,
|
||||
gameName: roomData?.gameName,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
}, [roomData?.gameConfig, roomData?.id, roomData?.gameName])
|
||||
|
||||
// Merge saved game config from room with initialState
|
||||
// Settings are scoped by game name to preserve settings when switching games
|
||||
const mergedInitialState = useMemo(() => {
|
||||
const gameConfig = roomData?.gameConfig as Record<string, any> | null | undefined
|
||||
console.log(
|
||||
'[RoomMemoryPairsProvider] Loading settings from database:',
|
||||
JSON.stringify(
|
||||
{
|
||||
gameConfig,
|
||||
roomId: roomData?.id,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
if (!gameConfig) {
|
||||
console.log('[RoomMemoryPairsProvider] No gameConfig, using initialState')
|
||||
return initialState
|
||||
}
|
||||
|
||||
// Get settings for this specific game (matching)
|
||||
const savedConfig = gameConfig.matching as Record<string, any> | null | undefined
|
||||
console.log(
|
||||
'[RoomMemoryPairsProvider] Saved config for matching:',
|
||||
JSON.stringify(savedConfig, null, 2)
|
||||
)
|
||||
|
||||
if (!savedConfig) {
|
||||
console.log('[RoomMemoryPairsProvider] No saved config for matching, using initialState')
|
||||
return initialState
|
||||
}
|
||||
|
||||
const merged = {
|
||||
...initialState,
|
||||
// Restore settings from saved config
|
||||
gameType: savedConfig.gameType ?? initialState.gameType,
|
||||
difficulty: savedConfig.difficulty ?? initialState.difficulty,
|
||||
turnTimer: savedConfig.turnTimer ?? initialState.turnTimer,
|
||||
}
|
||||
console.log(
|
||||
'[RoomMemoryPairsProvider] Merged state:',
|
||||
JSON.stringify(
|
||||
{
|
||||
gameType: merged.gameType,
|
||||
difficulty: merged.difficulty,
|
||||
turnTimer: merged.turnTimer,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
return merged
|
||||
}, [roomData?.gameConfig])
|
||||
|
||||
// Arcade session integration WITH room sync
|
||||
const {
|
||||
@@ -256,39 +329,55 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
} = useArcadeSession<MemoryPairsState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // CRITICAL: Pass roomId for network sync across room members
|
||||
initialState,
|
||||
initialState: mergedInitialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// Detect state corruption/mismatch (e.g., game type mismatch between sessions)
|
||||
const hasStateCorruption =
|
||||
!state.gameCards || !state.flippedCards || !Array.isArray(state.gameCards)
|
||||
|
||||
// Handle mismatch feedback timeout
|
||||
useEffect(() => {
|
||||
if (state.showMismatchFeedback && state.flippedCards.length === 2) {
|
||||
// Defensive check: ensure flippedCards exists
|
||||
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,
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, 1500)
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [state.showMismatchFeedback, state.flippedCards.length, sendMove, state.currentPlayer])
|
||||
}, [
|
||||
state.showMismatchFeedback,
|
||||
state.flippedCards?.length,
|
||||
sendMove,
|
||||
state.currentPlayer,
|
||||
viewerId,
|
||||
])
|
||||
|
||||
// Computed values
|
||||
const isGameActive = state.gamePhase === 'playing'
|
||||
|
||||
const canFlipCard = useCallback(
|
||||
(cardId: string): boolean => {
|
||||
// Defensive check: ensure required state exists
|
||||
const flippedCards = state.flippedCards || []
|
||||
const gameCards = state.gameCards || []
|
||||
|
||||
console.log('[RoomProvider][canFlipCard] Checking card:', {
|
||||
cardId,
|
||||
isGameActive,
|
||||
isProcessingMove: state.isProcessingMove,
|
||||
currentPlayer: state.currentPlayer,
|
||||
hasRoomData: !!roomData,
|
||||
flippedCardsCount: state.flippedCards.length,
|
||||
flippedCardsCount: flippedCards.length,
|
||||
})
|
||||
|
||||
if (!isGameActive || state.isProcessingMove) {
|
||||
@@ -296,20 +385,20 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
return false
|
||||
}
|
||||
|
||||
const card = state.gameCards.find((c) => c.id === cardId)
|
||||
const card = 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)) {
|
||||
if (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) {
|
||||
if (flippedCards.length >= 2) {
|
||||
console.log('[RoomProvider][canFlipCard] Blocked: 2 cards already flipped')
|
||||
return false
|
||||
}
|
||||
@@ -414,13 +503,14 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: firstPlayer,
|
||||
userId: viewerId || '',
|
||||
data: {
|
||||
cards,
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
},
|
||||
})
|
||||
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove])
|
||||
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove, viewerId])
|
||||
|
||||
const flipCard = useCallback(
|
||||
(cardId: string) => {
|
||||
@@ -441,6 +531,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const move = {
|
||||
type: 'FLIP_CARD' as const,
|
||||
playerId: state.currentPlayer, // Use the current player ID from game state (database player ID)
|
||||
userId: viewerId || '',
|
||||
data: { cardId },
|
||||
}
|
||||
console.log('[RoomProvider] Sending FLIP_CARD move via sendMove:', move)
|
||||
@@ -466,49 +557,152 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: firstPlayer,
|
||||
userId: viewerId || '',
|
||||
data: {
|
||||
cards,
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
},
|
||||
})
|
||||
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove])
|
||||
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove, viewerId])
|
||||
|
||||
const setGameType = useCallback(
|
||||
(gameType: typeof state.gameType) => {
|
||||
console.log('[RoomMemoryPairsProvider] setGameType called:', gameType)
|
||||
|
||||
// Use first active player as playerId, or empty string if none
|
||||
const playerId = activePlayers[0] || ''
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId,
|
||||
userId: viewerId || '',
|
||||
data: { field: 'gameType', value: gameType },
|
||||
})
|
||||
|
||||
// Save setting to room's gameConfig for persistence
|
||||
if (roomData?.id) {
|
||||
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
|
||||
const currentMatchingConfig = (currentGameConfig.matching as Record<string, any>) || {}
|
||||
|
||||
const updatedConfig = {
|
||||
...currentGameConfig,
|
||||
matching: {
|
||||
...currentMatchingConfig,
|
||||
gameType,
|
||||
},
|
||||
}
|
||||
console.log(
|
||||
'[RoomMemoryPairsProvider] Saving gameType to database:',
|
||||
JSON.stringify(
|
||||
{
|
||||
roomId: roomData.id,
|
||||
updatedConfig,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: updatedConfig,
|
||||
})
|
||||
} else {
|
||||
console.warn('[RoomMemoryPairsProvider] Cannot save gameType - no roomData.id')
|
||||
}
|
||||
},
|
||||
[activePlayers, sendMove]
|
||||
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
|
||||
)
|
||||
|
||||
const setDifficulty = useCallback(
|
||||
(difficulty: typeof state.difficulty) => {
|
||||
console.log('[RoomMemoryPairsProvider] setDifficulty called:', difficulty)
|
||||
|
||||
const playerId = activePlayers[0] || ''
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId,
|
||||
userId: viewerId || '',
|
||||
data: { field: 'difficulty', value: difficulty },
|
||||
})
|
||||
|
||||
// Save setting to room's gameConfig for persistence
|
||||
if (roomData?.id) {
|
||||
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
|
||||
const currentMatchingConfig = (currentGameConfig.matching as Record<string, any>) || {}
|
||||
|
||||
const updatedConfig = {
|
||||
...currentGameConfig,
|
||||
matching: {
|
||||
...currentMatchingConfig,
|
||||
difficulty,
|
||||
},
|
||||
}
|
||||
console.log(
|
||||
'[RoomMemoryPairsProvider] Saving difficulty to database:',
|
||||
JSON.stringify(
|
||||
{
|
||||
roomId: roomData.id,
|
||||
updatedConfig,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: updatedConfig,
|
||||
})
|
||||
} else {
|
||||
console.warn('[RoomMemoryPairsProvider] Cannot save difficulty - no roomData.id')
|
||||
}
|
||||
},
|
||||
[activePlayers, sendMove]
|
||||
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
|
||||
)
|
||||
|
||||
const setTurnTimer = useCallback(
|
||||
(turnTimer: typeof state.turnTimer) => {
|
||||
console.log('[RoomMemoryPairsProvider] setTurnTimer called:', turnTimer)
|
||||
|
||||
const playerId = activePlayers[0] || ''
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId,
|
||||
userId: viewerId || '',
|
||||
data: { field: 'turnTimer', value: turnTimer },
|
||||
})
|
||||
|
||||
// Save setting to room's gameConfig for persistence
|
||||
if (roomData?.id) {
|
||||
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
|
||||
const currentMatchingConfig = (currentGameConfig.matching as Record<string, any>) || {}
|
||||
|
||||
const updatedConfig = {
|
||||
...currentGameConfig,
|
||||
matching: {
|
||||
...currentMatchingConfig,
|
||||
turnTimer,
|
||||
},
|
||||
}
|
||||
console.log(
|
||||
'[RoomMemoryPairsProvider] Saving turnTimer to database:',
|
||||
JSON.stringify(
|
||||
{
|
||||
roomId: roomData.id,
|
||||
updatedConfig,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: updatedConfig,
|
||||
})
|
||||
} else {
|
||||
console.warn('[RoomMemoryPairsProvider] Cannot save turnTimer - no roomData.id')
|
||||
}
|
||||
},
|
||||
[activePlayers, sendMove]
|
||||
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
|
||||
)
|
||||
|
||||
const goToSetup = useCallback(() => {
|
||||
@@ -517,9 +711,10 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
sendMove({
|
||||
type: 'GO_TO_SETUP',
|
||||
playerId,
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [activePlayers, state.currentPlayer, sendMove])
|
||||
}, [activePlayers, state.currentPlayer, sendMove, viewerId])
|
||||
|
||||
const resumeGame = useCallback(() => {
|
||||
// PAUSE/RESUME: Resume paused game if config unchanged
|
||||
@@ -532,9 +727,10 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
sendMove({
|
||||
type: 'RESUME_GAME',
|
||||
playerId,
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [canResumeGame, activePlayers, state.currentPlayer, sendMove])
|
||||
}, [canResumeGame, activePlayers, state.currentPlayer, sendMove, viewerId])
|
||||
|
||||
const hoverCard = useCallback(
|
||||
(cardId: string | null) => {
|
||||
@@ -546,10 +742,11 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
sendMove({
|
||||
type: 'HOVER_CARD',
|
||||
playerId,
|
||||
userId: viewerId || '',
|
||||
data: { cardId },
|
||||
})
|
||||
},
|
||||
[state.currentPlayer, activePlayers, sendMove]
|
||||
[state.currentPlayer, activePlayers, sendMove, viewerId]
|
||||
)
|
||||
|
||||
// NO MORE effectiveState merging! Just use session state directly with gameMode added
|
||||
@@ -557,6 +754,100 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
gameMode: GameMode
|
||||
}
|
||||
|
||||
// If state is corrupted, show error message instead of crashing
|
||||
if (hasStateCorruption) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
minHeight: '400px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '48px',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
⚠️
|
||||
</div>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '12px',
|
||||
color: '#dc2626',
|
||||
}}
|
||||
>
|
||||
Game State Mismatch
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '24px',
|
||||
maxWidth: '500px',
|
||||
}}
|
||||
>
|
||||
There's a mismatch between game types in this room. This usually happens when room members
|
||||
are playing different games.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
background: '#f9fafb',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
maxWidth: '500px',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
To fix this:
|
||||
</p>
|
||||
<ol
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
textAlign: 'left',
|
||||
paddingLeft: '20px',
|
||||
lineHeight: '1.6',
|
||||
}}
|
||||
>
|
||||
<li>Make sure all room members are on the same game page</li>
|
||||
<li>Try refreshing the page</li>
|
||||
<li>If the issue persists, leave and rejoin the room</li>
|
||||
</ol>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const contextValue: MemoryPairsContextValue = {
|
||||
state: effectiveState,
|
||||
dispatch: () => {
|
||||
|
||||
197
apps/web/src/app/arcade/memory-quiz/components/CardGrid.tsx
Normal file
197
apps/web/src/app/arcade/memory-quiz/components/CardGrid.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import type { SorobanQuizState } from '../types'
|
||||
|
||||
interface CardGridProps {
|
||||
state: SorobanQuizState
|
||||
}
|
||||
|
||||
export function CardGrid({ state }: CardGridProps) {
|
||||
if (state.quizCards.length === 0) return null
|
||||
|
||||
// Calculate optimal grid layout based on number of cards
|
||||
const cardCount = state.quizCards.length
|
||||
|
||||
// Define static grid classes that Panda can generate
|
||||
const getGridClass = (count: number) => {
|
||||
if (count <= 2) return 'repeat(2, 1fr)'
|
||||
if (count <= 4) return 'repeat(2, 1fr)'
|
||||
if (count <= 6) return 'repeat(3, 1fr)'
|
||||
if (count <= 9) return 'repeat(3, 1fr)'
|
||||
if (count <= 12) return 'repeat(4, 1fr)'
|
||||
return 'repeat(5, 1fr)'
|
||||
}
|
||||
|
||||
const getCardSize = (count: number) => {
|
||||
if (count <= 2) return { minSize: '180px', cardHeight: '160px' }
|
||||
if (count <= 4) return { minSize: '160px', cardHeight: '150px' }
|
||||
if (count <= 6) return { minSize: '140px', cardHeight: '140px' }
|
||||
if (count <= 9) return { minSize: '120px', cardHeight: '130px' }
|
||||
if (count <= 12) return { minSize: '110px', cardHeight: '120px' }
|
||||
return { minSize: '100px', cardHeight: '110px' }
|
||||
}
|
||||
|
||||
const gridClass = getGridClass(cardCount)
|
||||
const cardSize = getCardSize(cardCount)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
padding: '12px',
|
||||
background: '#f9fafb',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
maxHeight: '50vh',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<h4
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
color: '#374151',
|
||||
marginBottom: '12px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
Cards you saw ({cardCount}):
|
||||
</h4>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: '8px',
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
width: 'fit-content',
|
||||
gridTemplateColumns: gridClass,
|
||||
}}
|
||||
>
|
||||
{state.quizCards.map((card, index) => {
|
||||
const isRevealed = state.foundNumbers.includes(card.number)
|
||||
return (
|
||||
<div
|
||||
key={`card-${index}-${card.number}`}
|
||||
style={{
|
||||
perspective: '1000px',
|
||||
maxWidth: '200px',
|
||||
height: cardSize.cardHeight,
|
||||
minWidth: cardSize.minSize,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
textAlign: 'center',
|
||||
transition: 'transform 0.8s',
|
||||
transformStyle: 'preserve-3d',
|
||||
transform: isRevealed ? 'rotateY(180deg)' : 'rotateY(0deg)',
|
||||
}}
|
||||
>
|
||||
{/* Card back (hidden state) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backfaceVisibility: 'hidden',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.1)',
|
||||
background: 'linear-gradient(135deg, #6c5ce7, #a29bfe)',
|
||||
color: 'white',
|
||||
fontSize: '32px',
|
||||
fontWeight: 'bold',
|
||||
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.3)',
|
||||
border: '2px solid #5f3dc4',
|
||||
}}
|
||||
>
|
||||
<div style={{ opacity: 0.8 }}>?</div>
|
||||
</div>
|
||||
|
||||
{/* Card front (revealed state) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backfaceVisibility: 'hidden',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.1)',
|
||||
background: 'white',
|
||||
border: '2px solid #28a745',
|
||||
transform: 'rotateY(180deg)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
padding: '4px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AbacusReact
|
||||
value={card.number}
|
||||
columns="auto"
|
||||
beadShape="diamond"
|
||||
colorScheme="place-value"
|
||||
hideInactiveBeads={false}
|
||||
scaleFactor={1.2}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
animated={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Summary row for large numbers of cards */}
|
||||
{cardCount > 8 && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
padding: '6px 8px',
|
||||
background: '#eff6ff',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #bfdbfe',
|
||||
textAlign: 'center',
|
||||
fontSize: '12px',
|
||||
color: '#1d4ed8',
|
||||
}}
|
||||
>
|
||||
<strong>{state.foundNumbers.length}</strong> of <strong>{cardCount}</strong> cards found
|
||||
{state.foundNumbers.length > 0 && (
|
||||
<span style={{ marginLeft: '6px', fontWeight: 'normal' }}>
|
||||
({Math.round((state.foundNumbers.length / cardCount) * 100)}% complete)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
244
apps/web/src/app/arcade/memory-quiz/components/DisplayPhase.tsx
Normal file
244
apps/web/src/app/arcade/memory-quiz/components/DisplayPhase.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useMemoryQuiz } from '../context/MemoryQuizContext'
|
||||
import type { QuizCard } from '../types'
|
||||
|
||||
// Calculate maximum columns needed for a set of numbers
|
||||
function calculateMaxColumns(numbers: number[]): number {
|
||||
if (numbers.length === 0) return 1
|
||||
const maxNumber = Math.max(...numbers)
|
||||
if (maxNumber === 0) return 1
|
||||
return Math.floor(Math.log10(maxNumber)) + 1
|
||||
}
|
||||
|
||||
export function DisplayPhase() {
|
||||
const { state, nextCard, showInputPhase, resetGame, isRoomCreator } = useMemoryQuiz()
|
||||
const [currentCard, setCurrentCard] = useState<QuizCard | null>(null)
|
||||
const [isTransitioning, setIsTransitioning] = useState(false)
|
||||
const isDisplayPhaseActive = state.currentCardIndex < state.quizCards.length
|
||||
const isProcessingRef = useRef(false)
|
||||
const lastProcessedIndexRef = useRef(-1)
|
||||
const appConfig = useAbacusConfig()
|
||||
|
||||
// In multiplayer room mode, only the room creator controls card timing
|
||||
// In local mode (isRoomCreator === undefined), allow timing control
|
||||
const shouldControlTiming = isRoomCreator === undefined || isRoomCreator === true
|
||||
|
||||
// Calculate maximum columns needed for this quiz set
|
||||
const maxColumns = useMemo(() => {
|
||||
const allNumbers = state.quizCards.map((card) => card.number)
|
||||
return calculateMaxColumns(allNumbers)
|
||||
}, [state.quizCards])
|
||||
|
||||
// Calculate adaptive animation duration
|
||||
const flashDuration = useMemo(() => {
|
||||
const displayTimeMs = state.displayTime * 1000
|
||||
return Math.min(Math.max(displayTimeMs * 0.3, 150), 600) / 1000 // Convert to seconds for CSS
|
||||
}, [state.displayTime])
|
||||
|
||||
const progressPercentage = (state.currentCardIndex / state.quizCards.length) * 100
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent processing the same card index multiple times
|
||||
// This prevents race conditions from optimistic updates
|
||||
if (state.currentCardIndex === lastProcessedIndexRef.current) {
|
||||
console.log(
|
||||
`DisplayPhase: Skipping duplicate processing of index ${state.currentCardIndex} (lastProcessed: ${lastProcessedIndexRef.current})`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (state.currentCardIndex >= state.quizCards.length) {
|
||||
// Only the room creator (or local mode) triggers phase transitions
|
||||
if (shouldControlTiming) {
|
||||
console.log(
|
||||
`DisplayPhase: All cards shown (${state.quizCards.length}), transitioning to input phase`
|
||||
)
|
||||
showInputPhase?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent multiple concurrent executions
|
||||
if (isProcessingRef.current) {
|
||||
console.log(
|
||||
`DisplayPhase: Already processing, skipping (index: ${state.currentCardIndex}, lastProcessed: ${lastProcessedIndexRef.current})`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Mark this index as being processed
|
||||
lastProcessedIndexRef.current = state.currentCardIndex
|
||||
|
||||
const showNextCard = async () => {
|
||||
isProcessingRef.current = true
|
||||
const card = state.quizCards[state.currentCardIndex]
|
||||
console.log(
|
||||
`DisplayPhase: Showing card ${state.currentCardIndex + 1}/${state.quizCards.length}, number: ${card.number} (isRoomCreator: ${isRoomCreator}, shouldControlTiming: ${shouldControlTiming})`
|
||||
)
|
||||
|
||||
// Calculate adaptive timing based on display speed
|
||||
const displayTimeMs = state.displayTime * 1000
|
||||
const flashDuration = Math.min(Math.max(displayTimeMs * 0.3, 150), 600) // 30% of display time, between 150ms-600ms
|
||||
const transitionPause = Math.min(Math.max(displayTimeMs * 0.1, 50), 200) // 10% of display time, between 50ms-200ms
|
||||
|
||||
// Trigger adaptive transition effect
|
||||
setIsTransitioning(true)
|
||||
setCurrentCard(card)
|
||||
|
||||
// Reset transition effect with adaptive duration
|
||||
setTimeout(() => setIsTransitioning(false), flashDuration)
|
||||
|
||||
console.log(
|
||||
`DisplayPhase: Card ${state.currentCardIndex + 1} now visible (flash: ${flashDuration}ms, pause: ${transitionPause}ms)`
|
||||
)
|
||||
|
||||
// Only the room creator (or local mode) controls the timing
|
||||
if (shouldControlTiming) {
|
||||
// Display card for specified time with adaptive transition pause
|
||||
await new Promise((resolve) => setTimeout(resolve, displayTimeMs - transitionPause))
|
||||
|
||||
// Don't hide the abacus - just advance to next card for smooth transition
|
||||
console.log(
|
||||
`DisplayPhase: Card ${state.currentCardIndex + 1} transitioning to next (controlled by ${isRoomCreator === undefined ? 'local mode' : 'room creator'})`
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, transitionPause)) // Adaptive pause for visual transition
|
||||
|
||||
isProcessingRef.current = false
|
||||
nextCard?.()
|
||||
} else {
|
||||
// Non-creator players just display the card, don't control timing
|
||||
console.log(
|
||||
`DisplayPhase: Non-creator player displaying card ${state.currentCardIndex + 1}, waiting for creator to advance`
|
||||
)
|
||||
isProcessingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
showNextCard()
|
||||
}, [
|
||||
state.currentCardIndex,
|
||||
state.displayTime,
|
||||
state.quizCards.length,
|
||||
nextCard,
|
||||
showInputPhase,
|
||||
shouldControlTiming,
|
||||
isRoomCreator,
|
||||
])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
boxSizing: 'border-box',
|
||||
height: '100%',
|
||||
animation: isTransitioning ? `subtlePageFlash ${flashDuration}s ease-out` : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxWidth: '800px',
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '8px',
|
||||
background: '#e5e7eb',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, #28a745, #20c997)',
|
||||
borderRadius: '4px',
|
||||
width: `${progressPercentage}%`,
|
||||
transition: 'width 0.5s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#374151',
|
||||
}}
|
||||
>
|
||||
Card {state.currentCardIndex + 1} of {state.quizCards.length}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
style={{
|
||||
background: '#ef4444',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s ease',
|
||||
}}
|
||||
onClick={() => resetGame?.()}
|
||||
>
|
||||
End Quiz
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Persistent abacus container - stays mounted during entire memorize phase */}
|
||||
<div
|
||||
style={{
|
||||
width: 'min(90vw, 800px)',
|
||||
height: 'min(70vh, 500px)',
|
||||
display: isDisplayPhaseActive ? 'flex' : 'none',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto',
|
||||
transition: 'opacity 0.3s ease',
|
||||
overflow: 'visible',
|
||||
padding: '20px 12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '20px',
|
||||
}}
|
||||
>
|
||||
{/* Persistent abacus with smooth bead animations and dynamically calculated columns */}
|
||||
<AbacusReact
|
||||
value={currentCard?.number || 0}
|
||||
columns={maxColumns}
|
||||
beadShape={appConfig.beadShape}
|
||||
colorScheme={appConfig.colorScheme}
|
||||
hideInactiveBeads={appConfig.hideInactiveBeads}
|
||||
scaleFactor={5.5}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
animated={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
848
apps/web/src/app/arcade/memory-quiz/components/InputPhase.tsx
Normal file
848
apps/web/src/app/arcade/memory-quiz/components/InputPhase.tsx
Normal file
@@ -0,0 +1,848 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { isPrefix } from '@/lib/memory-quiz-utils'
|
||||
import { useMemoryQuiz } from '../context/MemoryQuizContext'
|
||||
import { CardGrid } from './CardGrid'
|
||||
|
||||
export function InputPhase() {
|
||||
const { state, dispatch, acceptNumber, rejectNumber, setInput, showResults } = useMemoryQuiz()
|
||||
const [displayFeedback, setDisplayFeedback] = useState<'neutral' | 'correct' | 'incorrect'>(
|
||||
'neutral'
|
||||
)
|
||||
|
||||
// Use keyboard state from parent state instead of local state
|
||||
const { hasPhysicalKeyboard, testingMode, showOnScreenKeyboard } = state
|
||||
|
||||
// Debug: Log state changes and detect what's causing re-renders
|
||||
useEffect(() => {
|
||||
console.log('🔍 Keyboard state changed:', {
|
||||
hasPhysicalKeyboard,
|
||||
testingMode,
|
||||
showOnScreenKeyboard,
|
||||
})
|
||||
console.trace('🔍 State change trace:')
|
||||
}, [hasPhysicalKeyboard, testingMode, showOnScreenKeyboard])
|
||||
|
||||
// Debug: Monitor for unexpected state resets
|
||||
useEffect(() => {
|
||||
if (showOnScreenKeyboard) {
|
||||
const timer = setTimeout(() => {
|
||||
if (!showOnScreenKeyboard) {
|
||||
console.error('🚨 Keyboard was unexpectedly hidden!')
|
||||
}
|
||||
}, 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [showOnScreenKeyboard])
|
||||
|
||||
// Detect physical keyboard availability (disabled when testing mode is active)
|
||||
useEffect(() => {
|
||||
// Skip keyboard detection entirely when testing mode is enabled
|
||||
if (testingMode) {
|
||||
console.log('🧪 Testing mode enabled - skipping keyboard detection')
|
||||
return
|
||||
}
|
||||
|
||||
let detectionTimer: NodeJS.Timeout | null = null
|
||||
|
||||
const detectKeyboard = () => {
|
||||
// Method 1: Check if device supports keyboard via media queries
|
||||
const hasKeyboardSupport =
|
||||
window.matchMedia('(pointer: fine)').matches && window.matchMedia('(hover: hover)').matches
|
||||
|
||||
// Method 2: Check if device is likely touch-only
|
||||
const isTouchDevice =
|
||||
'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|
||||
|
||||
// Method 3: Check viewport characteristics for mobile devices
|
||||
const isMobileViewport = window.innerWidth <= 768 && window.innerHeight <= 1024
|
||||
|
||||
// Combined heuristic: assume no physical keyboard if:
|
||||
// - It's a touch device AND has mobile viewport AND lacks precise pointer
|
||||
const likelyNoKeyboard = isTouchDevice && isMobileViewport && !hasKeyboardSupport
|
||||
|
||||
console.log('⌨️ Keyboard detection result:', !likelyNoKeyboard)
|
||||
dispatch({
|
||||
type: 'SET_PHYSICAL_KEYBOARD',
|
||||
hasKeyboard: !likelyNoKeyboard,
|
||||
})
|
||||
}
|
||||
|
||||
// Test for actual keyboard input within 3 seconds
|
||||
let keyboardDetected = false
|
||||
const handleFirstKeyPress = (e: KeyboardEvent) => {
|
||||
if (/^[0-9]$/.test(e.key)) {
|
||||
console.log('⌨️ Physical keyboard detected via keypress')
|
||||
keyboardDetected = true
|
||||
dispatch({ type: 'SET_PHYSICAL_KEYBOARD', hasKeyboard: true })
|
||||
document.removeEventListener('keypress', handleFirstKeyPress)
|
||||
if (detectionTimer) clearTimeout(detectionTimer)
|
||||
}
|
||||
}
|
||||
|
||||
// Start detection
|
||||
document.addEventListener('keypress', handleFirstKeyPress)
|
||||
|
||||
// Fallback to heuristic detection after 3 seconds
|
||||
detectionTimer = setTimeout(() => {
|
||||
if (!keyboardDetected) {
|
||||
console.log('⌨️ Using fallback keyboard detection')
|
||||
detectKeyboard()
|
||||
}
|
||||
document.removeEventListener('keypress', handleFirstKeyPress)
|
||||
}, 3000)
|
||||
|
||||
// Initial heuristic detection (but don't commit to it yet)
|
||||
const initialDetection = setTimeout(detectKeyboard, 100)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keypress', handleFirstKeyPress)
|
||||
if (detectionTimer) clearTimeout(detectionTimer)
|
||||
clearTimeout(initialDetection)
|
||||
}
|
||||
}, [testingMode, dispatch])
|
||||
|
||||
const acceptCorrectNumber = useCallback(
|
||||
(number: number) => {
|
||||
acceptNumber?.(number)
|
||||
// setInput('') is called inside acceptNumber action creator
|
||||
setDisplayFeedback('correct')
|
||||
|
||||
setTimeout(() => setDisplayFeedback('neutral'), 500)
|
||||
|
||||
// Auto-finish if all found
|
||||
if (state.foundNumbers.length + 1 === state.correctAnswers.length) {
|
||||
setTimeout(() => showResults?.(), 1000)
|
||||
}
|
||||
},
|
||||
[acceptNumber, showResults, state.foundNumbers.length, state.correctAnswers.length]
|
||||
)
|
||||
|
||||
const handleIncorrectGuess = useCallback(() => {
|
||||
const wrongNumber = parseInt(state.currentInput, 10)
|
||||
if (!Number.isNaN(wrongNumber)) {
|
||||
dispatch({ type: 'ADD_WRONG_GUESS_ANIMATION', number: wrongNumber })
|
||||
// Clear wrong guess animations after explosion
|
||||
setTimeout(() => {
|
||||
dispatch({ type: 'CLEAR_WRONG_GUESS_ANIMATIONS' })
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
rejectNumber?.()
|
||||
// setInput('') is called inside rejectNumber action creator
|
||||
setDisplayFeedback('incorrect')
|
||||
|
||||
setTimeout(() => setDisplayFeedback('neutral'), 500)
|
||||
|
||||
// Auto-finish if out of guesses
|
||||
if (state.guessesRemaining - 1 === 0) {
|
||||
setTimeout(() => showResults?.(), 1000)
|
||||
}
|
||||
}, [state.currentInput, dispatch, rejectNumber, showResults, state.guessesRemaining])
|
||||
|
||||
// Simple keyboard event handlers that will be defined after callbacks
|
||||
const handleKeyboardInput = useCallback(
|
||||
(key: string) => {
|
||||
// Handle number input
|
||||
if (/^[0-9]$/.test(key)) {
|
||||
// Only handle if input phase is active and guesses remain
|
||||
if (state.guessesRemaining === 0) return
|
||||
|
||||
// Update input with new key
|
||||
const newInput = state.currentInput + key
|
||||
setInput?.(newInput)
|
||||
|
||||
// Clear any existing timeout
|
||||
if (state.prefixAcceptanceTimeout) {
|
||||
clearTimeout(state.prefixAcceptanceTimeout)
|
||||
dispatch({ type: 'SET_PREFIX_TIMEOUT', timeout: null })
|
||||
}
|
||||
|
||||
setDisplayFeedback('neutral')
|
||||
|
||||
const number = parseInt(newInput, 10)
|
||||
if (Number.isNaN(number)) return
|
||||
|
||||
// Check if correct and not already found
|
||||
if (state.correctAnswers.includes(number) && !state.foundNumbers.includes(number)) {
|
||||
if (!isPrefix(newInput, state.correctAnswers, state.foundNumbers)) {
|
||||
acceptCorrectNumber(number)
|
||||
} else {
|
||||
const timeout = setTimeout(() => {
|
||||
acceptCorrectNumber(number)
|
||||
}, 500)
|
||||
dispatch({ type: 'SET_PREFIX_TIMEOUT', timeout })
|
||||
}
|
||||
} else {
|
||||
// Check if this input could be a valid prefix or complete number
|
||||
const couldBePrefix = state.correctAnswers.some((n) => n.toString().startsWith(newInput))
|
||||
const isCompleteWrongNumber = !state.correctAnswers.includes(number) && !couldBePrefix
|
||||
|
||||
// Trigger explosion if:
|
||||
// 1. It's a complete wrong number (length >= 2 or can't be a prefix)
|
||||
// 2. It's a single digit that can't possibly be a prefix of any target
|
||||
if ((newInput.length >= 2 || isCompleteWrongNumber) && state.guessesRemaining > 0) {
|
||||
handleIncorrectGuess()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
state.currentInput,
|
||||
state.prefixAcceptanceTimeout,
|
||||
state.correctAnswers,
|
||||
state.foundNumbers,
|
||||
state.guessesRemaining,
|
||||
dispatch,
|
||||
setInput,
|
||||
acceptCorrectNumber,
|
||||
handleIncorrectGuess,
|
||||
]
|
||||
)
|
||||
|
||||
const handleKeyboardBackspace = useCallback(() => {
|
||||
if (state.currentInput.length > 0) {
|
||||
const newInput = state.currentInput.slice(0, -1)
|
||||
setInput?.(newInput)
|
||||
|
||||
// Clear any existing timeout
|
||||
if (state.prefixAcceptanceTimeout) {
|
||||
clearTimeout(state.prefixAcceptanceTimeout)
|
||||
dispatch({ type: 'SET_PREFIX_TIMEOUT', timeout: null })
|
||||
}
|
||||
|
||||
setDisplayFeedback('neutral')
|
||||
}
|
||||
}, [state.currentInput, state.prefixAcceptanceTimeout, dispatch, setInput])
|
||||
|
||||
// Set up global keyboard listeners
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only handle backspace/delete on keydown to prevent repetition
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
e.preventDefault()
|
||||
handleKeyboardBackspace()
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyPressEvent = (e: KeyboardEvent) => {
|
||||
// Handle number input
|
||||
if (/^[0-9]$/.test(e.key)) {
|
||||
handleKeyboardInput(e.key)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('keypress', handleKeyPressEvent)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('keypress', handleKeyPressEvent)
|
||||
}
|
||||
}, [handleKeyboardInput, handleKeyboardBackspace])
|
||||
|
||||
const hasFoundSome = state.foundNumbers.length > 0
|
||||
const hasFoundAll = state.foundNumbers.length === state.correctAnswers.length
|
||||
const outOfGuesses = state.guessesRemaining === 0
|
||||
const showFinishButtons = hasFoundAll || outOfGuesses || hasFoundSome
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px',
|
||||
paddingBottom:
|
||||
(hasPhysicalKeyboard === false || testingMode) && state.guessesRemaining > 0
|
||||
? '100px'
|
||||
: '12px', // Add space for keyboard
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
marginBottom: '16px',
|
||||
color: '#1f2937',
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
Enter the Numbers You Remember
|
||||
</h3>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '20px',
|
||||
padding: '16px',
|
||||
background: '#f9fafb',
|
||||
borderRadius: '8px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
minWidth: '80px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Cards shown:
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
{state.quizCards.length}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
minWidth: '80px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Guesses left:
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
{state.guessesRemaining}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
minWidth: '80px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Found:
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
{state.foundNumbers.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Scoreboard - Competitive Mode Only */}
|
||||
{state.playMode === 'competitive' &&
|
||||
state.activePlayers &&
|
||||
state.activePlayers.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '16px',
|
||||
padding: '12px',
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid #f59e0b',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
color: '#92400e',
|
||||
marginBottom: '8px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
🏆 LIVE SCOREBOARD
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
// Group players by userId
|
||||
const userTeams = new Map<
|
||||
string,
|
||||
{ userId: string; players: any[]; score: { correct: number; incorrect: number } }
|
||||
>()
|
||||
|
||||
console.log('📊 [InputPhase] Building scoreboard:', {
|
||||
activePlayers: state.activePlayers,
|
||||
playerMetadata: state.playerMetadata,
|
||||
playerScores: state.playerScores,
|
||||
})
|
||||
|
||||
for (const playerId of state.activePlayers) {
|
||||
const metadata = state.playerMetadata?.[playerId]
|
||||
const userId = metadata?.userId
|
||||
console.log('📊 [InputPhase] Processing player for scoreboard:', {
|
||||
playerId,
|
||||
metadata,
|
||||
userId,
|
||||
})
|
||||
if (!userId) continue
|
||||
|
||||
if (!userTeams.has(userId)) {
|
||||
userTeams.set(userId, {
|
||||
userId,
|
||||
players: [],
|
||||
score: state.playerScores?.[userId] || { correct: 0, incorrect: 0 },
|
||||
})
|
||||
}
|
||||
userTeams.get(userId)!.players.push(metadata)
|
||||
}
|
||||
|
||||
console.log('📊 [InputPhase] UserTeams created:', {
|
||||
count: userTeams.size,
|
||||
teams: Array.from(userTeams.entries()),
|
||||
})
|
||||
|
||||
// Sort teams by score
|
||||
return Array.from(userTeams.values())
|
||||
.sort((a, b) => {
|
||||
const aScore = a.score.correct - a.score.incorrect * 0.5
|
||||
const bScore = b.score.correct - b.score.incorrect * 0.5
|
||||
return bScore - aScore
|
||||
})
|
||||
.map((team, index) => {
|
||||
const netScore = team.score.correct - team.score.incorrect * 0.5
|
||||
return (
|
||||
<div
|
||||
key={team.userId}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
background: index === 0 ? '#fef3c7' : 'white',
|
||||
borderRadius: '8px',
|
||||
border: index === 0 ? '2px solid #f59e0b' : '1px solid #e5e7eb',
|
||||
}}
|
||||
>
|
||||
{/* Team header with rank and stats */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '18px' }}>
|
||||
{index === 0 ? '👑' : `${index + 1}.`}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 'bold',
|
||||
fontSize: '16px',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
Score: {netScore.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#10b981', fontWeight: 'bold' }}>
|
||||
✓{team.score.correct}
|
||||
</span>
|
||||
<span style={{ color: '#ef4444', fontWeight: 'bold' }}>
|
||||
✗{team.score.incorrect}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Players list */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
paddingLeft: '26px',
|
||||
}}
|
||||
>
|
||||
{team.players.map((player, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>{player?.emoji || '🎮'}</span>
|
||||
<span
|
||||
style={{
|
||||
color: '#1f2937',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
{player?.name || `Player ${i + 1}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
margin: '16px 0',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '8px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
{state.guessesRemaining === 0
|
||||
? '🚫 No more guesses available'
|
||||
: '⌨️ Type the numbers you remember'}
|
||||
</div>
|
||||
|
||||
{/* Testing control - remove in production */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
color: '#9ca3af',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={testingMode}
|
||||
onChange={(e) =>
|
||||
dispatch({
|
||||
type: 'SET_TESTING_MODE',
|
||||
enabled: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
Test on-screen keyboard (for demo)
|
||||
</label>
|
||||
<div style={{ fontSize: '9px', opacity: 0.7 }}>
|
||||
Keyboard detected:{' '}
|
||||
{hasPhysicalKeyboard === null ? 'detecting...' : hasPhysicalKeyboard ? 'yes' : 'no'}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
minHeight: '50px',
|
||||
padding: '12px 16px',
|
||||
fontSize: '22px',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
textAlign: 'center',
|
||||
fontWeight: '600',
|
||||
color: state.guessesRemaining === 0 ? '#6b7280' : '#1f2937',
|
||||
letterSpacing: '1px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.3s ease',
|
||||
background:
|
||||
displayFeedback === 'correct'
|
||||
? 'linear-gradient(45deg, #d4edda, #c3e6cb)'
|
||||
: displayFeedback === 'incorrect'
|
||||
? 'linear-gradient(45deg, #f8d7da, #f1b0b7)'
|
||||
: state.guessesRemaining === 0
|
||||
? '#e5e7eb'
|
||||
: 'linear-gradient(135deg, #f0f8ff, #e6f3ff)',
|
||||
borderRadius: '12px',
|
||||
position: 'relative',
|
||||
border: '2px solid',
|
||||
borderColor:
|
||||
displayFeedback === 'correct'
|
||||
? '#28a745'
|
||||
: displayFeedback === 'incorrect'
|
||||
? '#dc3545'
|
||||
: state.guessesRemaining === 0
|
||||
? '#9ca3af'
|
||||
: '#3b82f6',
|
||||
boxShadow:
|
||||
displayFeedback === 'correct'
|
||||
? '0 4px 12px rgba(40, 167, 69, 0.2)'
|
||||
: displayFeedback === 'incorrect'
|
||||
? '0 4px 12px rgba(220, 53, 69, 0.2)'
|
||||
: '0 4px 12px rgba(59, 130, 246, 0.15)',
|
||||
cursor: state.guessesRemaining === 0 ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<span style={{ opacity: 1, position: 'relative' }}>
|
||||
{state.guessesRemaining === 0
|
||||
? '🔒 Game Over'
|
||||
: state.currentInput || (
|
||||
<span
|
||||
style={{
|
||||
color: '#74c0fc',
|
||||
opacity: 0.8,
|
||||
fontStyle: 'normal',
|
||||
fontSize: '20px',
|
||||
}}
|
||||
>
|
||||
💭 Think & Type
|
||||
</span>
|
||||
)}
|
||||
{state.currentInput && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '-8px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: '2px',
|
||||
height: '20px',
|
||||
background: '#3b82f6',
|
||||
animation: 'blink 1s infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual card grid showing cards the user was shown */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
minHeight: '0',
|
||||
}}
|
||||
>
|
||||
<CardGrid state={state} />
|
||||
</div>
|
||||
|
||||
{/* Wrong guess explosion animations */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
{state.wrongGuessAnimations.map((animation) => (
|
||||
<div
|
||||
key={animation.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
fontSize: '48px',
|
||||
fontWeight: 'bold',
|
||||
color: '#ef4444',
|
||||
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.3)',
|
||||
animation: 'explode 1.5s ease-out forwards',
|
||||
}}
|
||||
>
|
||||
{animation.number}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Simple fixed keyboard bar - appears when needed, no hiding of game elements */}
|
||||
{(hasPhysicalKeyboard === false || testingMode) && state.guessesRemaining > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
|
||||
borderTop: '2px solid #3b82f6',
|
||||
padding: '12px',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
boxShadow: '0 -4px 12px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 0].map((digit) => (
|
||||
<button
|
||||
key={digit}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
background: 'white',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
cursor: 'pointer',
|
||||
minWidth: '50px',
|
||||
minHeight: '50px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
userSelect: 'none',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.95)'
|
||||
e.currentTarget.style.background = '#f3f4f6'
|
||||
e.currentTarget.style.borderColor = '#3b82f6'
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.background = 'white'
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.background = 'white'
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
}}
|
||||
onClick={() => handleKeyboardInput(digit.toString())}
|
||||
>
|
||||
{digit}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
border: '2px solid #dc2626',
|
||||
borderRadius: '8px',
|
||||
background: state.currentInput.length > 0 ? '#fef2f2' : '#f9fafb',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: state.currentInput.length > 0 ? '#dc2626' : '#9ca3af',
|
||||
cursor: state.currentInput.length > 0 ? 'pointer' : 'not-allowed',
|
||||
minWidth: '70px',
|
||||
minHeight: '50px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
userSelect: 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
disabled={state.currentInput.length === 0}
|
||||
onClick={handleKeyboardBackspace}
|
||||
>
|
||||
⌫
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFinishButtons && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
marginTop: '12px',
|
||||
paddingTop: '12px',
|
||||
borderTop: '1px solid #e5e7eb',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: '#3b82f6',
|
||||
color: 'white',
|
||||
minWidth: '120px',
|
||||
}}
|
||||
onClick={() => showResults?.()}
|
||||
>
|
||||
{hasFoundAll ? 'Finish Quiz' : 'Show Results'}
|
||||
</button>
|
||||
{hasFoundSome && !hasFoundAll && !outOfGuesses && (
|
||||
<button
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: '#6b7280',
|
||||
color: 'white',
|
||||
minWidth: '120px',
|
||||
}}
|
||||
onClick={() => showResults?.()}
|
||||
>
|
||||
Can't Remember More
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useMemoryQuiz } from '../context/MemoryQuizContext'
|
||||
import { DisplayPhase } from './DisplayPhase'
|
||||
import { InputPhase } from './InputPhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
|
||||
// CSS animations that need to be global
|
||||
const globalAnimations = `
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); }
|
||||
50% { transform: scale(1.05); box-shadow: 0 6px 20px rgba(59, 130, 246, 0.5); }
|
||||
100% { transform: scale(1); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); }
|
||||
}
|
||||
|
||||
@keyframes subtlePageFlash {
|
||||
0% { background: linear-gradient(to bottom right, #f0fdf4, #ecfdf5); }
|
||||
50% { background: linear-gradient(to bottom right, #dcfce7, #d1fae5); }
|
||||
100% { background: linear-gradient(to bottom right, #f0fdf4, #ecfdf5); }
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
from { opacity: 0; transform: scale(0.8); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes explode {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1.5);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(2) rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export function MemoryQuizGame() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession, resetGame } = useMemoryQuiz()
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Memory Lightning"
|
||||
navEmoji="🧠"
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
onExitSession={() => {
|
||||
exitSession?.()
|
||||
router.push('/arcade')
|
||||
}}
|
||||
onNewGame={() => {
|
||||
resetGame?.()
|
||||
}}
|
||||
>
|
||||
<style dangerouslySetInnerHTML={{ __html: globalAnimations }} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'auto',
|
||||
padding: '20px 8px',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
mb: '4',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<Link
|
||||
href="/arcade"
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
color: 'gray.600',
|
||||
textDecoration: 'none',
|
||||
mb: '4',
|
||||
_hover: { color: 'gray.800' },
|
||||
})}
|
||||
>
|
||||
← Back to Champion Arena
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
bg: 'white',
|
||||
rounded: 'xl',
|
||||
shadow: 'xl',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: '100%',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'auto',
|
||||
})}
|
||||
>
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'display' && <DisplayPhase />}
|
||||
{state.gamePhase === 'input' && <InputPhase key="input-phase" />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import type { SorobanQuizState } from '../types'
|
||||
|
||||
interface ResultsCardGridProps {
|
||||
state: SorobanQuizState
|
||||
}
|
||||
|
||||
export function ResultsCardGrid({ state }: ResultsCardGridProps) {
|
||||
if (state.quizCards.length === 0) return null
|
||||
|
||||
// Calculate optimal grid layout based on number of cards (same as CardGrid)
|
||||
const cardCount = state.quizCards.length
|
||||
|
||||
// Define static grid classes that Panda can generate (same as CardGrid)
|
||||
const getGridClass = (count: number) => {
|
||||
if (count <= 2) return 'repeat(2, 1fr)'
|
||||
if (count <= 4) return 'repeat(2, 1fr)'
|
||||
if (count <= 6) return 'repeat(3, 1fr)'
|
||||
if (count <= 9) return 'repeat(3, 1fr)'
|
||||
if (count <= 12) return 'repeat(4, 1fr)'
|
||||
return 'repeat(5, 1fr)'
|
||||
}
|
||||
|
||||
const getCardSize = (count: number) => {
|
||||
if (count <= 2) return { minSize: '180px', cardHeight: '160px' }
|
||||
if (count <= 4) return { minSize: '160px', cardHeight: '150px' }
|
||||
if (count <= 6) return { minSize: '140px', cardHeight: '140px' }
|
||||
if (count <= 9) return { minSize: '120px', cardHeight: '130px' }
|
||||
if (count <= 12) return { minSize: '110px', cardHeight: '120px' }
|
||||
return { minSize: '100px', cardHeight: '110px' }
|
||||
}
|
||||
|
||||
const gridClass = getGridClass(cardCount)
|
||||
const cardSize = getCardSize(cardCount)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: '8px',
|
||||
padding: '6px',
|
||||
justifyContent: 'center',
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
gridTemplateColumns: gridClass,
|
||||
}}
|
||||
>
|
||||
{state.quizCards.map((card, index) => {
|
||||
const isRevealed = true // All cards revealed in results
|
||||
const wasFound = state.foundNumbers.includes(card.number)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${card.number}-${index}`}
|
||||
style={{
|
||||
perspective: '1000px',
|
||||
position: 'relative',
|
||||
aspectRatio: '3/4',
|
||||
height: cardSize.cardHeight,
|
||||
minWidth: cardSize.minSize,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
textAlign: 'center',
|
||||
transition: 'transform 0.8s',
|
||||
transformStyle: 'preserve-3d',
|
||||
transform: isRevealed ? 'rotateY(180deg)' : 'rotateY(0deg)',
|
||||
}}
|
||||
>
|
||||
{/* Card back (hidden state) - not visible in results */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backfaceVisibility: 'hidden',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
background: 'linear-gradient(135deg, #6c5ce7, #a29bfe)',
|
||||
color: 'white',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
textShadow: '1px 1px 2px rgba(0, 0, 0, 0.3)',
|
||||
border: '2px solid #5f3dc4',
|
||||
}}
|
||||
>
|
||||
<div style={{ opacity: 0.8 }}>?</div>
|
||||
</div>
|
||||
|
||||
{/* Card front (revealed state) with success/failure indicators */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backfaceVisibility: 'hidden',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: wasFound ? '#10b981' : '#ef4444',
|
||||
transform: 'rotateY(180deg)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
padding: '4px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AbacusReact
|
||||
value={card.number}
|
||||
columns="auto"
|
||||
beadShape="diamond"
|
||||
colorScheme="place-value"
|
||||
hideInactiveBeads={false}
|
||||
scaleFactor={1.2}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
animated={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Player indicator overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
minWidth: wasFound ? '24px' : '20px',
|
||||
minHeight: '20px',
|
||||
maxHeight: '48px',
|
||||
borderRadius: wasFound ? '8px' : '50%',
|
||||
background: wasFound ? '#10b981' : '#ef4444',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: wasFound ? '14px' : '12px',
|
||||
fontWeight: 'bold',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.2)',
|
||||
padding: wasFound ? '2px' : '0',
|
||||
gap: '1px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{wasFound
|
||||
? (() => {
|
||||
// Get the userId who found this number
|
||||
const foundByUserId = state.numberFoundBy?.[card.number]
|
||||
if (!foundByUserId) return '✓'
|
||||
|
||||
// Get all players on that team
|
||||
const teamPlayers = state.activePlayers
|
||||
?.filter((playerId) => {
|
||||
const metadata = state.playerMetadata?.[playerId]
|
||||
return metadata?.userId === foundByUserId
|
||||
})
|
||||
.map((playerId) => state.playerMetadata?.[playerId])
|
||||
.filter(Boolean)
|
||||
|
||||
if (!teamPlayers || teamPlayers.length === 0) return '✓'
|
||||
|
||||
// Display emojis (stacked vertically if multiple)
|
||||
return teamPlayers.map((player, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
style={{
|
||||
lineHeight: '1',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{player?.emoji || '🎮'}
|
||||
</span>
|
||||
))
|
||||
})()
|
||||
: '✗'}
|
||||
</div>
|
||||
|
||||
{/* Number label overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '4px',
|
||||
left: '4px',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '3px',
|
||||
background: 'rgba(0, 0, 0, 0.7)',
|
||||
color: 'white',
|
||||
fontSize: '10px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{card.number}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Summary row for large numbers of cards (same as CardGrid) */}
|
||||
{cardCount > 8 && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
padding: '6px 8px',
|
||||
background: '#eff6ff',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #bfdbfe',
|
||||
textAlign: 'center',
|
||||
fontSize: '12px',
|
||||
color: '#1d4ed8',
|
||||
}}
|
||||
>
|
||||
<strong>{state.foundNumbers.length}</strong> of <strong>{cardCount}</strong> cards found
|
||||
{state.foundNumbers.length > 0 && (
|
||||
<span style={{ marginLeft: '6px', fontWeight: 'normal' }}>
|
||||
({Math.round((state.foundNumbers.length / cardCount) * 100)}% complete)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
561
apps/web/src/app/arcade/memory-quiz/components/ResultsPhase.tsx
Normal file
561
apps/web/src/app/arcade/memory-quiz/components/ResultsPhase.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
import { useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { useMemoryQuiz } from '../context/MemoryQuizContext'
|
||||
import { DIFFICULTY_LEVELS, type DifficultyLevel, type QuizCard } from '../types'
|
||||
import { ResultsCardGrid } from './ResultsCardGrid'
|
||||
|
||||
// Generate quiz cards with difficulty-based number ranges
|
||||
const generateQuizCards = (
|
||||
count: number,
|
||||
difficulty: DifficultyLevel,
|
||||
appConfig: any
|
||||
): QuizCard[] => {
|
||||
const { min, max } = DIFFICULTY_LEVELS[difficulty].range
|
||||
|
||||
// Generate unique numbers - no duplicates allowed
|
||||
const numbers: number[] = []
|
||||
const maxAttempts = (max - min + 1) * 10 // Prevent infinite loops
|
||||
let attempts = 0
|
||||
|
||||
while (numbers.length < count && attempts < maxAttempts) {
|
||||
const newNumber = Math.floor(Math.random() * (max - min + 1)) + min
|
||||
if (!numbers.includes(newNumber)) {
|
||||
numbers.push(newNumber)
|
||||
}
|
||||
attempts++
|
||||
}
|
||||
|
||||
// If we couldn't generate enough unique numbers, fill with sequential numbers
|
||||
if (numbers.length < count) {
|
||||
for (let i = min; i <= max && numbers.length < count; i++) {
|
||||
if (!numbers.includes(i)) {
|
||||
numbers.push(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return numbers.map((number) => ({
|
||||
number,
|
||||
svgComponent: <div />, // Placeholder - not used in results phase
|
||||
element: null,
|
||||
}))
|
||||
}
|
||||
|
||||
export function ResultsPhase() {
|
||||
const { state, resetGame, startQuiz } = useMemoryQuiz()
|
||||
const appConfig = useAbacusConfig()
|
||||
const correct = state.foundNumbers.length
|
||||
const total = state.correctAnswers.length
|
||||
const percentage = Math.round((correct / total) * 100)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px',
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
marginBottom: '20px',
|
||||
color: '#1f2937',
|
||||
fontSize: '18px',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
Quiz Results
|
||||
</h3>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '20px',
|
||||
padding: '16px',
|
||||
background: '#f9fafb',
|
||||
borderRadius: '8px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(45deg, #3b82f6, #2563eb)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
<span>{percentage}%</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: '12px',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: '500', color: '#6b7280' }}>Correct:</span>
|
||||
<span style={{ fontWeight: 'bold' }}>{correct}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: '12px',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: '500', color: '#6b7280' }}>Total:</span>
|
||||
<span style={{ fontWeight: 'bold' }}>{total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multiplayer Leaderboard - Competitive Mode */}
|
||||
{state.playMode === 'competitive' &&
|
||||
state.activePlayers &&
|
||||
state.activePlayers.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '16px',
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
borderRadius: '12px',
|
||||
border: '2px solid #f59e0b',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: '#92400e',
|
||||
marginBottom: '12px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
🏆 FINAL LEADERBOARD
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
// Group players by userId
|
||||
const userTeams = new Map<
|
||||
string,
|
||||
{ userId: string; players: any[]; score: { correct: number; incorrect: number } }
|
||||
>()
|
||||
|
||||
console.log('🏆 [ResultsPhase] Building leaderboard:', {
|
||||
activePlayers: state.activePlayers,
|
||||
playerMetadata: state.playerMetadata,
|
||||
playerScores: state.playerScores,
|
||||
})
|
||||
|
||||
for (const playerId of state.activePlayers) {
|
||||
const metadata = state.playerMetadata?.[playerId]
|
||||
const userId = metadata?.userId
|
||||
console.log('🏆 [ResultsPhase] Processing player for leaderboard:', {
|
||||
playerId,
|
||||
metadata,
|
||||
userId,
|
||||
})
|
||||
if (!userId) continue
|
||||
|
||||
if (!userTeams.has(userId)) {
|
||||
userTeams.set(userId, {
|
||||
userId,
|
||||
players: [],
|
||||
score: state.playerScores?.[userId] || { correct: 0, incorrect: 0 },
|
||||
})
|
||||
}
|
||||
userTeams.get(userId)!.players.push(metadata)
|
||||
}
|
||||
|
||||
console.log('🏆 [ResultsPhase] UserTeams created:', {
|
||||
count: userTeams.size,
|
||||
teams: Array.from(userTeams.entries()),
|
||||
})
|
||||
|
||||
// Sort teams by score
|
||||
return Array.from(userTeams.values())
|
||||
.sort((a, b) => {
|
||||
const aScore = a.score.correct - a.score.incorrect * 0.5
|
||||
const bScore = b.score.correct - b.score.incorrect * 0.5
|
||||
return bScore - aScore
|
||||
})
|
||||
.map((team, index) => {
|
||||
const netScore = team.score.correct - team.score.incorrect * 0.5
|
||||
return (
|
||||
<div
|
||||
key={team.userId}
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
background:
|
||||
index === 0
|
||||
? 'linear-gradient(135deg, #fef3c7 0%, #fde68a 50%)'
|
||||
: 'white',
|
||||
borderRadius: '10px',
|
||||
border: index === 0 ? '3px solid #f59e0b' : '1px solid #e5e7eb',
|
||||
boxShadow: index === 0 ? '0 4px 12px rgba(245, 158, 11, 0.3)' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Team header with rank and stats */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '10px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<span style={{ fontSize: '24px', minWidth: '32px' }}>
|
||||
{index === 0
|
||||
? '🏆'
|
||||
: index === 1
|
||||
? '🥈'
|
||||
: index === 2
|
||||
? '🥉'
|
||||
: `${index + 1}.`}
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 'bold',
|
||||
fontSize: index === 0 ? '20px' : '18px',
|
||||
color: index === 0 ? '#f59e0b' : '#1f2937',
|
||||
}}
|
||||
>
|
||||
{netScore.toFixed(1)}
|
||||
</span>
|
||||
{index === 0 && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: '#92400e',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
CHAMPION
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: '#10b981',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
✓{team.score.correct}
|
||||
</span>
|
||||
<span style={{ fontSize: '10px', color: '#6b7280' }}>correct</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: '#ef4444',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
✗{team.score.incorrect}
|
||||
</span>
|
||||
<span style={{ fontSize: '10px', color: '#6b7280' }}>wrong</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Players list */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
paddingLeft: '42px',
|
||||
}}
|
||||
>
|
||||
{team.players.map((player, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '18px' }}>{player?.emoji || '🎮'}</span>
|
||||
<span
|
||||
style={{
|
||||
color: '#1f2937',
|
||||
fontWeight: '500',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{player?.name || `Player ${i + 1}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Multiplayer Stats - Cooperative Mode */}
|
||||
{state.playMode === 'cooperative' &&
|
||||
state.activePlayers &&
|
||||
state.activePlayers.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '16px',
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
|
||||
borderRadius: '12px',
|
||||
border: '2px solid #3b82f6',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1e3a8a',
|
||||
marginBottom: '12px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
🤝 TEAM CONTRIBUTIONS
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
// Group players by userId
|
||||
const userTeams = new Map<
|
||||
string,
|
||||
{ userId: string; players: any[]; score: { correct: number; incorrect: 0 } }
|
||||
>()
|
||||
|
||||
console.log('🤝 [ResultsPhase] Building team contributions:', {
|
||||
activePlayers: state.activePlayers,
|
||||
playerMetadata: state.playerMetadata,
|
||||
playerScores: state.playerScores,
|
||||
})
|
||||
|
||||
for (const playerId of state.activePlayers) {
|
||||
const metadata = state.playerMetadata?.[playerId]
|
||||
const userId = metadata?.userId
|
||||
console.log('🤝 [ResultsPhase] Processing player for contributions:', {
|
||||
playerId,
|
||||
metadata,
|
||||
userId,
|
||||
})
|
||||
if (!userId) continue
|
||||
|
||||
if (!userTeams.has(userId)) {
|
||||
userTeams.set(userId, {
|
||||
userId,
|
||||
players: [],
|
||||
score: state.playerScores?.[userId] || { correct: 0, incorrect: 0 },
|
||||
})
|
||||
}
|
||||
userTeams.get(userId)!.players.push(metadata)
|
||||
}
|
||||
|
||||
console.log('🤝 [ResultsPhase] UserTeams created for contributions:', {
|
||||
count: userTeams.size,
|
||||
teams: Array.from(userTeams.entries()),
|
||||
})
|
||||
|
||||
// Sort teams by correct answers
|
||||
return Array.from(userTeams.values())
|
||||
.sort((a, b) => b.score.correct - a.score.correct)
|
||||
.map((team, index) => (
|
||||
<div
|
||||
key={team.userId}
|
||||
style={{
|
||||
padding: '12px 14px',
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
}}
|
||||
>
|
||||
{/* Team header with stats */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '16px', fontWeight: '600', color: '#6b7280' }}>
|
||||
Team {index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#10b981', fontWeight: 'bold' }}>
|
||||
✓ {team.score.correct}
|
||||
</span>
|
||||
<span style={{ color: '#ef4444', fontWeight: 'bold' }}>
|
||||
✗ {team.score.incorrect}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Players list */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
paddingLeft: '8px',
|
||||
}}
|
||||
>
|
||||
{team.players.map((player, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '18px' }}>{player?.emoji || '🎮'}</span>
|
||||
<span
|
||||
style={{
|
||||
color: '#1f2937',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
{player?.name || `Player ${i + 1}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results card grid - reuse CardGrid but with all cards revealed and status indicators */}
|
||||
<div style={{ marginTop: '12px', flex: 1, overflow: 'auto' }}>
|
||||
<ResultsCardGrid state={state} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
marginTop: '16px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: '#10b981',
|
||||
color: 'white',
|
||||
minWidth: '120px',
|
||||
}}
|
||||
onClick={() => {
|
||||
resetGame?.()
|
||||
const quizCards = generateQuizCards(
|
||||
state.selectedCount,
|
||||
state.selectedDifficulty,
|
||||
appConfig
|
||||
)
|
||||
startQuiz?.(quizCards)
|
||||
}}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: '#6b7280',
|
||||
color: 'white',
|
||||
minWidth: '120px',
|
||||
}}
|
||||
onClick={() => resetGame?.()}
|
||||
>
|
||||
Back to Cards
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
335
apps/web/src/app/arcade/memory-quiz/components/SetupPhase.tsx
Normal file
335
apps/web/src/app/arcade/memory-quiz/components/SetupPhase.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { useMemoryQuiz } from '../context/MemoryQuizContext'
|
||||
import { DIFFICULTY_LEVELS, type DifficultyLevel, type QuizCard } from '../types'
|
||||
|
||||
// Generate quiz cards with difficulty-based number ranges
|
||||
const generateQuizCards = (
|
||||
count: number,
|
||||
difficulty: DifficultyLevel,
|
||||
appConfig: any
|
||||
): QuizCard[] => {
|
||||
const { min, max } = DIFFICULTY_LEVELS[difficulty].range
|
||||
|
||||
// Generate unique numbers - no duplicates allowed
|
||||
const numbers: number[] = []
|
||||
const maxAttempts = (max - min + 1) * 10 // Prevent infinite loops
|
||||
let attempts = 0
|
||||
|
||||
while (numbers.length < count && attempts < maxAttempts) {
|
||||
const newNumber = Math.floor(Math.random() * (max - min + 1)) + min
|
||||
if (!numbers.includes(newNumber)) {
|
||||
numbers.push(newNumber)
|
||||
}
|
||||
attempts++
|
||||
}
|
||||
|
||||
// If we couldn't generate enough unique numbers, fill with sequential numbers
|
||||
if (numbers.length < count) {
|
||||
for (let i = min; i <= max && numbers.length < count; i++) {
|
||||
if (!numbers.includes(i)) {
|
||||
numbers.push(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return numbers.map((number) => ({
|
||||
number,
|
||||
svgComponent: (
|
||||
<AbacusReact
|
||||
value={number}
|
||||
columns="auto"
|
||||
beadShape={appConfig.beadShape}
|
||||
colorScheme={appConfig.colorScheme}
|
||||
hideInactiveBeads={appConfig.hideInactiveBeads}
|
||||
scaleFactor={1.0}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
animated={false}
|
||||
soundEnabled={appConfig.soundEnabled}
|
||||
soundVolume={appConfig.soundVolume}
|
||||
/>
|
||||
),
|
||||
element: null,
|
||||
}))
|
||||
}
|
||||
|
||||
export function SetupPhase() {
|
||||
const { state, setConfig, startQuiz } = useMemoryQuiz()
|
||||
const appConfig = useAbacusConfig()
|
||||
|
||||
const handleCountSelect = (count: number) => {
|
||||
setConfig?.('selectedCount', count)
|
||||
}
|
||||
|
||||
const handleTimeChange = (time: number) => {
|
||||
setConfig?.('displayTime', time)
|
||||
}
|
||||
|
||||
const handleDifficultySelect = (difficulty: DifficultyLevel) => {
|
||||
setConfig?.('selectedDifficulty', difficulty)
|
||||
}
|
||||
|
||||
const handlePlayModeSelect = (playMode: 'cooperative' | 'competitive') => {
|
||||
setConfig?.('playMode', playMode)
|
||||
}
|
||||
|
||||
const handleStartQuiz = () => {
|
||||
const quizCards = generateQuizCards(
|
||||
state.selectedCount ?? 5,
|
||||
state.selectedDifficulty ?? 'easy',
|
||||
appConfig
|
||||
)
|
||||
startQuiz?.(quizCards)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px',
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<div style={{ margin: '12px 0' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Difficulty Level:
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{Object.entries(DIFFICULTY_LEVELS).map(([key, level]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
style={{
|
||||
background: state.selectedDifficulty === key ? '#3b82f6' : 'white',
|
||||
color: state.selectedDifficulty === key ? 'white' : '#1f2937',
|
||||
border: '2px solid',
|
||||
borderColor: state.selectedDifficulty === key ? '#3b82f6' : '#d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
onClick={() => handleDifficultySelect(key as DifficultyLevel)}
|
||||
title={level.description}
|
||||
>
|
||||
<div style={{ fontWeight: 'bold', fontSize: '13px' }}>{level.name}</div>
|
||||
<div style={{ fontSize: '10px', opacity: 0.8 }}>{level.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ margin: '12px 0' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Play Mode:
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
key="cooperative"
|
||||
type="button"
|
||||
style={{
|
||||
background: state.playMode === 'cooperative' ? '#10b981' : 'white',
|
||||
color: state.playMode === 'cooperative' ? 'white' : '#1f2937',
|
||||
border: '2px solid',
|
||||
borderColor: state.playMode === 'cooperative' ? '#10b981' : '#d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
onClick={() => handlePlayModeSelect('cooperative')}
|
||||
title="Work together as a team to find all numbers"
|
||||
>
|
||||
<div style={{ fontWeight: 'bold', fontSize: '13px' }}>🤝 Cooperative</div>
|
||||
<div style={{ fontSize: '10px', opacity: 0.8 }}>Work together</div>
|
||||
</button>
|
||||
<button
|
||||
key="competitive"
|
||||
type="button"
|
||||
style={{
|
||||
background: state.playMode === 'competitive' ? '#ef4444' : 'white',
|
||||
color: state.playMode === 'competitive' ? 'white' : '#1f2937',
|
||||
border: '2px solid',
|
||||
borderColor: state.playMode === 'competitive' ? '#ef4444' : '#d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
onClick={() => handlePlayModeSelect('competitive')}
|
||||
title="Compete for the highest score"
|
||||
>
|
||||
<div style={{ fontWeight: 'bold', fontSize: '13px' }}>🏆 Competitive</div>
|
||||
<div style={{ fontSize: '10px', opacity: 0.8 }}>Battle for score</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ margin: '12px 0' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Cards to Quiz:
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{[2, 5, 8, 12, 15].map((count) => (
|
||||
<button
|
||||
key={count}
|
||||
type="button"
|
||||
style={{
|
||||
background: state.selectedCount === count ? '#3b82f6' : 'white',
|
||||
color: state.selectedCount === count ? 'white' : '#1f2937',
|
||||
border: '2px solid',
|
||||
borderColor: state.selectedCount === count ? '#3b82f6' : '#d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
minWidth: '50px',
|
||||
}}
|
||||
onClick={() => handleCountSelect(count)}
|
||||
>
|
||||
{count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ margin: '12px 0' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Display Time per Card:
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="10"
|
||||
step="0.5"
|
||||
value={state.displayTime ?? 2.0}
|
||||
onChange={(e) => handleTimeChange(parseFloat(e.target.value))}
|
||||
style={{
|
||||
flex: 1,
|
||||
maxWidth: '200px',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 'bold',
|
||||
color: '#3b82f6',
|
||||
minWidth: '40px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{(state.displayTime ?? 2.0).toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
style={{
|
||||
background: '#10b981',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 24px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
marginTop: '16px',
|
||||
width: '100%',
|
||||
maxWidth: '200px',
|
||||
}}
|
||||
onClick={handleStartQuiz}
|
||||
>
|
||||
Start Quiz
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useReducer } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { initialState, quizReducer } from '../reducer'
|
||||
import type { QuizCard } from '../types'
|
||||
import { MemoryQuizContext, type MemoryQuizContextValue } from './MemoryQuizContext'
|
||||
|
||||
interface LocalMemoryQuizProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* LocalMemoryQuizProvider - Provides context for single-player local mode
|
||||
*
|
||||
* This provider wraps the memory quiz reducer and provides action creators
|
||||
* to child components. It's used for standalone local play (non-room mode).
|
||||
*
|
||||
* Action creators wrap dispatch calls to maintain same interface as RoomProvider.
|
||||
*/
|
||||
export function LocalMemoryQuizProvider({ children }: LocalMemoryQuizProviderProps) {
|
||||
const router = useRouter()
|
||||
const [state, dispatch] = useReducer(quizReducer, initialState)
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (state.prefixAcceptanceTimeout) {
|
||||
clearTimeout(state.prefixAcceptanceTimeout)
|
||||
}
|
||||
}
|
||||
}, [state.prefixAcceptanceTimeout])
|
||||
|
||||
// Computed values
|
||||
const isGameActive = state.gamePhase === 'display' || state.gamePhase === 'input'
|
||||
|
||||
// Action creators - wrap dispatch calls to match RoomProvider interface
|
||||
const startQuiz = useCallback((quizCards: QuizCard[]) => {
|
||||
dispatch({ type: 'START_QUIZ', quizCards })
|
||||
}, [])
|
||||
|
||||
const nextCard = useCallback(() => {
|
||||
dispatch({ type: 'NEXT_CARD' })
|
||||
}, [])
|
||||
|
||||
const showInputPhase = useCallback(() => {
|
||||
dispatch({ type: 'SHOW_INPUT_PHASE' })
|
||||
}, [])
|
||||
|
||||
const acceptNumber = useCallback((number: number) => {
|
||||
dispatch({ type: 'ACCEPT_NUMBER', number })
|
||||
}, [])
|
||||
|
||||
const rejectNumber = useCallback(() => {
|
||||
dispatch({ type: 'REJECT_NUMBER' })
|
||||
}, [])
|
||||
|
||||
const setInput = useCallback((input: string) => {
|
||||
dispatch({ type: 'SET_INPUT', input })
|
||||
}, [])
|
||||
|
||||
const showResults = useCallback(() => {
|
||||
dispatch({ type: 'SHOW_RESULTS' })
|
||||
}, [])
|
||||
|
||||
const resetGame = useCallback(() => {
|
||||
dispatch({ type: 'RESET_QUIZ' })
|
||||
}, [])
|
||||
|
||||
const setConfig = useCallback(
|
||||
(field: 'selectedCount' | 'displayTime' | 'selectedDifficulty', value: any) => {
|
||||
switch (field) {
|
||||
case 'selectedCount':
|
||||
dispatch({ type: 'SET_SELECTED_COUNT', count: value })
|
||||
break
|
||||
case 'displayTime':
|
||||
dispatch({ type: 'SET_DISPLAY_TIME', time: value })
|
||||
break
|
||||
case 'selectedDifficulty':
|
||||
dispatch({ type: 'SET_DIFFICULTY', difficulty: value })
|
||||
break
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const exitSession = useCallback(() => {
|
||||
router.push('/games')
|
||||
}, [router])
|
||||
|
||||
const contextValue: MemoryQuizContextValue = {
|
||||
state,
|
||||
dispatch: () => {
|
||||
// No-op - local provider uses action creators instead
|
||||
console.warn('dispatch() is not available in local mode, use action creators instead')
|
||||
},
|
||||
isGameActive,
|
||||
resetGame,
|
||||
exitSession,
|
||||
// Expose action creators for components to use
|
||||
startQuiz,
|
||||
nextCard,
|
||||
showInputPhase,
|
||||
acceptNumber,
|
||||
rejectNumber,
|
||||
setInput,
|
||||
showResults,
|
||||
setConfig,
|
||||
}
|
||||
|
||||
return <MemoryQuizContext.Provider value={contextValue}>{children}</MemoryQuizContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext } from 'react'
|
||||
import type { QuizAction, QuizCard, SorobanQuizState } from '../types'
|
||||
|
||||
// Context value interface
|
||||
export interface MemoryQuizContextValue {
|
||||
state: SorobanQuizState
|
||||
dispatch: React.Dispatch<QuizAction>
|
||||
|
||||
// Computed values
|
||||
isGameActive: boolean
|
||||
isRoomCreator?: boolean // True if current user is room creator (controls timing in multiplayer)
|
||||
|
||||
// Action creators (to be implemented by providers)
|
||||
// Local mode uses dispatch, room mode uses these action creators
|
||||
startGame?: () => void
|
||||
resetGame?: () => void
|
||||
exitSession?: () => void
|
||||
|
||||
// Room mode action creators (optional for local mode)
|
||||
startQuiz?: (quizCards: QuizCard[]) => void
|
||||
nextCard?: () => void
|
||||
showInputPhase?: () => void
|
||||
acceptNumber?: (number: number) => void
|
||||
rejectNumber?: () => void
|
||||
setInput?: (input: string) => void
|
||||
showResults?: () => void
|
||||
setConfig?: (
|
||||
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode',
|
||||
value: any
|
||||
) => void
|
||||
}
|
||||
|
||||
// Create context
|
||||
export const MemoryQuizContext = createContext<MemoryQuizContextValue | null>(null)
|
||||
|
||||
// Hook to use the context
|
||||
export function useMemoryQuiz(): MemoryQuizContextValue {
|
||||
const context = useContext(MemoryQuizContext)
|
||||
if (!context) {
|
||||
throw new Error('useMemoryQuiz must be used within a MemoryQuizProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import type { GameMove } from '@/lib/arcade/validation'
|
||||
import { TEAM_MOVE } from '@/lib/arcade/validation/types'
|
||||
import {
|
||||
buildPlayerMetadata as buildPlayerMetadataUtil,
|
||||
buildPlayerOwnershipFromRoomData,
|
||||
} from '@/lib/arcade/player-ownership.client'
|
||||
import { initialState } from '../reducer'
|
||||
import type { QuizCard, SorobanQuizState } from '../types'
|
||||
import { MemoryQuizContext, type MemoryQuizContextValue } from './MemoryQuizContext'
|
||||
|
||||
/**
|
||||
* Optimistic move application (client-side prediction)
|
||||
* The server will validate and send back the authoritative state
|
||||
*/
|
||||
function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): SorobanQuizState {
|
||||
switch (move.type) {
|
||||
case 'START_QUIZ': {
|
||||
// Handle both client-generated moves (with quizCards) and server-generated moves (with numbers only)
|
||||
// Server can't serialize React components, so it only sends numbers
|
||||
const clientQuizCards = move.data.quizCards
|
||||
const serverNumbers = move.data.numbers
|
||||
|
||||
let quizCards: QuizCard[]
|
||||
let correctAnswers: number[]
|
||||
|
||||
if (clientQuizCards) {
|
||||
// Client-side optimistic update: use the full quizCards with React components
|
||||
quizCards = clientQuizCards
|
||||
correctAnswers = clientQuizCards.map((card: QuizCard) => card.number)
|
||||
} else if (serverNumbers) {
|
||||
// Server update: create minimal quizCards from numbers (no React components needed for validation)
|
||||
quizCards = serverNumbers.map((number: number) => ({
|
||||
number,
|
||||
svgComponent: null,
|
||||
element: null,
|
||||
}))
|
||||
correctAnswers = serverNumbers
|
||||
} else {
|
||||
// Fallback: preserve existing state
|
||||
quizCards = state.quizCards
|
||||
correctAnswers = state.correctAnswers
|
||||
}
|
||||
|
||||
const cardCount = quizCards.length
|
||||
|
||||
// Initialize player scores for all active players (by userId, not playerId)
|
||||
const activePlayers = move.data.activePlayers || []
|
||||
const playerMetadata = move.data.playerMetadata || {}
|
||||
|
||||
// Extract unique userIds from playerMetadata
|
||||
const uniqueUserIds = new Set<string>()
|
||||
for (const playerId of activePlayers) {
|
||||
const metadata = playerMetadata[playerId]
|
||||
if (metadata?.userId) {
|
||||
uniqueUserIds.add(metadata.userId)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize scores for each userId
|
||||
const playerScores = Array.from(uniqueUserIds).reduce((acc: any, userId: string) => {
|
||||
acc[userId] = { correct: 0, incorrect: 0 }
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return {
|
||||
...state,
|
||||
quizCards,
|
||||
correctAnswers,
|
||||
currentCardIndex: 0,
|
||||
foundNumbers: [],
|
||||
guessesRemaining: cardCount + Math.floor(cardCount / 2),
|
||||
gamePhase: 'display',
|
||||
incorrectGuesses: 0,
|
||||
currentInput: '',
|
||||
wrongGuessAnimations: [],
|
||||
prefixAcceptanceTimeout: null,
|
||||
// Multiplayer state
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
playerScores,
|
||||
}
|
||||
}
|
||||
|
||||
case 'NEXT_CARD':
|
||||
return {
|
||||
...state,
|
||||
currentCardIndex: state.currentCardIndex + 1,
|
||||
}
|
||||
|
||||
case 'SHOW_INPUT_PHASE':
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'input',
|
||||
}
|
||||
|
||||
case 'ACCEPT_NUMBER': {
|
||||
// Track scores by userId (not playerId) since we can't determine which player typed
|
||||
// Defensive check: ensure state properties exist
|
||||
const playerScores = state.playerScores || {}
|
||||
const foundNumbers = state.foundNumbers || []
|
||||
const numberFoundBy = state.numberFoundBy || {}
|
||||
|
||||
const newPlayerScores = { ...playerScores }
|
||||
const newNumberFoundBy = { ...numberFoundBy }
|
||||
|
||||
if (move.userId) {
|
||||
const currentScore = newPlayerScores[move.userId] || { correct: 0, incorrect: 0 }
|
||||
newPlayerScores[move.userId] = {
|
||||
...currentScore,
|
||||
correct: currentScore.correct + 1,
|
||||
}
|
||||
// Track who found this number
|
||||
newNumberFoundBy[move.data.number] = move.userId
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
foundNumbers: [...foundNumbers, move.data.number],
|
||||
playerScores: newPlayerScores,
|
||||
numberFoundBy: newNumberFoundBy,
|
||||
}
|
||||
}
|
||||
|
||||
case 'REJECT_NUMBER': {
|
||||
// Track scores by userId (not playerId) since we can't determine which player typed
|
||||
// Defensive check: ensure state properties exist
|
||||
const playerScores = state.playerScores || {}
|
||||
|
||||
const newPlayerScores = { ...playerScores }
|
||||
if (move.userId) {
|
||||
const currentScore = newPlayerScores[move.userId] || { correct: 0, incorrect: 0 }
|
||||
newPlayerScores[move.userId] = {
|
||||
...currentScore,
|
||||
incorrect: currentScore.incorrect + 1,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
guessesRemaining: state.guessesRemaining - 1,
|
||||
incorrectGuesses: state.incorrectGuesses + 1,
|
||||
playerScores: newPlayerScores,
|
||||
}
|
||||
}
|
||||
|
||||
case 'SHOW_RESULTS':
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'results',
|
||||
}
|
||||
|
||||
case 'RESET_QUIZ':
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'setup',
|
||||
quizCards: [],
|
||||
correctAnswers: [],
|
||||
currentCardIndex: 0,
|
||||
foundNumbers: [],
|
||||
guessesRemaining: 0,
|
||||
currentInput: '',
|
||||
incorrectGuesses: 0,
|
||||
wrongGuessAnimations: [],
|
||||
prefixAcceptanceTimeout: null,
|
||||
finishButtonsBound: false,
|
||||
}
|
||||
|
||||
case 'SET_CONFIG': {
|
||||
const { field, value } = move.data as {
|
||||
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode'
|
||||
value: any
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
[field]: value,
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RoomMemoryQuizProvider - Provides context for room-based multiplayer mode
|
||||
*
|
||||
* This provider uses useArcadeSession for network-synchronized gameplay.
|
||||
* All state changes are sent as moves and validated on the server.
|
||||
*/
|
||||
export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
const { activePlayers: activePlayerIds, players } = useGameMode()
|
||||
const { mutate: updateGameConfig } = useUpdateGameConfig()
|
||||
|
||||
// Get active player IDs as array
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
// LOCAL-ONLY state for current input (not synced over network)
|
||||
// This prevents sending a network request for every keystroke
|
||||
const [localCurrentInput, setLocalCurrentInput] = useState('')
|
||||
|
||||
// Merge saved game config from room with initialState
|
||||
// Settings are scoped by game name to preserve settings when switching games
|
||||
const mergedInitialState = useMemo(() => {
|
||||
const gameConfig = roomData?.gameConfig as Record<string, any> | null | undefined
|
||||
console.log('[RoomMemoryQuizProvider] Initializing - gameConfig:', gameConfig)
|
||||
|
||||
if (!gameConfig) {
|
||||
console.log('[RoomMemoryQuizProvider] No gameConfig, using initialState')
|
||||
return initialState
|
||||
}
|
||||
|
||||
// Get settings for this specific game (memory-quiz)
|
||||
const savedConfig = gameConfig['memory-quiz'] as Record<string, any> | null | undefined
|
||||
console.log('[RoomMemoryQuizProvider] Loading saved config for memory-quiz:', savedConfig)
|
||||
|
||||
if (!savedConfig) {
|
||||
console.log('[RoomMemoryQuizProvider] No saved config for memory-quiz, using initialState')
|
||||
return initialState
|
||||
}
|
||||
|
||||
const merged = {
|
||||
...initialState,
|
||||
// Restore settings from saved config
|
||||
selectedCount: savedConfig.selectedCount ?? initialState.selectedCount,
|
||||
displayTime: savedConfig.displayTime ?? initialState.displayTime,
|
||||
selectedDifficulty: savedConfig.selectedDifficulty ?? initialState.selectedDifficulty,
|
||||
playMode: savedConfig.playMode ?? initialState.playMode,
|
||||
}
|
||||
console.log('[RoomMemoryQuizProvider] Merged state:', {
|
||||
selectedCount: merged.selectedCount,
|
||||
displayTime: merged.displayTime,
|
||||
selectedDifficulty: merged.selectedDifficulty,
|
||||
playMode: merged.playMode,
|
||||
})
|
||||
|
||||
return merged
|
||||
}, [roomData?.gameConfig])
|
||||
|
||||
// Arcade session integration WITH room sync
|
||||
const {
|
||||
state,
|
||||
sendMove,
|
||||
connected: _connected,
|
||||
exitSession,
|
||||
} = useArcadeSession<SorobanQuizState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // CRITICAL: Pass roomId for network sync across room members
|
||||
initialState: mergedInitialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// Clear local input when game phase changes or when game resets
|
||||
useEffect(() => {
|
||||
if (state.gamePhase !== 'input') {
|
||||
setLocalCurrentInput('')
|
||||
}
|
||||
}, [state.gamePhase])
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (state.prefixAcceptanceTimeout) {
|
||||
clearTimeout(state.prefixAcceptanceTimeout)
|
||||
}
|
||||
}
|
||||
}, [state.prefixAcceptanceTimeout])
|
||||
|
||||
// Detect state corruption/mismatch (e.g., game type mismatch between sessions)
|
||||
const hasStateCorruption =
|
||||
!state.quizCards ||
|
||||
!state.correctAnswers ||
|
||||
!state.foundNumbers ||
|
||||
!Array.isArray(state.quizCards)
|
||||
|
||||
// Computed values
|
||||
const isGameActive = state.gamePhase === 'display' || state.gamePhase === 'input'
|
||||
|
||||
// Build player metadata from room data and player map
|
||||
const buildPlayerMetadata = useCallback(() => {
|
||||
const playerOwnership = buildPlayerOwnershipFromRoomData(roomData)
|
||||
const metadata = buildPlayerMetadataUtil(activePlayers, playerOwnership, players, viewerId)
|
||||
return metadata
|
||||
}, [activePlayers, players, roomData, viewerId])
|
||||
|
||||
// Action creators - send moves to arcade session
|
||||
const startQuiz = useCallback(
|
||||
(quizCards: QuizCard[]) => {
|
||||
// Extract only serializable data (numbers) for server
|
||||
// React components can't be sent over Socket.IO
|
||||
const numbers = quizCards.map((card) => card.number)
|
||||
|
||||
// Build player metadata for multiplayer
|
||||
const playerMetadata = buildPlayerMetadata()
|
||||
|
||||
sendMove({
|
||||
type: 'START_QUIZ',
|
||||
playerId: TEAM_MOVE, // Team move - all players act together
|
||||
userId: viewerId || '', // User who initiated
|
||||
data: {
|
||||
numbers, // Send to server
|
||||
quizCards, // Keep for optimistic local update
|
||||
activePlayers, // Send active players list
|
||||
playerMetadata, // Send player display info
|
||||
},
|
||||
})
|
||||
},
|
||||
[viewerId, sendMove, activePlayers, buildPlayerMetadata]
|
||||
)
|
||||
|
||||
const nextCard = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'NEXT_CARD',
|
||||
playerId: TEAM_MOVE,
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [viewerId, sendMove])
|
||||
|
||||
const showInputPhase = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'SHOW_INPUT_PHASE',
|
||||
playerId: TEAM_MOVE,
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [viewerId, sendMove])
|
||||
|
||||
const acceptNumber = useCallback(
|
||||
(number: number) => {
|
||||
// Clear local input immediately
|
||||
setLocalCurrentInput('')
|
||||
|
||||
sendMove({
|
||||
type: 'ACCEPT_NUMBER',
|
||||
playerId: TEAM_MOVE, // Team move - can't identify specific player
|
||||
userId: viewerId || '', // User who guessed correctly
|
||||
data: { number },
|
||||
})
|
||||
},
|
||||
[viewerId, sendMove]
|
||||
)
|
||||
|
||||
const rejectNumber = useCallback(() => {
|
||||
// Clear local input immediately
|
||||
setLocalCurrentInput('')
|
||||
|
||||
sendMove({
|
||||
type: 'REJECT_NUMBER',
|
||||
playerId: TEAM_MOVE, // Team move - can't identify specific player
|
||||
userId: viewerId || '', // User who guessed incorrectly
|
||||
data: {},
|
||||
})
|
||||
}, [viewerId, sendMove])
|
||||
|
||||
const setInput = useCallback((input: string) => {
|
||||
// LOCAL ONLY - no network sync!
|
||||
// This makes typing instant with zero network lag
|
||||
setLocalCurrentInput(input)
|
||||
}, [])
|
||||
|
||||
const showResults = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'SHOW_RESULTS',
|
||||
playerId: TEAM_MOVE,
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [viewerId, sendMove])
|
||||
|
||||
const resetGame = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'RESET_QUIZ',
|
||||
playerId: TEAM_MOVE,
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [viewerId, sendMove])
|
||||
|
||||
const setConfig = useCallback(
|
||||
(field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode', value: any) => {
|
||||
console.log(`[RoomMemoryQuizProvider] setConfig called: ${field} = ${value}`)
|
||||
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId: TEAM_MOVE,
|
||||
userId: viewerId || '',
|
||||
data: { field, value },
|
||||
})
|
||||
|
||||
// Save setting to room's gameConfig for persistence
|
||||
// Settings are scoped by game name to preserve settings when switching games
|
||||
if (roomData?.id) {
|
||||
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
|
||||
console.log('[RoomMemoryQuizProvider] Current gameConfig:', currentGameConfig)
|
||||
|
||||
const currentMemoryQuizConfig =
|
||||
(currentGameConfig['memory-quiz'] as Record<string, any>) || {}
|
||||
console.log('[RoomMemoryQuizProvider] Current memory-quiz config:', currentMemoryQuizConfig)
|
||||
|
||||
const updatedConfig = {
|
||||
...currentGameConfig,
|
||||
'memory-quiz': {
|
||||
...currentMemoryQuizConfig,
|
||||
[field]: value,
|
||||
},
|
||||
}
|
||||
console.log('[RoomMemoryQuizProvider] Saving updated gameConfig:', updatedConfig)
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: updatedConfig,
|
||||
})
|
||||
} else {
|
||||
console.warn('[RoomMemoryQuizProvider] No roomData.id, cannot save config')
|
||||
}
|
||||
},
|
||||
[viewerId, sendMove, roomData?.id, roomData?.gameConfig, updateGameConfig]
|
||||
)
|
||||
|
||||
// Merge network state with local input state
|
||||
const mergedState = {
|
||||
...state,
|
||||
currentInput: localCurrentInput, // Override network state with local input
|
||||
}
|
||||
|
||||
// If state is corrupted, show error message instead of crashing
|
||||
if (hasStateCorruption) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
minHeight: '400px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '48px',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
⚠️
|
||||
</div>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '12px',
|
||||
color: '#dc2626',
|
||||
}}
|
||||
>
|
||||
Game State Mismatch
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '24px',
|
||||
maxWidth: '500px',
|
||||
}}
|
||||
>
|
||||
There's a mismatch between game types in this room. This usually happens when room members
|
||||
are playing different games.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
background: '#f9fafb',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
maxWidth: '500px',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
To fix this:
|
||||
</p>
|
||||
<ol
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
textAlign: 'left',
|
||||
paddingLeft: '20px',
|
||||
lineHeight: '1.6',
|
||||
}}
|
||||
>
|
||||
<li>Make sure all room members are on the same game page</li>
|
||||
<li>Try refreshing the page</li>
|
||||
<li>If the issue persists, leave and rejoin the room</li>
|
||||
</ol>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Determine if current user is the room creator (controls card timing)
|
||||
const isRoomCreator =
|
||||
roomData?.members.find((member) => member.userId === viewerId)?.isCreator || false
|
||||
|
||||
const contextValue: MemoryQuizContextValue = {
|
||||
state: mergedState,
|
||||
dispatch: () => {
|
||||
// No-op - replaced with action creators
|
||||
console.warn('dispatch() is deprecated in room mode, use action creators instead')
|
||||
},
|
||||
isGameActive,
|
||||
resetGame,
|
||||
exitSession,
|
||||
isRoomCreator, // Pass room creator flag to components
|
||||
// Expose action creators for components to use
|
||||
startQuiz,
|
||||
nextCard,
|
||||
showInputPhase,
|
||||
acceptNumber,
|
||||
rejectNumber,
|
||||
setInput,
|
||||
showResults,
|
||||
setConfig,
|
||||
}
|
||||
|
||||
return <MemoryQuizContext.Provider value={contextValue}>{children}</MemoryQuizContext.Provider>
|
||||
}
|
||||
|
||||
// Export the hook for this provider
|
||||
export { useMemoryQuiz } from './MemoryQuizContext'
|
||||
File diff suppressed because it is too large
Load Diff
138
apps/web/src/app/arcade/memory-quiz/reducer.ts
Normal file
138
apps/web/src/app/arcade/memory-quiz/reducer.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { QuizAction, SorobanQuizState } from './types'
|
||||
|
||||
export const initialState: SorobanQuizState = {
|
||||
cards: [],
|
||||
quizCards: [],
|
||||
correctAnswers: [],
|
||||
currentCardIndex: 0,
|
||||
displayTime: 2.0,
|
||||
selectedCount: 5,
|
||||
selectedDifficulty: 'easy', // Default to easy level
|
||||
foundNumbers: [],
|
||||
guessesRemaining: 0,
|
||||
currentInput: '',
|
||||
incorrectGuesses: 0,
|
||||
// Multiplayer state
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
playerScores: {},
|
||||
playMode: 'cooperative', // Default to cooperative
|
||||
numberFoundBy: {},
|
||||
// UI state
|
||||
gamePhase: 'setup',
|
||||
prefixAcceptanceTimeout: null,
|
||||
finishButtonsBound: false,
|
||||
wrongGuessAnimations: [],
|
||||
// Keyboard state (persistent across re-renders)
|
||||
hasPhysicalKeyboard: null,
|
||||
testingMode: false,
|
||||
showOnScreenKeyboard: false,
|
||||
}
|
||||
|
||||
export function quizReducer(state: SorobanQuizState, action: QuizAction): SorobanQuizState {
|
||||
switch (action.type) {
|
||||
case 'SET_CARDS':
|
||||
return { ...state, cards: action.cards }
|
||||
case 'SET_DISPLAY_TIME':
|
||||
return { ...state, displayTime: action.time }
|
||||
case 'SET_SELECTED_COUNT':
|
||||
return { ...state, selectedCount: action.count }
|
||||
case 'SET_DIFFICULTY':
|
||||
return { ...state, selectedDifficulty: action.difficulty }
|
||||
case 'SET_PLAY_MODE':
|
||||
return { ...state, playMode: action.playMode }
|
||||
case 'START_QUIZ':
|
||||
return {
|
||||
...state,
|
||||
quizCards: action.quizCards,
|
||||
correctAnswers: action.quizCards.map((card) => card.number),
|
||||
currentCardIndex: 0,
|
||||
foundNumbers: [],
|
||||
guessesRemaining: action.quizCards.length + Math.floor(action.quizCards.length / 2),
|
||||
gamePhase: 'display',
|
||||
}
|
||||
case 'NEXT_CARD':
|
||||
return { ...state, currentCardIndex: state.currentCardIndex + 1 }
|
||||
case 'SHOW_INPUT_PHASE':
|
||||
return { ...state, gamePhase: 'input' }
|
||||
case 'ACCEPT_NUMBER': {
|
||||
// In competitive mode, track which player guessed correctly
|
||||
const newPlayerScores = { ...state.playerScores }
|
||||
if (state.playMode === 'competitive' && action.playerId) {
|
||||
const currentScore = newPlayerScores[action.playerId] || { correct: 0, incorrect: 0 }
|
||||
newPlayerScores[action.playerId] = {
|
||||
...currentScore,
|
||||
correct: currentScore.correct + 1,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
foundNumbers: [...state.foundNumbers, action.number],
|
||||
currentInput: '',
|
||||
playerScores: newPlayerScores,
|
||||
}
|
||||
}
|
||||
case 'REJECT_NUMBER': {
|
||||
// In competitive mode, track which player guessed incorrectly
|
||||
const newPlayerScores = { ...state.playerScores }
|
||||
if (state.playMode === 'competitive' && action.playerId) {
|
||||
const currentScore = newPlayerScores[action.playerId] || { correct: 0, incorrect: 0 }
|
||||
newPlayerScores[action.playerId] = {
|
||||
...currentScore,
|
||||
incorrect: currentScore.incorrect + 1,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
guessesRemaining: state.guessesRemaining - 1,
|
||||
incorrectGuesses: state.incorrectGuesses + 1,
|
||||
currentInput: '',
|
||||
playerScores: newPlayerScores,
|
||||
}
|
||||
}
|
||||
case 'SET_INPUT':
|
||||
return { ...state, currentInput: action.input }
|
||||
case 'SET_PREFIX_TIMEOUT':
|
||||
return { ...state, prefixAcceptanceTimeout: action.timeout }
|
||||
case 'ADD_WRONG_GUESS_ANIMATION':
|
||||
return {
|
||||
...state,
|
||||
wrongGuessAnimations: [
|
||||
...state.wrongGuessAnimations,
|
||||
{
|
||||
number: action.number,
|
||||
id: `wrong-${action.number}-${Date.now()}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}
|
||||
case 'CLEAR_WRONG_GUESS_ANIMATIONS':
|
||||
return {
|
||||
...state,
|
||||
wrongGuessAnimations: [],
|
||||
}
|
||||
case 'SHOW_RESULTS':
|
||||
return { ...state, gamePhase: 'results' }
|
||||
case 'RESET_QUIZ':
|
||||
return {
|
||||
...initialState,
|
||||
cards: state.cards, // Preserve generated cards
|
||||
displayTime: state.displayTime,
|
||||
selectedCount: state.selectedCount,
|
||||
selectedDifficulty: state.selectedDifficulty,
|
||||
playMode: state.playMode, // Preserve play mode
|
||||
// Preserve keyboard state across resets
|
||||
hasPhysicalKeyboard: state.hasPhysicalKeyboard,
|
||||
testingMode: state.testingMode,
|
||||
showOnScreenKeyboard: state.showOnScreenKeyboard,
|
||||
}
|
||||
case 'SET_PHYSICAL_KEYBOARD':
|
||||
return { ...state, hasPhysicalKeyboard: action.hasKeyboard }
|
||||
case 'SET_TESTING_MODE':
|
||||
return { ...state, testingMode: action.enabled }
|
||||
case 'TOGGLE_ONSCREEN_KEYBOARD':
|
||||
return { ...state, showOnScreenKeyboard: !state.showOnScreenKeyboard }
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
105
apps/web/src/app/arcade/memory-quiz/types.ts
Normal file
105
apps/web/src/app/arcade/memory-quiz/types.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { PlayerMetadata } from '@/lib/arcade/player-ownership.client'
|
||||
|
||||
export interface QuizCard {
|
||||
number: number
|
||||
svgComponent: JSX.Element | null
|
||||
element: HTMLElement | null
|
||||
}
|
||||
|
||||
export interface PlayerScore {
|
||||
correct: number
|
||||
incorrect: number
|
||||
}
|
||||
|
||||
export interface SorobanQuizState {
|
||||
// Core game data
|
||||
cards: QuizCard[]
|
||||
quizCards: QuizCard[]
|
||||
correctAnswers: number[]
|
||||
|
||||
// Game progression
|
||||
currentCardIndex: number
|
||||
displayTime: number
|
||||
selectedCount: number
|
||||
selectedDifficulty: DifficultyLevel
|
||||
|
||||
// Input system state
|
||||
foundNumbers: number[]
|
||||
guessesRemaining: number
|
||||
currentInput: string
|
||||
incorrectGuesses: number
|
||||
|
||||
// Multiplayer state
|
||||
activePlayers: string[]
|
||||
playerMetadata: Record<string, PlayerMetadata>
|
||||
playerScores: Record<string, PlayerScore>
|
||||
playMode: 'cooperative' | 'competitive'
|
||||
numberFoundBy: Record<number, string> // Maps number to userId who found it
|
||||
|
||||
// UI state
|
||||
gamePhase: 'setup' | 'display' | 'input' | 'results'
|
||||
prefixAcceptanceTimeout: NodeJS.Timeout | null
|
||||
finishButtonsBound: boolean
|
||||
wrongGuessAnimations: Array<{
|
||||
number: number
|
||||
id: string
|
||||
timestamp: number
|
||||
}>
|
||||
|
||||
// Keyboard state (moved from InputPhase to persist across re-renders)
|
||||
hasPhysicalKeyboard: boolean | null
|
||||
testingMode: boolean
|
||||
showOnScreenKeyboard: boolean
|
||||
}
|
||||
|
||||
export type QuizAction =
|
||||
| { type: 'SET_CARDS'; cards: QuizCard[] }
|
||||
| { type: 'SET_DISPLAY_TIME'; time: number }
|
||||
| { type: 'SET_SELECTED_COUNT'; count: number }
|
||||
| { type: 'SET_DIFFICULTY'; difficulty: DifficultyLevel }
|
||||
| { type: 'SET_PLAY_MODE'; playMode: 'cooperative' | 'competitive' }
|
||||
| { type: 'START_QUIZ'; quizCards: QuizCard[] }
|
||||
| { type: 'NEXT_CARD' }
|
||||
| { type: 'SHOW_INPUT_PHASE' }
|
||||
| { type: 'ACCEPT_NUMBER'; number: number; playerId?: string }
|
||||
| { type: 'REJECT_NUMBER'; playerId?: string }
|
||||
| { type: 'ADD_WRONG_GUESS_ANIMATION'; number: number }
|
||||
| { type: 'CLEAR_WRONG_GUESS_ANIMATIONS' }
|
||||
| { type: 'SET_INPUT'; input: string }
|
||||
| { type: 'SET_PREFIX_TIMEOUT'; timeout: NodeJS.Timeout | null }
|
||||
| { type: 'SHOW_RESULTS' }
|
||||
| { type: 'RESET_QUIZ' }
|
||||
| { type: 'SET_PHYSICAL_KEYBOARD'; hasKeyboard: boolean | null }
|
||||
| { type: 'SET_TESTING_MODE'; enabled: boolean }
|
||||
| { type: 'TOGGLE_ONSCREEN_KEYBOARD' }
|
||||
|
||||
// Difficulty levels with progressive number ranges
|
||||
export const DIFFICULTY_LEVELS = {
|
||||
beginner: {
|
||||
name: 'Beginner',
|
||||
range: { min: 1, max: 9 },
|
||||
description: 'Single digits (1-9)',
|
||||
},
|
||||
easy: {
|
||||
name: 'Easy',
|
||||
range: { min: 10, max: 99 },
|
||||
description: 'Two digits (10-99)',
|
||||
},
|
||||
medium: {
|
||||
name: 'Medium',
|
||||
range: { min: 100, max: 499 },
|
||||
description: 'Three digits (100-499)',
|
||||
},
|
||||
hard: {
|
||||
name: 'Hard',
|
||||
range: { min: 500, max: 999 },
|
||||
description: 'Large numbers (500-999)',
|
||||
},
|
||||
expert: {
|
||||
name: 'Expert',
|
||||
range: { min: 1, max: 999 },
|
||||
description: 'Mixed range (1-999)',
|
||||
},
|
||||
} as const
|
||||
|
||||
export type DifficultyLevel = keyof typeof DIFFICULTY_LEVELS
|
||||
@@ -78,7 +78,7 @@ function ArcadeContent() {
|
||||
|
||||
function ArcadePageWithRedirect() {
|
||||
return (
|
||||
<PageWithNav navTitle="Champion Arena" navEmoji="🏟️" emphasizeGameContext={true}>
|
||||
<PageWithNav navTitle="Champion Arena" navEmoji="🏟️" emphasizePlayerSelection={true}>
|
||||
<ArcadeContent />
|
||||
</PageWithNav>
|
||||
)
|
||||
|
||||
@@ -1,70 +1,219 @@
|
||||
'use client'
|
||||
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { ModerationNotifications } from '@/components/nav/ModerationNotifications'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
|
||||
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
|
||||
import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProvider'
|
||||
import { MemoryQuizGame } from '../memory-quiz/components/MemoryQuizGame'
|
||||
import { RoomMemoryQuizProvider } from '../memory-quiz/context/RoomMemoryQuizProvider'
|
||||
import { GAMES_CONFIG } from '@/components/GameSelector'
|
||||
import type { GameType } from '@/components/GameSelector'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
|
||||
// Map GameType keys to internal game names
|
||||
const GAME_TYPE_TO_NAME: Record<GameType, string> = {
|
||||
'battle-arena': 'matching',
|
||||
'memory-quiz': 'memory-quiz',
|
||||
'complement-race': 'complement-race',
|
||||
'master-organizer': 'master-organizer',
|
||||
}
|
||||
|
||||
/**
|
||||
* /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
|
||||
*
|
||||
* Shows game selection when no game is set, then shows the game itself once selected.
|
||||
* URL never changes - it's always /arcade/room regardless of selection, setup, or gameplay.
|
||||
*
|
||||
* Note: We don't redirect to /arcade if no room exists to avoid navigation loops.
|
||||
* Instead, we show a friendly message with a link back to the Champion Arena.
|
||||
*
|
||||
* Note: ModerationNotifications is handled by PageWithNav inside each game component,
|
||||
* so we don't need to render it here.
|
||||
*/
|
||||
export default function RoomPage() {
|
||||
const { roomData, isLoading, moderationEvent, clearModerationEvent } = useRoomData()
|
||||
const router = useRouter()
|
||||
const { roomData, isLoading } = useRoomData()
|
||||
const { mutate: setRoomGame } = useSetRoomGame()
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Loading room...
|
||||
</div>
|
||||
<ModerationNotifications moderationEvent={moderationEvent} onClose={clearModerationEvent} />
|
||||
</>
|
||||
<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
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
// Show game selection if no game is set
|
||||
if (!roomData.gameName) {
|
||||
const handleGameSelect = (gameType: GameType) => {
|
||||
console.log('[RoomPage] handleGameSelect called with gameType:', gameType)
|
||||
|
||||
const gameConfig = GAMES_CONFIG[gameType]
|
||||
console.log('[RoomPage] Game config:', {
|
||||
name: gameConfig.name,
|
||||
available: gameConfig.available,
|
||||
})
|
||||
|
||||
if (gameConfig.available === false) {
|
||||
console.log('[RoomPage] Game not available, blocking selection')
|
||||
return // Don't allow selecting unavailable games
|
||||
}
|
||||
|
||||
// Map GameType to internal game name
|
||||
const internalGameName = GAME_TYPE_TO_NAME[gameType]
|
||||
console.log('[RoomPage] Mapping:', {
|
||||
gameType,
|
||||
internalGameName,
|
||||
mappingExists: !!internalGameName,
|
||||
})
|
||||
|
||||
console.log('[RoomPage] Calling setRoomGame with:', {
|
||||
roomId: roomData.id,
|
||||
gameName: internalGameName,
|
||||
preservingGameConfig: true,
|
||||
})
|
||||
|
||||
// Don't pass gameConfig - we want to preserve existing settings for all games
|
||||
setRoomGame({
|
||||
roomId: roomData.id,
|
||||
gameName: internalGameName,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Choose Game"
|
||||
navEmoji="🎮"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
gap: '1rem',
|
||||
}}
|
||||
padding: '4',
|
||||
})}
|
||||
>
|
||||
<div>No active room found</div>
|
||||
<a
|
||||
href="/arcade"
|
||||
style={{
|
||||
color: '#3b82f6',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '8',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Go to Champion Arena
|
||||
</a>
|
||||
Choose a Game
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: '1fr', md: 'repeat(2, 1fr)' },
|
||||
gap: '4',
|
||||
maxWidth: '800px',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{Object.entries(GAMES_CONFIG).map(([gameType, config]) => (
|
||||
<button
|
||||
key={gameType}
|
||||
onClick={() => handleGameSelect(gameType as GameType)}
|
||||
disabled={config.available === false}
|
||||
className={css({
|
||||
background: config.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: config.borderColor || 'blue.200',
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: config.available === false ? 'not-allowed' : 'pointer',
|
||||
opacity: config.available === false ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover:
|
||||
config.available === false
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{config.icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{config.name}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{config.description}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ModerationNotifications moderationEvent={moderationEvent} onClose={clearModerationEvent} />
|
||||
</>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,21 +221,27 @@ export default function RoomPage() {
|
||||
switch (roomData.gameName) {
|
||||
case 'matching':
|
||||
return (
|
||||
<>
|
||||
<RoomMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</RoomMemoryPairsProvider>
|
||||
<ModerationNotifications
|
||||
moderationEvent={moderationEvent}
|
||||
onClose={clearModerationEvent}
|
||||
/>
|
||||
</>
|
||||
<RoomMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</RoomMemoryPairsProvider>
|
||||
)
|
||||
|
||||
// TODO: Add other games (complement-race, memory-quiz, etc.)
|
||||
case 'memory-quiz':
|
||||
return (
|
||||
<RoomMemoryQuizProvider>
|
||||
<MemoryQuizGame />
|
||||
</RoomMemoryQuizProvider>
|
||||
)
|
||||
|
||||
// TODO: Add other games (complement-race, etc.)
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<PageWithNav
|
||||
navTitle="Game Not Available"
|
||||
navEmoji="⚠️"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -99,11 +254,7 @@ export default function RoomPage() {
|
||||
>
|
||||
Game "{roomData.gameName}" not yet supported
|
||||
</div>
|
||||
<ModerationNotifications
|
||||
moderationEvent={moderationEvent}
|
||||
onClose={clearModerationEvent}
|
||||
/>
|
||||
</>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function MemoryPairsGame() {
|
||||
navTitle={navTitle}
|
||||
navEmoji={navEmoji}
|
||||
gameName="matching"
|
||||
emphasizeGameContext={state.gamePhase === 'setup'}
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
currentPlayerId={state.currentPlayer}
|
||||
playerScores={state.scores}
|
||||
playerStreaks={state.consecutiveMatches}
|
||||
|
||||
@@ -21,7 +21,7 @@ function GamesPageContent() {
|
||||
const _handleGameClick = (gameType: string) => {
|
||||
// Navigate directly to games using the centralized game mode with Next.js router
|
||||
console.log('🔄 GamesPage: Navigating with Next.js router (no page reload)')
|
||||
if (gameType === 'memory-lightning') {
|
||||
if (gameType === 'memory-quiz') {
|
||||
router.push('/games/memory-quiz')
|
||||
} else if (gameType === 'battle-arena') {
|
||||
router.push('/games/matching')
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { io } from 'socket.io-client'
|
||||
import { useGetRoomByCode, useJoinRoom, useRoomData } from '@/hooks/useRoomData'
|
||||
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
|
||||
|
||||
@@ -276,21 +277,17 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
return
|
||||
}
|
||||
|
||||
if (room.accessMode === 'restricted') {
|
||||
setError('This room is invitation-only')
|
||||
return
|
||||
}
|
||||
|
||||
if (room.accessMode === 'approval-only') {
|
||||
setShowApprovalPrompt(true)
|
||||
return
|
||||
}
|
||||
|
||||
// For restricted rooms, try to join - the API will check for invitation
|
||||
// If user is in a different room, show confirmation
|
||||
if (roomData) {
|
||||
setShowConfirmation(true)
|
||||
} else {
|
||||
// Otherwise, auto-join (for open rooms)
|
||||
// Otherwise, auto-join (for open rooms and restricted rooms with invitation)
|
||||
handleJoin(room.id)
|
||||
}
|
||||
})
|
||||
@@ -351,6 +348,60 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Socket listener for approval notifications
|
||||
useEffect(() => {
|
||||
if (!approvalRequested || !targetRoomData) return
|
||||
|
||||
console.log('[Join Page] Setting up approval listener for room:', targetRoomData.id)
|
||||
|
||||
let socket: ReturnType<typeof io> | null = null
|
||||
|
||||
// Fetch viewer ID and set up socket
|
||||
const setupSocket = async () => {
|
||||
try {
|
||||
// Get current user's viewer ID
|
||||
const res = await fetch('/api/viewer')
|
||||
if (!res.ok) {
|
||||
console.error('[Join Page] Failed to get viewer ID')
|
||||
return
|
||||
}
|
||||
|
||||
const { viewerId } = await res.json()
|
||||
console.log('[Join Page] Got viewer ID:', viewerId)
|
||||
|
||||
// Connect socket
|
||||
socket = io({ path: '/api/socket' })
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('[Join Page] Socket connected, joining user channel')
|
||||
// Join user-specific channel to receive moderation events
|
||||
socket?.emit('join-user-channel', { userId: viewerId })
|
||||
})
|
||||
|
||||
socket.on('join-request-approved', (data: { roomId: string; requestId: string }) => {
|
||||
console.log('[Join Page] Request approved via socket!', data)
|
||||
if (data.roomId === targetRoomData.id) {
|
||||
console.log('[Join Page] Joining room automatically...')
|
||||
handleJoin(targetRoomData.id)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('[Join Page] Socket connection error:', error)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Join Page] Error setting up socket:', error)
|
||||
}
|
||||
}
|
||||
|
||||
setupSocket()
|
||||
|
||||
return () => {
|
||||
console.log('[Join Page] Cleaning up approval listener')
|
||||
socket?.disconnect()
|
||||
}
|
||||
}, [approvalRequested, targetRoomData, handleJoin])
|
||||
|
||||
// Only show error page for non-password and non-approval errors
|
||||
if (error && !showPasswordPrompt && !showApprovalPrompt) {
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { AbacusDisplayProvider } from '@soroban/abacus-react'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import { ToastProvider } from '@/components/common/ToastContext'
|
||||
import { FullscreenProvider } from '@/contexts/FullscreenContext'
|
||||
import { GameModeProvider } from '@/contexts/GameModeContext'
|
||||
import { UserProfileProvider } from '@/contexts/UserProfileContext'
|
||||
@@ -20,17 +21,19 @@ export function ClientProviders({ children }: ClientProvidersProps) {
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AbacusDisplayProvider>
|
||||
<AbacusSettingsSync />
|
||||
<UserProfileProvider>
|
||||
<GameModeProvider>
|
||||
<FullscreenProvider>
|
||||
{children}
|
||||
<DeploymentInfo />
|
||||
</FullscreenProvider>
|
||||
</GameModeProvider>
|
||||
</UserProfileProvider>
|
||||
</AbacusDisplayProvider>
|
||||
<ToastProvider>
|
||||
<AbacusDisplayProvider>
|
||||
<AbacusSettingsSync />
|
||||
<UserProfileProvider>
|
||||
<GameModeProvider>
|
||||
<FullscreenProvider>
|
||||
{children}
|
||||
<DeploymentInfo />
|
||||
</FullscreenProvider>
|
||||
</GameModeProvider>
|
||||
</UserProfileProvider>
|
||||
</AbacusDisplayProvider>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,10 +23,23 @@ export function GameCard({ gameType, config, variant = 'detailed', className }:
|
||||
}
|
||||
|
||||
const handleGameClick = () => {
|
||||
console.log(`[GameCard] Clicked on ${config.name}:`, {
|
||||
activePlayerCount,
|
||||
maxPlayers: config.maxPlayers,
|
||||
isGameAvailable: isGameAvailable(),
|
||||
configAvailable: config.available,
|
||||
willNavigate: isGameAvailable() && config.available !== false,
|
||||
url: config.url,
|
||||
})
|
||||
|
||||
if (isGameAvailable() && config.available !== false) {
|
||||
console.log('🔄 GameCard: Navigating with Next.js router (no page reload)')
|
||||
// Use Next.js router for client-side navigation - this preserves fullscreen!
|
||||
router.push(config.url)
|
||||
} else {
|
||||
console.warn('❌ GameCard: Navigation blocked', {
|
||||
reason: !isGameAvailable() ? 'Player count mismatch' : 'Game not available',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,16 +6,16 @@ import { GameCard } from './GameCard'
|
||||
|
||||
// Game configuration defining player limits
|
||||
export const GAMES_CONFIG = {
|
||||
'memory-lightning': {
|
||||
'memory-quiz': {
|
||||
name: 'Memory Lightning',
|
||||
fullName: 'Memory Lightning ⚡',
|
||||
maxPlayers: 1,
|
||||
maxPlayers: 4,
|
||||
description: 'Test your memory speed with rapid-fire abacus calculations',
|
||||
longDescription:
|
||||
'Challenge yourself with lightning-fast memory tests. Perfect your mental math skills with this intense solo experience.',
|
||||
'Challenge yourself or compete with friends in lightning-fast memory tests. Work together cooperatively or compete for the highest score!',
|
||||
url: '/arcade/memory-quiz',
|
||||
icon: '⚡',
|
||||
chips: ['⭐ Beginner Friendly', '🔥 Speed Challenge', '🧮 Abacus Focus'],
|
||||
chips: ['👥 Multiplayer', '🔥 Speed Challenge', '🧮 Abacus Focus'],
|
||||
color: 'green',
|
||||
gradient: 'linear-gradient(135deg, #dcfce7, #bbf7d0)',
|
||||
borderColor: 'green.200',
|
||||
|
||||
@@ -13,7 +13,7 @@ interface PageWithNavProps {
|
||||
navTitle?: string
|
||||
navEmoji?: string
|
||||
gameName?: 'matching' | 'memory-quiz' | 'complement-race' // Internal game name for API
|
||||
emphasizeGameContext?: boolean
|
||||
emphasizePlayerSelection?: boolean
|
||||
onExitSession?: () => void
|
||||
onSetup?: () => void
|
||||
onNewGame?: () => void
|
||||
@@ -28,7 +28,7 @@ export function PageWithNav({
|
||||
navTitle,
|
||||
navEmoji,
|
||||
gameName,
|
||||
emphasizeGameContext = false,
|
||||
emphasizePlayerSelection = false,
|
||||
onExitSession,
|
||||
onSetup,
|
||||
onNewGame,
|
||||
@@ -103,7 +103,7 @@ export function PageWithNav({
|
||||
? 'tournament'
|
||||
: 'none'
|
||||
|
||||
const shouldEmphasize = emphasizeGameContext && mounted
|
||||
const shouldEmphasize = emphasizePlayerSelection && mounted
|
||||
const showFullscreenSelection = shouldEmphasize && activePlayerCount === 0
|
||||
|
||||
// Compute arcade session info for display
|
||||
|
||||
235
apps/web/src/components/common/ToastContext.tsx
Normal file
235
apps/web/src/components/common/ToastContext.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import * as Toast from '@radix-ui/react-toast'
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
export interface ToastMessage {
|
||||
id: string
|
||||
type: 'success' | 'error' | 'info'
|
||||
title: string
|
||||
description?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
showToast: (toast: Omit<ToastMessage, 'id'>) => void
|
||||
showSuccess: (title: string, description?: string) => void
|
||||
showError: (title: string, description?: string) => void
|
||||
showInfo: (title: string, description?: string) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([])
|
||||
|
||||
const showToast = useCallback((toast: Omit<ToastMessage, 'id'>) => {
|
||||
const id = Math.random().toString(36).substring(7)
|
||||
setToasts((prev) => [...prev, { ...toast, id }])
|
||||
}, [])
|
||||
|
||||
const showSuccess = useCallback(
|
||||
(title: string, description?: string) => {
|
||||
showToast({ type: 'success', title, description, duration: 5000 })
|
||||
},
|
||||
[showToast]
|
||||
)
|
||||
|
||||
const showError = useCallback(
|
||||
(title: string, description?: string) => {
|
||||
showToast({ type: 'error', title, description, duration: 7000 })
|
||||
},
|
||||
[showToast]
|
||||
)
|
||||
|
||||
const showInfo = useCallback(
|
||||
(title: string, description?: string) => {
|
||||
showToast({ type: 'info', title, description, duration: 5000 })
|
||||
},
|
||||
[showToast]
|
||||
)
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const getToastStyles = (type: ToastMessage['type']) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return {
|
||||
background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.97), rgba(22, 163, 74, 0.97))',
|
||||
border: '2px solid rgba(34, 197, 94, 0.6)',
|
||||
icon: '✓',
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
background: 'linear-gradient(135deg, rgba(239, 68, 68, 0.97), rgba(220, 38, 38, 0.97))',
|
||||
border: '2px solid rgba(239, 68, 68, 0.6)',
|
||||
icon: '⚠',
|
||||
}
|
||||
case 'info':
|
||||
return {
|
||||
background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.97), rgba(37, 99, 235, 0.97))',
|
||||
border: '2px solid rgba(59, 130, 246, 0.6)',
|
||||
icon: 'ℹ',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast, showSuccess, showError, showInfo }}>
|
||||
{children}
|
||||
<Toast.Provider swipeDirection="right">
|
||||
{toasts.map((toast) => {
|
||||
const styles = getToastStyles(toast.type)
|
||||
return (
|
||||
<Toast.Root
|
||||
key={toast.id}
|
||||
open={true}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
removeToast(toast.id)
|
||||
}
|
||||
}}
|
||||
duration={toast.duration}
|
||||
style={{
|
||||
background: styles.background,
|
||||
border: styles.border,
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4)',
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
alignItems: 'flex-start',
|
||||
minWidth: '300px',
|
||||
maxWidth: '450px',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '20px', flexShrink: 0 }}>{styles.icon}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Toast.Title
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
marginBottom: toast.description ? '4px' : 0,
|
||||
}}
|
||||
>
|
||||
{toast.title}
|
||||
</Toast.Title>
|
||||
{toast.description && (
|
||||
<Toast.Description
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
>
|
||||
{toast.description}
|
||||
</Toast.Description>
|
||||
)}
|
||||
</div>
|
||||
<Toast.Close
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
color: 'white',
|
||||
fontSize: '16px',
|
||||
lineHeight: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</Toast.Close>
|
||||
</Toast.Root>
|
||||
)
|
||||
})}
|
||||
|
||||
<Toast.Viewport
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '80px',
|
||||
right: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
zIndex: 10001,
|
||||
maxWidth: '100vw',
|
||||
margin: 0,
|
||||
listStyle: 'none',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes toastSlideIn {
|
||||
from {
|
||||
transform: translateX(calc(100% + 25px));
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toastSlideOut {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(calc(100% + 25px));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toastHide {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-radix-toast-viewport] [data-state='open'] {
|
||||
animation: toastSlideIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
[data-radix-toast-viewport] [data-state='closed'] {
|
||||
animation: toastHide 100ms ease-in, toastSlideOut 200ms cubic-bezier(0.32, 0, 0.67, 0);
|
||||
}
|
||||
|
||||
[data-radix-toast-viewport] [data-swipe='move'] {
|
||||
transform: translateX(var(--radix-toast-swipe-move-x));
|
||||
}
|
||||
|
||||
[data-radix-toast-viewport] [data-swipe='cancel'] {
|
||||
transform: translateX(0);
|
||||
transition: transform 200ms ease-out;
|
||||
}
|
||||
|
||||
[data-radix-toast-viewport] [data-swipe='end'] {
|
||||
animation: toastSlideOut 100ms ease-out;
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</Toast.Provider>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useToast } from '@/components/common/ToastContext'
|
||||
import { InvitePlayersTab } from './InvitePlayersTab'
|
||||
import { PlayOnlineTab } from './PlayOnlineTab'
|
||||
import { addToRecentRooms } from './RecentRoomsList'
|
||||
@@ -41,6 +42,7 @@ export function AddPlayerButton({
|
||||
}: AddPlayerButtonProps) {
|
||||
const popoverRef = React.useRef<HTMLDivElement>(null)
|
||||
const router = useRouter()
|
||||
const { showError } = useToast()
|
||||
|
||||
// Use lifted state if provided, otherwise fallback to internal state
|
||||
const [internalShowPopover, setInternalShowPopover] = React.useState(false)
|
||||
@@ -60,22 +62,29 @@ export function AddPlayerButton({
|
||||
const { mutate: joinRoom } = useJoinRoom()
|
||||
const { mutateAsync: getRoomByCode } = useGetRoomByCode()
|
||||
|
||||
// Handler for creating a new room
|
||||
// Handler for creating a new room (without a game - game will be selected in room)
|
||||
const handleCreateRoom = () => {
|
||||
createRoom(
|
||||
{
|
||||
name: `${gameName} Room`,
|
||||
gameName: gameName,
|
||||
name: null, // Auto-generated from code
|
||||
gameName: null, // No game selected yet - will be chosen in room
|
||||
creatorName: 'Player',
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
// Popover stays open, switch to invite tab to share room code
|
||||
setActiveTab('invite')
|
||||
// Add to recent rooms
|
||||
addToRecentRooms({
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
gameName: data.gameName,
|
||||
})
|
||||
// Close popover and navigate to room to choose game
|
||||
setShowPopover(false)
|
||||
router.push('/arcade/room')
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to create room:', error)
|
||||
alert(`Failed to create room: ${error.message}`)
|
||||
showError('Failed to create room', error.message)
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -100,8 +109,9 @@ export function AddPlayerButton({
|
||||
gameName: data.room.gameName,
|
||||
})
|
||||
}
|
||||
// Close popover
|
||||
// Close popover and navigate to room
|
||||
setShowPopover(false)
|
||||
router.push('/arcade/room')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { io } from 'socket.io-client'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import type { schema } from '@/db'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
|
||||
export interface JoinRoomModalProps {
|
||||
/**
|
||||
@@ -32,6 +33,7 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
|
||||
const [roomInfo, setRoomInfo] = useState<schema.ArcadeRoom | null>(null)
|
||||
const [needsPassword, setNeedsPassword] = useState(false)
|
||||
const [needsApproval, setNeedsApproval] = useState(false)
|
||||
const [approvalRequested, setApprovalRequested] = useState(false)
|
||||
|
||||
const handleClose = () => {
|
||||
setCode('')
|
||||
@@ -41,6 +43,7 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
|
||||
setRoomInfo(null)
|
||||
setNeedsPassword(false)
|
||||
setNeedsApproval(false)
|
||||
setApprovalRequested(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
@@ -74,18 +77,14 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
|
||||
return
|
||||
}
|
||||
|
||||
if (room.accessMode === 'restricted') {
|
||||
setError('This room is invitation-only. Please ask the host for an invitation.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (room.accessMode === 'approval-only') {
|
||||
setNeedsApproval(true)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// For restricted rooms, try to join - the API will check for invitation
|
||||
|
||||
if (room.accessMode === 'password') {
|
||||
// Check if password is provided
|
||||
if (!needsPassword) {
|
||||
@@ -118,6 +117,8 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
|
||||
if (!roomInfo) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/arcade/rooms/${roomInfo.id}/join-requests`, {
|
||||
method: 'POST',
|
||||
@@ -129,16 +130,76 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
|
||||
throw new Error(errorData.error || 'Failed to request access')
|
||||
}
|
||||
|
||||
// Success!
|
||||
alert('Access request sent! The host will review your request.')
|
||||
handleClose()
|
||||
// Success! Show waiting state
|
||||
setApprovalRequested(true)
|
||||
setIsLoading(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to request access')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Socket listener for approval notifications
|
||||
useEffect(() => {
|
||||
if (!approvalRequested || !roomInfo) return
|
||||
|
||||
console.log('[JoinRoomModal] Setting up approval listener for room:', roomInfo.id)
|
||||
|
||||
let socket: ReturnType<typeof io> | null = null
|
||||
|
||||
// Fetch viewer ID and set up socket
|
||||
const setupSocket = async () => {
|
||||
try {
|
||||
// Get current user's viewer ID
|
||||
const res = await fetch('/api/viewer')
|
||||
if (!res.ok) {
|
||||
console.error('[JoinRoomModal] Failed to get viewer ID')
|
||||
return
|
||||
}
|
||||
|
||||
const { viewerId } = await res.json()
|
||||
console.log('[JoinRoomModal] Got viewer ID:', viewerId)
|
||||
|
||||
// Connect socket
|
||||
socket = io({ path: '/api/socket' })
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('[JoinRoomModal] Socket connected, joining user channel')
|
||||
// Join user-specific channel to receive moderation events
|
||||
socket?.emit('join-user-channel', { userId: viewerId })
|
||||
})
|
||||
|
||||
socket.on('join-request-approved', async (data: { roomId: string; requestId: string }) => {
|
||||
console.log('[JoinRoomModal] Request approved via socket!', data)
|
||||
if (data.roomId === roomInfo.id) {
|
||||
console.log('[JoinRoomModal] Joining room automatically...')
|
||||
try {
|
||||
await joinRoom(roomInfo.id)
|
||||
handleClose()
|
||||
onSuccess?.()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to join room')
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('[JoinRoomModal] Socket connection error:', error)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[JoinRoomModal] Error setting up socket:', error)
|
||||
}
|
||||
}
|
||||
|
||||
setupSocket()
|
||||
|
||||
return () => {
|
||||
console.log('[JoinRoomModal] Cleaning up approval listener')
|
||||
socket?.disconnect()
|
||||
}
|
||||
}, [approvalRequested, roomInfo, joinRoom, handleClose, onSuccess])
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose}>
|
||||
<div
|
||||
@@ -165,7 +226,9 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
|
||||
}}
|
||||
>
|
||||
{needsApproval
|
||||
? 'This room requires host approval. Send a request to join?'
|
||||
? approvalRequested
|
||||
? 'Your request has been sent to the room moderator.'
|
||||
: 'This room requires host approval. Send a request to join?'
|
||||
: needsPassword
|
||||
? 'This room is password protected'
|
||||
: 'Enter the 6-character room code'}
|
||||
@@ -174,110 +237,192 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
|
||||
{needsApproval ? (
|
||||
// Approval request UI
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
background: 'rgba(59, 130, 246, 0.1)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.3)',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<strong>{roomInfo?.name}</strong>
|
||||
</p>
|
||||
<p style={{ fontSize: '13px', color: 'rgba(156, 163, 175, 1)' }}>
|
||||
Code: {roomInfo?.code}
|
||||
</p>
|
||||
</div>
|
||||
{approvalRequested ? (
|
||||
// Waiting for approval state
|
||||
<>
|
||||
<div style={{ textAlign: 'center', marginBottom: '20px' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>⏳</div>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
color: 'rgba(96, 165, 250, 1)',
|
||||
}}
|
||||
>
|
||||
Waiting for Approval
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'rgba(248, 113, 113, 1)',
|
||||
marginBottom: '16px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
background: 'rgba(59, 130, 246, 0.1)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.3)',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<strong>{roomInfo?.name}</strong>
|
||||
</p>
|
||||
<p style={{ fontSize: '13px', color: 'rgba(156, 163, 175, 1)' }}>
|
||||
Code: {roomInfo?.code}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: 'rgba(75, 85, 99, 0.3)',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
border: '2px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '10px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isLoading) {
|
||||
<p
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'rgba(156, 163, 175, 1)',
|
||||
textAlign: 'center',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
You'll be able to join once the host approves your request. You can close this
|
||||
dialog and check back later.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
background: 'rgba(75, 85, 99, 0.3)',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
border: '2px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '10px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isLoading) {
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRequestAccess}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: isLoading
|
||||
? 'rgba(75, 85, 99, 0.3)'
|
||||
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
|
||||
color: isLoading ? 'rgba(156, 163, 175, 1)' : 'rgba(255, 255, 255, 1)',
|
||||
border: isLoading
|
||||
? '2px solid rgba(75, 85, 99, 0.5)'
|
||||
: '2px solid rgba(59, 130, 246, 0.6)',
|
||||
borderRadius: '10px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.background =
|
||||
'linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9))'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.background =
|
||||
'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Send Request'}
|
||||
</button>
|
||||
</div>
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// Initial request prompt
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
background: 'rgba(59, 130, 246, 0.1)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.3)',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<strong>{roomInfo?.name}</strong>
|
||||
</p>
|
||||
<p style={{ fontSize: '13px', color: 'rgba(156, 163, 175, 1)' }}>
|
||||
Code: {roomInfo?.code}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'rgba(248, 113, 113, 1)',
|
||||
marginBottom: '16px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: 'rgba(75, 85, 99, 0.3)',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
border: '2px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '10px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRequestAccess}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: isLoading
|
||||
? 'rgba(75, 85, 99, 0.3)'
|
||||
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
|
||||
color: isLoading ? 'rgba(156, 163, 175, 1)' : 'rgba(255, 255, 255, 1)',
|
||||
border: isLoading
|
||||
? '2px solid rgba(75, 85, 99, 0.5)'
|
||||
: '2px solid rgba(59, 130, 246, 0.6)',
|
||||
borderRadius: '10px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
opacity: isLoading ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.background =
|
||||
'linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9))'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.background =
|
||||
'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Send Request'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Standard join form
|
||||
@@ -323,7 +468,6 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
|
||||
}}
|
||||
placeholder="Enter password"
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px',
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import * as Toast from '@radix-ui/react-toast'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { useToast } from '@/components/common/ToastContext'
|
||||
import type { ModerationEvent } from '@/hooks/useRoomData'
|
||||
import { useJoinRoom } from '@/hooks/useRoomData'
|
||||
|
||||
@@ -25,8 +27,13 @@ export function ModerationNotifications({
|
||||
onClose,
|
||||
}: ModerationNotificationsProps) {
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const { showError } = useToast()
|
||||
const [showToast, setShowToast] = useState(false)
|
||||
const [showJoinRequestToast, setShowJoinRequestToast] = useState(false)
|
||||
const [isAcceptingInvitation, setIsAcceptingInvitation] = useState(false)
|
||||
const [isProcessingRequest, setIsProcessingRequest] = useState(false)
|
||||
const [requestError, setRequestError] = useState<string | null>(null)
|
||||
const { mutateAsync: joinRoom } = useJoinRoom()
|
||||
|
||||
// Handle report toast (for hosts)
|
||||
@@ -42,8 +49,94 @@ export function ModerationNotifications({
|
||||
}
|
||||
}, [moderationEvent, onClose])
|
||||
|
||||
// Handle join request toast (for hosts)
|
||||
useEffect(() => {
|
||||
if (moderationEvent?.type === 'join-request') {
|
||||
setShowJoinRequestToast(true)
|
||||
setRequestError(null) // Clear any previous errors
|
||||
} else {
|
||||
// Reset toast state when event is cleared or changes type
|
||||
setShowJoinRequestToast(false)
|
||||
setRequestError(null)
|
||||
}
|
||||
}, [moderationEvent])
|
||||
|
||||
// Handle approve join request
|
||||
const handleApprove = async () => {
|
||||
if (!moderationEvent?.data.requestId || !moderationEvent?.data.roomId) return
|
||||
|
||||
setIsProcessingRequest(true)
|
||||
setRequestError(null) // Clear any previous errors
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/arcade/rooms/${moderationEvent.data.roomId}/join-requests/${moderationEvent.data.requestId}/approve`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Failed to approve join request')
|
||||
}
|
||||
|
||||
// Close toast and event on success
|
||||
setShowJoinRequestToast(false)
|
||||
onClose()
|
||||
|
||||
// Invalidate join requests query to refresh the list
|
||||
queryClient.invalidateQueries({ queryKey: ['join-requests'] })
|
||||
} catch (error) {
|
||||
console.error('Failed to approve join request:', error)
|
||||
// Keep toast visible and show error message
|
||||
setRequestError(error instanceof Error ? error.message : 'Failed to approve request')
|
||||
} finally {
|
||||
setIsProcessingRequest(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deny join request
|
||||
const handleDeny = async () => {
|
||||
if (!moderationEvent?.data.requestId || !moderationEvent?.data.roomId) return
|
||||
|
||||
setIsProcessingRequest(true)
|
||||
setRequestError(null) // Clear any previous errors
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/arcade/rooms/${moderationEvent.data.roomId}/join-requests/${moderationEvent.data.requestId}/deny`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Failed to deny join request')
|
||||
}
|
||||
|
||||
// Close toast and event on success
|
||||
setShowJoinRequestToast(false)
|
||||
onClose()
|
||||
|
||||
// Invalidate join requests query to refresh the list
|
||||
queryClient.invalidateQueries({ queryKey: ['join-requests'] })
|
||||
} catch (error) {
|
||||
console.error('Failed to deny join request:', error)
|
||||
// Keep toast visible and show error message
|
||||
setRequestError(error instanceof Error ? error.message : 'Failed to deny request')
|
||||
} finally {
|
||||
setIsProcessingRequest(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Kicked modal
|
||||
if (moderationEvent?.type === 'kicked') {
|
||||
const isRetired = moderationEvent.data.reason?.includes('retired')
|
||||
|
||||
return (
|
||||
<Modal isOpen={true} onClose={() => {}}>
|
||||
<div
|
||||
@@ -54,7 +147,7 @@ export function ModerationNotifications({
|
||||
minWidth: '400px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>⚠️</div>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>{isRetired ? '🏁' : '⚠️'}</div>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
@@ -63,7 +156,7 @@ export function ModerationNotifications({
|
||||
color: 'rgba(253, 186, 116, 1)',
|
||||
}}
|
||||
>
|
||||
Kicked from Room
|
||||
{isRetired ? 'Room Retired' : 'Kicked from Room'}
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
@@ -72,10 +165,16 @@ export function ModerationNotifications({
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
You were kicked from the room by{' '}
|
||||
<strong style={{ color: 'rgba(253, 186, 116, 1)' }}>
|
||||
{moderationEvent.data.kickedBy}
|
||||
</strong>
|
||||
{isRetired ? (
|
||||
<>The room owner has retired this room and access has been closed</>
|
||||
) : (
|
||||
<>
|
||||
You were kicked from the room by{' '}
|
||||
<strong style={{ color: 'rgba(253, 186, 116, 1)' }}>
|
||||
{moderationEvent.data.kickedBy}
|
||||
</strong>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
@@ -84,7 +183,9 @@ export function ModerationNotifications({
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
You can rejoin if the host sends you a new invite
|
||||
{isRetired
|
||||
? 'Only the room owner can access retired rooms'
|
||||
: 'You can rejoin if the host sends you a new invite'}
|
||||
</p>
|
||||
|
||||
<button
|
||||
@@ -391,6 +492,255 @@ export function ModerationNotifications({
|
||||
)
|
||||
}
|
||||
|
||||
// Join request toast (for hosts)
|
||||
if (moderationEvent?.type === 'join-request') {
|
||||
return (
|
||||
<Toast.Provider swipeDirection="right" duration={Infinity}>
|
||||
<Toast.Root
|
||||
open={showJoinRequestToast}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setShowJoinRequestToast(false)
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(59, 130, 246, 0.97), rgba(37, 99, 235, 0.97))',
|
||||
border: '2px solid rgba(59, 130, 246, 0.6)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4)',
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
alignItems: 'flex-start',
|
||||
minWidth: '350px',
|
||||
maxWidth: '450px',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '24px', flexShrink: 0 }}>✋</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Toast.Title
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Join Request
|
||||
</Toast.Title>
|
||||
<Toast.Description
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
<strong>{moderationEvent.data.requesterName}</strong> wants to join your room
|
||||
</Toast.Description>
|
||||
|
||||
{/* Error message */}
|
||||
{requestError && (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
background: 'rgba(239, 68, 68, 0.2)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.4)',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'rgba(254, 202, 202, 1)',
|
||||
fontWeight: '600',
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
>
|
||||
⚠️ Error
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
>
|
||||
{requestError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isProcessingRequest}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeny()
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
background: isProcessingRequest
|
||||
? 'rgba(75, 85, 99, 0.3)'
|
||||
: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: isProcessingRequest ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
opacity: isProcessingRequest ? 0.5 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isProcessingRequest) {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isProcessingRequest) {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isProcessingRequest}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleApprove()
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
background: isProcessingRequest
|
||||
? 'rgba(75, 85, 99, 0.3)'
|
||||
: 'rgba(34, 197, 94, 0.9)',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(34, 197, 94, 0.8)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: isProcessingRequest ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
opacity: isProcessingRequest ? 0.5 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isProcessingRequest) {
|
||||
e.currentTarget.style.background = 'rgba(34, 197, 94, 1)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isProcessingRequest) {
|
||||
e.currentTarget.style.background = 'rgba(34, 197, 94, 0.9)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isProcessingRequest ? 'Processing...' : 'Approve'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Toast.Close
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
color: 'white',
|
||||
fontSize: '16px',
|
||||
lineHeight: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</Toast.Close>
|
||||
</Toast.Root>
|
||||
|
||||
<Toast.Viewport
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '80px',
|
||||
right: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
zIndex: 10001,
|
||||
maxWidth: '100vw',
|
||||
margin: 0,
|
||||
listStyle: 'none',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(calc(100% + 25px));
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOut {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(calc(100% + 25px));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hide {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-state='open'] {
|
||||
animation: slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
[data-state='closed'] {
|
||||
animation: hide 100ms ease-in, slideOut 200ms cubic-bezier(0.32, 0, 0.67, 0);
|
||||
}
|
||||
|
||||
[data-swipe='move'] {
|
||||
transform: translateX(var(--radix-toast-swipe-move-x));
|
||||
}
|
||||
|
||||
[data-swipe='cancel'] {
|
||||
transform: translateX(0);
|
||||
transition: transform 200ms ease-out;
|
||||
}
|
||||
|
||||
[data-swipe='end'] {
|
||||
animation: slideOut 100ms ease-out;
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</Toast.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Invitation modal
|
||||
if (moderationEvent?.type === 'invitation') {
|
||||
const invitationType = moderationEvent.data.invitationType
|
||||
@@ -486,7 +836,10 @@ export function ModerationNotifications({
|
||||
router.push('/arcade/room')
|
||||
} catch (error) {
|
||||
console.error('Failed to join room:', error)
|
||||
alert(error instanceof Error ? error.message : 'Failed to join room')
|
||||
showError(
|
||||
'Failed to join room',
|
||||
error instanceof Error ? error.message : undefined
|
||||
)
|
||||
setIsAcceptingInvitation(false)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -88,10 +88,16 @@ export function ModerationPanel({
|
||||
|
||||
// Settings state
|
||||
const [accessMode, setAccessMode] = useState<string>('open')
|
||||
const [originalAccessMode, setOriginalAccessMode] = useState<string>('open')
|
||||
const [roomPassword, setRoomPassword] = useState('')
|
||||
const [showPasswordInput, setShowPasswordInput] = useState(false)
|
||||
const [selectedNewOwner, setSelectedNewOwner] = useState<string>('')
|
||||
const [joinRequests, setJoinRequests] = useState<any[]>([])
|
||||
const [passwordCopied, setPasswordCopied] = useState(false)
|
||||
|
||||
// Inline feedback state
|
||||
const [successMessage, setSuccessMessage] = useState<string>('')
|
||||
const [errorMessage, setErrorMessage] = useState<string>('')
|
||||
|
||||
// Ban modal state
|
||||
const [showBanModal, setShowBanModal] = useState(false)
|
||||
@@ -180,8 +186,9 @@ export function ModerationPanel({
|
||||
}
|
||||
|
||||
// Success - member will be removed via socket update
|
||||
showSuccess('Player kicked from room')
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to kick player')
|
||||
showError(err instanceof Error ? err.message : 'Failed to kick player')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
@@ -218,8 +225,10 @@ export function ModerationPanel({
|
||||
const data = await bansRes.json()
|
||||
setBans(data.bans || [])
|
||||
}
|
||||
|
||||
showSuccess('Player banned from room')
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to ban player')
|
||||
showError(err instanceof Error ? err.message : 'Failed to ban player')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
setBanTargetUserId(null)
|
||||
@@ -255,8 +264,10 @@ export function ModerationPanel({
|
||||
const data = await historyRes.json()
|
||||
setHistoricalMembers(data.historicalMembers || [])
|
||||
}
|
||||
|
||||
showSuccess('Player unbanned')
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to unban player')
|
||||
showError(err instanceof Error ? err.message : 'Failed to unban player')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
@@ -292,9 +303,9 @@ export function ModerationPanel({
|
||||
setHistoricalMembers(data.historicalMembers || [])
|
||||
}
|
||||
|
||||
alert(`${userName} has been unbanned and invited back to the room!`)
|
||||
showSuccess(`${userName} has been unbanned and invited back to the room`)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to unban player')
|
||||
showError(err instanceof Error ? err.message : 'Failed to unban player')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
@@ -322,9 +333,9 @@ export function ModerationPanel({
|
||||
setHistoricalMembers(data.historicalMembers || [])
|
||||
}
|
||||
|
||||
alert(`Invitation sent to ${userName}!`)
|
||||
showSuccess(`Invitation sent to ${userName}`)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to send invitation')
|
||||
showError(err instanceof Error ? err.message : 'Failed to send invitation')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
@@ -336,11 +347,19 @@ export function ModerationPanel({
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
// Fetch current room data to get access mode
|
||||
// Fetch current room data to get access mode and password
|
||||
const roomRes = await fetch(`/api/arcade/rooms/${roomId}`)
|
||||
if (roomRes.ok) {
|
||||
const data = await roomRes.json()
|
||||
setAccessMode(data.room?.accessMode || 'open')
|
||||
const currentAccessMode = data.room?.accessMode || 'open'
|
||||
setAccessMode(currentAccessMode)
|
||||
setOriginalAccessMode(currentAccessMode)
|
||||
|
||||
// Set password field if room has a password and user is the creator
|
||||
if (currentAccessMode === 'password' && data.room?.displayPassword) {
|
||||
setRoomPassword(data.room.displayPassword)
|
||||
setShowPasswordInput(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch join requests if any
|
||||
@@ -377,11 +396,12 @@ export function ModerationPanel({
|
||||
throw new Error(errorData.error || 'Failed to update settings')
|
||||
}
|
||||
|
||||
alert('Room settings updated successfully!')
|
||||
showSuccess('Room settings updated successfully')
|
||||
setOriginalAccessMode(accessMode) // Update original to current
|
||||
setShowPasswordInput(false)
|
||||
setRoomPassword('')
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to update settings')
|
||||
showError(err instanceof Error ? err.message : 'Failed to update settings')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
@@ -409,10 +429,10 @@ export function ModerationPanel({
|
||||
throw new Error(errorData.error || 'Failed to transfer ownership')
|
||||
}
|
||||
|
||||
alert(`Ownership transferred to ${newOwner.displayName}!`)
|
||||
onClose() // Close panel since user is no longer host
|
||||
showSuccess(`Ownership transferred to ${newOwner.displayName}`)
|
||||
setTimeout(() => onClose(), 2000) // Close panel after showing message
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to transfer ownership')
|
||||
showError(err instanceof Error ? err.message : 'Failed to transfer ownership')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
@@ -437,9 +457,9 @@ export function ModerationPanel({
|
||||
setJoinRequests(data.requests || [])
|
||||
}
|
||||
|
||||
alert('Join request approved!')
|
||||
showSuccess('Join request approved')
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to approve request')
|
||||
showError(err instanceof Error ? err.message : 'Failed to approve request')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
@@ -463,16 +483,47 @@ export function ModerationPanel({
|
||||
const data = await requestsRes.json()
|
||||
setJoinRequests(data.requests || [])
|
||||
}
|
||||
|
||||
showSuccess('Join request denied')
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to deny request')
|
||||
showError(err instanceof Error ? err.message : 'Failed to deny request')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyPassword = async () => {
|
||||
if (!roomPassword) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(roomPassword)
|
||||
setPasswordCopied(true)
|
||||
setTimeout(() => setPasswordCopied(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy password:', err)
|
||||
showError('Failed to copy password to clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
// Utility functions for showing feedback
|
||||
const showSuccess = (message: string) => {
|
||||
setSuccessMessage(message)
|
||||
setErrorMessage('')
|
||||
setTimeout(() => setSuccessMessage(''), 5000)
|
||||
}
|
||||
|
||||
const showError = (message: string) => {
|
||||
setErrorMessage(message)
|
||||
setSuccessMessage('')
|
||||
setTimeout(() => setErrorMessage(''), 5000)
|
||||
}
|
||||
|
||||
const pendingReports = reports.filter((r) => r.status === 'pending')
|
||||
const otherMembers = members.filter((m) => m.userId !== currentUserId)
|
||||
|
||||
// Check if there are unsaved changes in settings
|
||||
const hasUnsavedAccessModeChanges = accessMode !== originalAccessMode
|
||||
|
||||
// Group reports by reported user ID
|
||||
const reportsByUser = pendingReports.reduce(
|
||||
(acc, report) => {
|
||||
@@ -513,6 +564,69 @@ export function ModerationPanel({
|
||||
Manage members, reports, and bans
|
||||
</p>
|
||||
|
||||
{/* Success/Error Messages */}
|
||||
{(successMessage || errorMessage) && (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
background: successMessage ? 'rgba(34, 197, 94, 0.1)' : 'rgba(239, 68, 68, 0.1)',
|
||||
border: successMessage
|
||||
? '1px solid rgba(34, 197, 94, 0.4)'
|
||||
: '1px solid rgba(239, 68, 68, 0.4)',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
animation: 'fadeIn 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>{successMessage ? '✓' : '⚠'}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: successMessage ? 'rgba(34, 197, 94, 1)' : 'rgba(239, 68, 68, 1)',
|
||||
}}
|
||||
>
|
||||
{successMessage || errorMessage}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSuccessMessage('')
|
||||
setErrorMessage('')
|
||||
}}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: successMessage ? 'rgba(34, 197, 94, 0.8)' : 'rgba(239, 68, 68, 0.8)',
|
||||
fontSize: '18px',
|
||||
cursor: 'pointer',
|
||||
padding: '0 4px',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = '1'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.opacity = '0.8'
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div
|
||||
style={{
|
||||
@@ -1333,77 +1447,203 @@ export function ModerationPanel({
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<select
|
||||
value={accessMode}
|
||||
onChange={(e) => {
|
||||
setAccessMode(e.target.value)
|
||||
setShowPasswordInput(e.target.value === 'password')
|
||||
}}
|
||||
{/* Access mode button grid */}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
border: '1px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '6px',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
fontSize: '14px',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '8px',
|
||||
marginBottom: '12px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value="open">🌐 Open - Anyone can join</option>
|
||||
<option value="password">🔑 Password Protected</option>
|
||||
<option value="approval-only">✋ Approval Required</option>
|
||||
<option value="restricted">🚫 Restricted - Invitation only</option>
|
||||
<option value="locked">🔒 Locked - No new members</option>
|
||||
<option value="retired">🏁 Retired - Room closed</option>
|
||||
</select>
|
||||
{[
|
||||
{ value: 'open', emoji: '🌐', label: 'Open', desc: 'Anyone' },
|
||||
{ value: 'password', emoji: '🔑', label: 'Password', desc: 'With key' },
|
||||
{ value: 'approval-only', emoji: '✋', label: 'Approval', desc: 'Request' },
|
||||
{
|
||||
value: 'restricted',
|
||||
emoji: '🚫',
|
||||
label: 'Restricted',
|
||||
desc: 'Invite only',
|
||||
},
|
||||
{ value: 'locked', emoji: '🔒', label: 'Locked', desc: 'No new members' },
|
||||
{ value: 'retired', emoji: '🏁', label: 'Retired', desc: 'Closed' },
|
||||
].map((mode) => (
|
||||
<button
|
||||
key={mode.value}
|
||||
type="button"
|
||||
disabled={actionLoading === 'update-settings'}
|
||||
onClick={() => {
|
||||
setAccessMode(mode.value)
|
||||
setShowPasswordInput(mode.value === 'password')
|
||||
}}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
background:
|
||||
accessMode === mode.value
|
||||
? 'rgba(253, 186, 116, 0.15)'
|
||||
: 'rgba(255, 255, 255, 0.05)',
|
||||
border:
|
||||
accessMode === mode.value
|
||||
? '2px solid rgba(253, 186, 116, 0.6)'
|
||||
: '2px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '8px',
|
||||
color:
|
||||
accessMode === mode.value
|
||||
? 'rgba(253, 186, 116, 1)'
|
||||
: 'rgba(209, 213, 219, 0.8)',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
cursor: actionLoading === 'update-settings' ? 'not-allowed' : 'pointer',
|
||||
opacity: actionLoading === 'update-settings' ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (actionLoading !== 'update-settings' && accessMode !== mode.value) {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.08)'
|
||||
e.currentTarget.style.borderColor = 'rgba(253, 186, 116, 0.4)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (accessMode !== mode.value) {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)'
|
||||
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '18px' }}>{mode.emoji}</span>
|
||||
<div style={{ textAlign: 'left', flex: 1, lineHeight: '1.2' }}>
|
||||
<div style={{ fontSize: '13px', fontWeight: '600' }}>{mode.label}</div>
|
||||
<div style={{ fontSize: '11px', opacity: 0.7 }}>{mode.desc}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Password input (conditional) */}
|
||||
{(accessMode === 'password' || showPasswordInput) && (
|
||||
<input
|
||||
type="text"
|
||||
value={roomPassword}
|
||||
onChange={(e) => setRoomPassword(e.target.value)}
|
||||
placeholder="Enter room password"
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: 'rgba(209, 213, 219, 0.8)',
|
||||
marginBottom: '6px',
|
||||
}}
|
||||
>
|
||||
Room Password
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={roomPassword}
|
||||
onChange={(e) => setRoomPassword(e.target.value)}
|
||||
placeholder="Enter password to share with guests"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 12px',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
border: '1px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '6px',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s ease',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(253, 186, 116, 0.6)'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyPassword}
|
||||
disabled={!roomPassword}
|
||||
title="Copy password to clipboard"
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
background: passwordCopied
|
||||
? 'rgba(34, 197, 94, 0.2)'
|
||||
: roomPassword
|
||||
? 'rgba(59, 130, 246, 0.2)'
|
||||
: 'rgba(75, 85, 99, 0.2)',
|
||||
color: passwordCopied
|
||||
? 'rgba(34, 197, 94, 1)'
|
||||
: roomPassword
|
||||
? 'rgba(59, 130, 246, 1)'
|
||||
: 'rgba(156, 163, 175, 1)',
|
||||
border: passwordCopied
|
||||
? '1px solid rgba(34, 197, 94, 0.4)'
|
||||
: roomPassword
|
||||
? '1px solid rgba(59, 130, 246, 0.4)'
|
||||
: '1px solid rgba(75, 85, 99, 0.3)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: roomPassword ? 'pointer' : 'not-allowed',
|
||||
opacity: roomPassword ? 1 : 0.5,
|
||||
transition: 'all 0.2s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (roomPassword && !passwordCopied) {
|
||||
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.3)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (roomPassword && !passwordCopied) {
|
||||
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.2)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{passwordCopied ? '✓ Copied!' : '📋 Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'rgba(156, 163, 175, 1)',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
Share this password with guests to allow them to join
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasUnsavedAccessModeChanges && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpdateAccessMode}
|
||||
disabled={actionLoading === 'update-settings'}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
border: '1px solid rgba(75, 85, 99, 0.5)',
|
||||
background:
|
||||
actionLoading === 'update-settings'
|
||||
? 'rgba(75, 85, 99, 0.3)'
|
||||
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
|
||||
color: 'white',
|
||||
border:
|
||||
actionLoading === 'update-settings'
|
||||
? '1px solid rgba(75, 85, 99, 0.5)'
|
||||
: '1px solid rgba(59, 130, 246, 0.6)',
|
||||
borderRadius: '6px',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
fontSize: '14px',
|
||||
marginBottom: '12px',
|
||||
fontWeight: '600',
|
||||
cursor: actionLoading === 'update-settings' ? 'not-allowed' : 'pointer',
|
||||
opacity: actionLoading === 'update-settings' ? 0.5 : 1,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{actionLoading === 'update-settings' ? 'Updating...' : 'Update Access Mode'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpdateAccessMode}
|
||||
disabled={actionLoading === 'update-settings'}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
background:
|
||||
actionLoading === 'update-settings'
|
||||
? 'rgba(75, 85, 99, 0.3)'
|
||||
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
|
||||
color: 'white',
|
||||
border:
|
||||
actionLoading === 'update-settings'
|
||||
? '1px solid rgba(75, 85, 99, 0.5)'
|
||||
: '1px solid rgba(59, 130, 246, 0.6)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: actionLoading === 'update-settings' ? 'not-allowed' : 'pointer',
|
||||
opacity: actionLoading === 'update-settings' ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{actionLoading === 'update-settings' ? 'Updating...' : 'Update Access Mode'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1613,23 +1853,44 @@ export function ModerationPanel({
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '20px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
onClick={hasUnsavedAccessModeChanges ? undefined : onClose}
|
||||
disabled={hasUnsavedAccessModeChanges}
|
||||
title={
|
||||
hasUnsavedAccessModeChanges
|
||||
? 'Please update access mode settings before closing'
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: 'rgba(75, 85, 99, 0.3)',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
border: '1px solid rgba(75, 85, 99, 0.5)',
|
||||
background: hasUnsavedAccessModeChanges
|
||||
? 'rgba(75, 85, 99, 0.2)'
|
||||
: 'rgba(75, 85, 99, 0.3)',
|
||||
color: hasUnsavedAccessModeChanges
|
||||
? 'rgba(156, 163, 175, 1)'
|
||||
: 'rgba(209, 213, 219, 1)',
|
||||
border: hasUnsavedAccessModeChanges
|
||||
? '1px solid rgba(251, 146, 60, 0.4)'
|
||||
: '1px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '10px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
cursor: hasUnsavedAccessModeChanges ? 'not-allowed' : 'pointer',
|
||||
opacity: hasUnsavedAccessModeChanges ? 0.6 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
|
||||
if (!hasUnsavedAccessModeChanges) {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
|
||||
} else {
|
||||
e.currentTarget.style.borderColor = 'rgba(251, 146, 60, 0.8)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
|
||||
if (!hasUnsavedAccessModeChanges) {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
|
||||
} else {
|
||||
e.currentTarget.style.borderColor = 'rgba(251, 146, 60, 0.4)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Close
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { PlayerTooltip } from './PlayerTooltip'
|
||||
import { ReportPlayerModal } from './ReportPlayerModal'
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useToast } from '@/components/common/ToastContext'
|
||||
import { useJoinRoom } from '@/hooks/useRoomData'
|
||||
|
||||
interface PendingInvitation {
|
||||
@@ -32,6 +33,7 @@ export interface PendingInvitationsProps {
|
||||
*/
|
||||
export function PendingInvitations({ onInvitationChange, currentRoomId }: PendingInvitationsProps) {
|
||||
const router = useRouter()
|
||||
const { showError } = useToast()
|
||||
const [invitations, setInvitations] = useState<PendingInvitation[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@@ -72,7 +74,7 @@ export function PendingInvitations({ onInvitationChange, currentRoomId }: Pendin
|
||||
onInvitationChange?.()
|
||||
} catch (error) {
|
||||
console.error('Failed to join room:', error)
|
||||
alert(error instanceof Error ? error.message : 'Failed to join room')
|
||||
showError('Failed to join room', error instanceof Error ? error.message : undefined)
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
@@ -97,7 +99,7 @@ export function PendingInvitations({ onInvitationChange, currentRoomId }: Pendin
|
||||
onInvitationChange?.()
|
||||
} catch (error) {
|
||||
console.error('Failed to decline invitation:', error)
|
||||
alert(error instanceof Error ? error.message : 'Failed to decline invitation')
|
||||
showError('Failed to decline invitation', error instanceof Error ? error.message : undefined)
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { EmojiPicker } from '../../app/games/matching/components/EmojiPicker'
|
||||
import { useGameMode } from '../../contexts/GameModeContext'
|
||||
import { generateUniquePlayerName } from '../../utils/playerNames'
|
||||
|
||||
interface PlayerConfigDialogProps {
|
||||
playerId: string
|
||||
@@ -48,6 +49,15 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
setShowEmojiPicker(false)
|
||||
}
|
||||
|
||||
const handleGenerateNewName = () => {
|
||||
const allPlayers = Array.from(players.values())
|
||||
const existingNames = allPlayers.filter((p) => p.id !== playerId).map((p) => p.name)
|
||||
const newName = generateUniquePlayerName(existingNames)
|
||||
|
||||
setLocalName(newName)
|
||||
updatePlayer(playerId, { name: newName })
|
||||
}
|
||||
|
||||
// Get player number for UI theming (first 4 players get special colors)
|
||||
const allPlayers = Array.from(players.values()).sort((a, b) => {
|
||||
const aTime =
|
||||
@@ -256,40 +266,89 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={localName}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="Player Name"
|
||||
maxLength={20}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
fontSize: '16px',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
outline: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = gradientColor
|
||||
e.currentTarget.style.boxShadow = `0 0 0 3px ${gradientColor}20`
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
marginTop: '4px',
|
||||
textAlign: 'right',
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{localName.length}/20 characters
|
||||
<div style={{ flex: 1 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={localName}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="Player Name"
|
||||
maxLength={20}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
fontSize: '16px',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
outline: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = gradientColor
|
||||
e.currentTarget.style.boxShadow = `0 0 0 3px ${gradientColor}20`
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
marginTop: '6px',
|
||||
}}
|
||||
>
|
||||
{localName.length}/20 characters
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerateNewName}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
color: 'white',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.05)'
|
||||
e.currentTarget.style.boxShadow = `0 4px 12px ${gradientColor}40`
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
title="Generate random name"
|
||||
>
|
||||
🎲
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
marginTop: '6px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Random name
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLeaveRoom, useRoomData } from '@/hooks/useRoomData'
|
||||
import { useClearRoomGame, useLeaveRoom, useRoomData } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
|
||||
import { CreateRoomModal } from './CreateRoomModal'
|
||||
@@ -62,6 +62,7 @@ export function RoomInfo({
|
||||
const { getRoomShareUrl, roomData } = useRoomData()
|
||||
const { data: currentUserId } = useViewerId()
|
||||
const { mutateAsync: leaveRoom } = useLeaveRoom()
|
||||
const { mutate: clearRoomGame } = useClearRoomGame()
|
||||
|
||||
// Use room display utility for consistent naming
|
||||
const displayName = joinCode
|
||||
@@ -403,6 +404,43 @@ export function RoomInfo({
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
|
||||
{/* Change Game - only show for host and only when a game is selected */}
|
||||
{isCurrentUserCreator && roomId && roomData?.gameName && (
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
if (roomId) {
|
||||
clearRoomGame(roomId)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '10px 14px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(236, 72, 153, 0.2)'
|
||||
e.currentTarget.style.color = 'rgba(249, 168, 212, 1)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
e.currentTarget.style.color = 'rgba(209, 213, 219, 1)'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>🔄</span>
|
||||
<span>Change Game</span>
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
|
||||
{/* Moderation - only show for host */}
|
||||
{isCurrentUserCreator && roomId && (
|
||||
<DropdownMenu.Item
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// Available character emojis for players
|
||||
export const PLAYER_EMOJIS = [
|
||||
// Abacus
|
||||
'🧮',
|
||||
|
||||
// People & Characters
|
||||
'😀',
|
||||
'😃',
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import type { Player as DBPlayer } from '@/db/schema/players'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import {
|
||||
useCreatePlayer,
|
||||
useDeletePlayer,
|
||||
useUpdatePlayer,
|
||||
useUserPlayers,
|
||||
} from '@/hooks/useUserPlayers'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { getNextPlayerColor } from '../types/player'
|
||||
import { generateUniquePlayerName, generateUniquePlayerNames } from '../utils/playerNames'
|
||||
|
||||
// Client-side Player type (compatible with old type)
|
||||
export interface Player {
|
||||
@@ -44,11 +45,12 @@ export interface GameModeContextType {
|
||||
const GameModeContext = createContext<GameModeContextType | null>(null)
|
||||
|
||||
// Default players to create if none exist
|
||||
const DEFAULT_PLAYERS = [
|
||||
{ name: 'Player 1', emoji: '😀', color: '#3b82f6' },
|
||||
{ name: 'Player 2', emoji: '😎', color: '#8b5cf6' },
|
||||
{ name: 'Player 3', emoji: '🤠', color: '#10b981' },
|
||||
{ name: 'Player 4', emoji: '🚀', color: '#f59e0b' },
|
||||
// Names are generated randomly on first initialization
|
||||
const DEFAULT_PLAYER_CONFIGS = [
|
||||
{ emoji: '😀', color: '#3b82f6' },
|
||||
{ emoji: '😎', color: '#8b5cf6' },
|
||||
{ emoji: '🤠', color: '#10b981' },
|
||||
{ emoji: '🚀', color: '#f59e0b' },
|
||||
]
|
||||
|
||||
// Convert DB player to client Player type
|
||||
@@ -139,14 +141,19 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isInitialized) {
|
||||
if (dbPlayers.length === 0) {
|
||||
// Create default players
|
||||
DEFAULT_PLAYERS.forEach((data, index) => {
|
||||
// Generate unique names for default players
|
||||
const generatedNames = generateUniquePlayerNames(DEFAULT_PLAYER_CONFIGS.length)
|
||||
|
||||
// Create default players with generated names
|
||||
DEFAULT_PLAYER_CONFIGS.forEach((config, index) => {
|
||||
createPlayer({
|
||||
...data,
|
||||
name: generatedNames[index],
|
||||
emoji: config.emoji,
|
||||
color: config.color,
|
||||
isActive: index === 0, // First player active by default
|
||||
})
|
||||
})
|
||||
console.log('✅ Created default players via API')
|
||||
console.log('✅ Created default players via API with auto-generated names:', generatedNames)
|
||||
} else {
|
||||
console.log('✅ Loaded players from API', {
|
||||
playerCount: dbPlayers.length,
|
||||
@@ -159,9 +166,10 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const addPlayer = (playerData?: Partial<Player>) => {
|
||||
const playerList = Array.from(players.values())
|
||||
const existingNames = playerList.map((p) => p.name)
|
||||
|
||||
const newPlayer = {
|
||||
name: playerData?.name ?? `Player ${players.size + 1}`,
|
||||
name: playerData?.name ?? generateUniquePlayerName(existingNames),
|
||||
emoji: playerData?.emoji ?? '🎮',
|
||||
color: playerData?.color ?? getNextPlayerColor(playerList),
|
||||
isActive: playerData?.isActive ?? false,
|
||||
@@ -246,10 +254,15 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
deletePlayer(player.id)
|
||||
})
|
||||
|
||||
// Create default players
|
||||
DEFAULT_PLAYERS.forEach((data, index) => {
|
||||
// Generate unique names for default players
|
||||
const generatedNames = generateUniquePlayerNames(DEFAULT_PLAYER_CONFIGS.length)
|
||||
|
||||
// Create default players with generated names
|
||||
DEFAULT_PLAYER_CONFIGS.forEach((config, index) => {
|
||||
createPlayer({
|
||||
...data,
|
||||
name: generatedNames[index],
|
||||
emoji: config.emoji,
|
||||
color: config.color,
|
||||
isActive: index === 0,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,12 +30,13 @@ export const arcadeRooms = sqliteTable('arcade_rooms', {
|
||||
.notNull()
|
||||
.default('open'),
|
||||
password: text('password', { length: 255 }), // Hashed password for password-protected rooms
|
||||
displayPassword: text('display_password', { length: 100 }), // Plain text password for display to room owner
|
||||
|
||||
// Game configuration
|
||||
// Game configuration (nullable to support game selection in room)
|
||||
gameName: text('game_name', {
|
||||
enum: ['matching', 'memory-quiz', 'complement-race'],
|
||||
}).notNull(),
|
||||
gameConfig: text('game_config', { mode: 'json' }).notNull(), // Game-specific settings
|
||||
}),
|
||||
gameConfig: text('game_config', { mode: 'json' }), // Game-specific settings (nullable when no game selected)
|
||||
|
||||
// Current state
|
||||
status: text('status', {
|
||||
|
||||
@@ -42,6 +42,7 @@ describe('useOptimisticGameState', () => {
|
||||
const move: GameMove = {
|
||||
type: 'INCREMENT',
|
||||
playerId: 'test',
|
||||
userId: 'test-user',
|
||||
timestamp: Date.now(),
|
||||
data: {},
|
||||
}
|
||||
@@ -65,6 +66,7 @@ describe('useOptimisticGameState', () => {
|
||||
const move: GameMove = {
|
||||
type: 'INCREMENT',
|
||||
playerId: 'test',
|
||||
userId: 'test-user',
|
||||
timestamp: 123,
|
||||
data: {},
|
||||
}
|
||||
@@ -100,6 +102,7 @@ describe('useOptimisticGameState', () => {
|
||||
const move: GameMove = {
|
||||
type: 'INCREMENT',
|
||||
playerId: 'test',
|
||||
userId: 'test-user',
|
||||
timestamp: 123,
|
||||
data: {},
|
||||
}
|
||||
@@ -133,6 +136,7 @@ describe('useOptimisticGameState', () => {
|
||||
const move1: GameMove = {
|
||||
type: 'INCREMENT',
|
||||
playerId: 'test',
|
||||
userId: 'test-user',
|
||||
timestamp: 123,
|
||||
data: {},
|
||||
}
|
||||
@@ -140,6 +144,7 @@ describe('useOptimisticGameState', () => {
|
||||
const move2: GameMove = {
|
||||
type: 'INCREMENT',
|
||||
playerId: 'test',
|
||||
userId: 'test-user',
|
||||
timestamp: 124,
|
||||
data: {},
|
||||
}
|
||||
@@ -184,6 +189,7 @@ describe('useOptimisticGameState', () => {
|
||||
result.current.applyOptimisticMove({
|
||||
type: 'INCREMENT',
|
||||
playerId: 'test',
|
||||
userId: 'test-user',
|
||||
timestamp: 123,
|
||||
data: {},
|
||||
})
|
||||
@@ -215,6 +221,7 @@ describe('useOptimisticGameState', () => {
|
||||
result.current.applyOptimisticMove({
|
||||
type: 'INCREMENT',
|
||||
playerId: 'test',
|
||||
userId: 'test-user',
|
||||
timestamp: 123,
|
||||
data: {},
|
||||
})
|
||||
@@ -245,6 +252,7 @@ describe('useOptimisticGameState', () => {
|
||||
const move: GameMove = {
|
||||
type: 'INCREMENT',
|
||||
playerId: 'test',
|
||||
userId: 'test-user',
|
||||
timestamp: 123,
|
||||
data: {},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import { useViewerId } from './useViewerId'
|
||||
|
||||
@@ -22,7 +22,8 @@ export interface RoomData {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
gameName: string
|
||||
gameName: string | null // Nullable to support game selection in room
|
||||
gameConfig?: Record<string, unknown> | null // Game-specific settings
|
||||
accessMode: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
|
||||
members: RoomMember[]
|
||||
memberPlayers: Record<string, RoomPlayer[]> // userId -> players
|
||||
@@ -30,7 +31,7 @@ export interface RoomData {
|
||||
|
||||
export interface CreateRoomParams {
|
||||
name: string | null
|
||||
gameName: string
|
||||
gameName?: string | null // Optional - rooms can be created without a game
|
||||
creatorName?: string
|
||||
gameConfig?: Record<string, unknown>
|
||||
accessMode?: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
|
||||
@@ -71,6 +72,7 @@ async function fetchCurrentRoom(): Promise<RoomData | null> {
|
||||
name: data.room.name,
|
||||
code: data.room.code,
|
||||
gameName: data.room.gameName,
|
||||
gameConfig: data.room.gameConfig || null,
|
||||
accessMode: data.room.accessMode || 'open',
|
||||
members: data.members || [],
|
||||
memberPlayers: data.memberPlayers || {},
|
||||
@@ -86,9 +88,9 @@ async function createRoomApi(params: CreateRoomParams): Promise<RoomData> {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: params.name,
|
||||
gameName: params.gameName,
|
||||
gameName: params.gameName || null,
|
||||
creatorName: params.creatorName || 'Player',
|
||||
gameConfig: params.gameConfig || { difficulty: 6 },
|
||||
gameConfig: params.gameConfig || null,
|
||||
accessMode: params.accessMode,
|
||||
password: params.password,
|
||||
}),
|
||||
@@ -105,6 +107,7 @@ async function createRoomApi(params: CreateRoomParams): Promise<RoomData> {
|
||||
name: data.room.name,
|
||||
code: data.room.code,
|
||||
gameName: data.room.gameName,
|
||||
gameConfig: data.room.gameConfig || null,
|
||||
accessMode: data.room.accessMode || 'open',
|
||||
members: data.members || [],
|
||||
memberPlayers: data.memberPlayers || {},
|
||||
@@ -141,6 +144,7 @@ async function joinRoomApi(params: {
|
||||
name: data.room.name,
|
||||
code: data.room.code,
|
||||
gameName: data.room.gameName,
|
||||
gameConfig: data.room.gameConfig || null,
|
||||
accessMode: data.room.accessMode || 'open',
|
||||
members: data.members || [],
|
||||
memberPlayers: data.memberPlayers || {},
|
||||
@@ -183,6 +187,7 @@ async function getRoomByCodeApi(code: string): Promise<RoomData> {
|
||||
name: data.room.name,
|
||||
code: data.room.code,
|
||||
gameName: data.room.gameName,
|
||||
gameConfig: data.room.gameConfig || null,
|
||||
accessMode: data.room.accessMode || 'open',
|
||||
members: data.members || [],
|
||||
memberPlayers: data.memberPlayers || {},
|
||||
@@ -190,7 +195,7 @@ async function getRoomByCodeApi(code: string): Promise<RoomData> {
|
||||
}
|
||||
|
||||
export interface ModerationEvent {
|
||||
type: 'kicked' | 'banned' | 'report' | 'invitation'
|
||||
type: 'kicked' | 'banned' | 'report' | 'invitation' | 'join-request'
|
||||
data: {
|
||||
roomId?: string
|
||||
kickedBy?: string
|
||||
@@ -206,6 +211,10 @@ export interface ModerationEvent {
|
||||
invitedByName?: string
|
||||
invitationType?: 'manual' | 'auto-unban' | 'auto-create'
|
||||
message?: string
|
||||
// Join request fields
|
||||
requestId?: string
|
||||
requesterId?: string
|
||||
requesterName?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,13 +352,13 @@ export function useRoomData() {
|
||||
}
|
||||
|
||||
// Moderation event handlers
|
||||
const handleKickedFromRoom = (data: { roomId: string; kickedBy: string }) => {
|
||||
console.log('[useRoomData] User was kicked from room:', data)
|
||||
const handleKickedFromRoom = (data: { roomId: string; kickedBy: string; reason?: string }) => {
|
||||
setModerationEvent({
|
||||
type: 'kicked',
|
||||
data: {
|
||||
roomId: data.roomId,
|
||||
kickedBy: data.kickedBy,
|
||||
reason: data.reason,
|
||||
},
|
||||
})
|
||||
// Clear room data since user was kicked
|
||||
@@ -357,7 +366,6 @@ export function useRoomData() {
|
||||
}
|
||||
|
||||
const handleBannedFromRoom = (data: { roomId: string; bannedBy: string; reason: string }) => {
|
||||
console.log('[useRoomData] User was banned from room:', data)
|
||||
setModerationEvent({
|
||||
type: 'banned',
|
||||
data: {
|
||||
@@ -381,7 +389,6 @@ export function useRoomData() {
|
||||
createdAt: Date
|
||||
}
|
||||
}) => {
|
||||
console.log('[useRoomData] New report submitted:', data)
|
||||
setModerationEvent({
|
||||
type: 'report',
|
||||
data: {
|
||||
@@ -406,7 +413,6 @@ export function useRoomData() {
|
||||
createdAt: Date
|
||||
}
|
||||
}) => {
|
||||
console.log('[useRoomData] Room invitation received:', data)
|
||||
setModerationEvent({
|
||||
type: 'invitation',
|
||||
data: {
|
||||
@@ -420,6 +426,45 @@ export function useRoomData() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleJoinRequestSubmitted = (data: {
|
||||
roomId: string
|
||||
request: {
|
||||
id: string
|
||||
userId: string
|
||||
userName: string
|
||||
createdAt: Date
|
||||
}
|
||||
}) => {
|
||||
setModerationEvent({
|
||||
type: 'join-request',
|
||||
data: {
|
||||
roomId: data.roomId,
|
||||
requestId: data.request.id,
|
||||
requesterId: data.request.userId,
|
||||
requesterName: data.request.userName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleRoomGameChanged = (data: {
|
||||
roomId: string
|
||||
gameName: string | null
|
||||
gameConfig?: Record<string, unknown>
|
||||
}) => {
|
||||
console.log('[useRoomData] Room game changed:', data)
|
||||
if (data.roomId === roomData?.id) {
|
||||
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
gameName: data.gameName,
|
||||
// Only update gameConfig if it was provided in the broadcast
|
||||
...(data.gameConfig !== undefined ? { gameConfig: data.gameConfig } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('room-joined', handleRoomJoined)
|
||||
socket.on('member-joined', handleMemberJoined)
|
||||
socket.on('member-left', handleMemberLeft)
|
||||
@@ -428,6 +473,8 @@ export function useRoomData() {
|
||||
socket.on('banned-from-room', handleBannedFromRoom)
|
||||
socket.on('report-submitted', handleReportSubmitted)
|
||||
socket.on('room-invitation-received', handleInvitationReceived)
|
||||
socket.on('join-request-submitted', handleJoinRequestSubmitted)
|
||||
socket.on('room-game-changed', handleRoomGameChanged)
|
||||
|
||||
return () => {
|
||||
socket.off('room-joined', handleRoomJoined)
|
||||
@@ -438,13 +485,14 @@ export function useRoomData() {
|
||||
socket.off('banned-from-room', handleBannedFromRoom)
|
||||
socket.off('report-submitted', handleReportSubmitted)
|
||||
socket.off('room-invitation-received', handleInvitationReceived)
|
||||
socket.off('join-request-submitted', handleJoinRequestSubmitted)
|
||||
socket.off('room-game-changed', handleRoomGameChanged)
|
||||
}
|
||||
}, [socket, roomData?.id, queryClient])
|
||||
|
||||
// Function to notify room members of player updates
|
||||
const notifyRoomOfPlayerUpdate = useCallback(() => {
|
||||
if (socket && roomData?.id && userId) {
|
||||
console.log('[useRoomData] Notifying room of player update')
|
||||
socket.emit('players-updated', { roomId: roomData.id, userId })
|
||||
}
|
||||
}, [socket, roomData?.id, userId])
|
||||
@@ -530,3 +578,162 @@ export function useGetRoomByCode() {
|
||||
mutationFn: getRoomByCodeApi,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Set game for a room
|
||||
*/
|
||||
async function setRoomGameApi(params: {
|
||||
roomId: string
|
||||
gameName: string
|
||||
gameConfig?: Record<string, unknown>
|
||||
}): Promise<void> {
|
||||
// Only include gameConfig in the request if it was explicitly provided
|
||||
// Otherwise, we preserve the existing gameConfig in the database
|
||||
const body: { gameName: string; gameConfig?: Record<string, unknown> } = {
|
||||
gameName: params.gameName,
|
||||
}
|
||||
|
||||
if (params.gameConfig !== undefined) {
|
||||
body.gameConfig = params.gameConfig
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/arcade/rooms/${params.roomId}/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to set room game')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Set game for a room
|
||||
*/
|
||||
export function useSetRoomGame() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: setRoomGameApi,
|
||||
onSuccess: (_, variables) => {
|
||||
// Update the cache with the new game
|
||||
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
gameName: variables.gameName,
|
||||
}
|
||||
})
|
||||
// Refetch to get the full updated room data
|
||||
queryClient.invalidateQueries({ queryKey: roomKeys.current() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear/reset game for a room (host only)
|
||||
* This only clears gameName (returns to game selection) but preserves gameConfig
|
||||
* so settings persist when the user selects a game again.
|
||||
*/
|
||||
async function clearRoomGameApi(roomId: string): Promise<void> {
|
||||
const response = await fetch(`/api/arcade/rooms/${roomId}/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gameName: null,
|
||||
// DO NOT send gameConfig: null - we want to preserve settings!
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to clear room game')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Clear/reset game for a room (returns to game selection screen)
|
||||
*/
|
||||
export function useClearRoomGame() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: clearRoomGameApi,
|
||||
onSuccess: () => {
|
||||
// Update the cache to clear the game
|
||||
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
gameName: null,
|
||||
}
|
||||
})
|
||||
// Refetch to get the full updated room data
|
||||
queryClient.invalidateQueries({ queryKey: roomKeys.current() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update game config for current room (game-specific settings)
|
||||
*/
|
||||
async function updateGameConfigApi(params: {
|
||||
roomId: string
|
||||
gameConfig: Record<string, unknown>
|
||||
}): Promise<void> {
|
||||
console.log(
|
||||
'[updateGameConfigApi] Sending PATCH to server:',
|
||||
JSON.stringify(
|
||||
{
|
||||
url: `/api/arcade/rooms/${params.roomId}/settings`,
|
||||
gameConfig: params.gameConfig,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
const response = await fetch(`/api/arcade/rooms/${params.roomId}/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gameConfig: params.gameConfig,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
console.error('[updateGameConfigApi] Server error:', JSON.stringify(errorData, null, 2))
|
||||
throw new Error(errorData.error || 'Failed to update game config')
|
||||
}
|
||||
|
||||
console.log('[updateGameConfigApi] Server responded OK')
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Update game config for current room
|
||||
* This allows games to persist their settings (e.g., difficulty, card count)
|
||||
*/
|
||||
export function useUpdateGameConfig() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateGameConfigApi,
|
||||
onSuccess: (_, variables) => {
|
||||
// Update the cache with the new gameConfig
|
||||
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
gameConfig: variables.gameConfig,
|
||||
}
|
||||
})
|
||||
console.log(
|
||||
'[useUpdateGameConfig] Updated cache with new gameConfig:',
|
||||
JSON.stringify(variables.gameConfig, null, 2)
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ describe('Arcade Session Integration', () => {
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: ['1'],
|
||||
playerMetadata: {},
|
||||
consecutiveMatches: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
@@ -76,6 +77,7 @@ describe('Arcade Session Integration', () => {
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
playerHovers: {},
|
||||
}
|
||||
|
||||
const session = await createArcadeSession({
|
||||
@@ -170,6 +172,7 @@ describe('Arcade Session Integration', () => {
|
||||
moves: 0,
|
||||
scores: { 1: 0 },
|
||||
activePlayers: ['1'],
|
||||
playerMetadata: {},
|
||||
consecutiveMatches: { 1: 0 },
|
||||
gameStartTime: Date.now(),
|
||||
gameEndTime: null,
|
||||
@@ -179,6 +182,7 @@ describe('Arcade Session Integration', () => {
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
playerHovers: {},
|
||||
}
|
||||
|
||||
await createArcadeSession({
|
||||
|
||||
@@ -52,38 +52,12 @@ describe('Orphaned Session Cleanup', () => {
|
||||
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()
|
||||
// NOTE: This test is no longer valid with roomId as primary key
|
||||
// roomId cannot be null since it's the primary key with a foreign key constraint
|
||||
// Orphaned sessions are now automatically cleaned up via CASCADE delete when room is deleted
|
||||
it.skip('should return undefined when session has no roomId', async () => {
|
||||
// This test scenario is impossible with the new schema where roomId is the primary key
|
||||
// and has a foreign key constraint with CASCADE delete
|
||||
})
|
||||
|
||||
it('should return undefined when session room has been deleted', async () => {
|
||||
|
||||
@@ -62,6 +62,7 @@ describe('Room Manager', () => {
|
||||
ttlMinutes: 60,
|
||||
accessMode: 'open',
|
||||
password: null,
|
||||
displayPassword: null,
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6 },
|
||||
status: 'lobby',
|
||||
|
||||
@@ -260,6 +260,7 @@ describe('session-manager', () => {
|
||||
type: 'FLIP_CARD',
|
||||
data: { cardId: '1' },
|
||||
playerId: '1',
|
||||
userId: mockUserId,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,7 @@
|
||||
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { db } from '@/db'
|
||||
import {
|
||||
roomBans,
|
||||
roomMembers,
|
||||
roomReports,
|
||||
type NewRoomBan,
|
||||
type NewRoomReport,
|
||||
} from '@/db/schema'
|
||||
import { roomBans, roomMembers, roomReports } from '@/db/schema'
|
||||
import { recordRoomMemberHistory } from './room-member-history'
|
||||
|
||||
/**
|
||||
|
||||
@@ -220,8 +220,10 @@ export async function applyGameMove(
|
||||
const validator = getValidator(session.currentGame as GameName)
|
||||
|
||||
console.log('[SessionManager] About to validate move:', {
|
||||
gameName: session.currentGame,
|
||||
moveType: move.type,
|
||||
playerId: move.playerId,
|
||||
moveData: move.type === 'SET_CONFIG' ? (move as any).data : undefined,
|
||||
gameStateCurrentPlayer: (session.gameState as any)?.currentPlayer,
|
||||
gameStateActivePlayers: (session.gameState as any)?.activePlayers,
|
||||
gameStatePhase: (session.gameState as any)?.gamePhase,
|
||||
|
||||
440
apps/web/src/lib/arcade/validation/MemoryQuizGameValidator.ts
Normal file
440
apps/web/src/lib/arcade/validation/MemoryQuizGameValidator.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* Server-side validator for memory-quiz game
|
||||
* Validates all game moves and state transitions
|
||||
*/
|
||||
|
||||
import type { DifficultyLevel, SorobanQuizState } from '@/app/arcade/memory-quiz/types'
|
||||
import type {
|
||||
GameValidator,
|
||||
MemoryQuizGameMove,
|
||||
MemoryQuizSetConfigMove,
|
||||
ValidationResult,
|
||||
} from './types'
|
||||
|
||||
export class MemoryQuizGameValidator
|
||||
implements GameValidator<SorobanQuizState, MemoryQuizGameMove>
|
||||
{
|
||||
validateMove(
|
||||
state: SorobanQuizState,
|
||||
move: MemoryQuizGameMove,
|
||||
context?: { userId?: string; playerOwnership?: Record<string, string> }
|
||||
): ValidationResult {
|
||||
switch (move.type) {
|
||||
case 'START_QUIZ':
|
||||
return this.validateStartQuiz(state, move.data)
|
||||
|
||||
case 'NEXT_CARD':
|
||||
return this.validateNextCard(state)
|
||||
|
||||
case 'SHOW_INPUT_PHASE':
|
||||
return this.validateShowInputPhase(state)
|
||||
|
||||
case 'ACCEPT_NUMBER':
|
||||
return this.validateAcceptNumber(state, move.data.number, move.userId)
|
||||
|
||||
case 'REJECT_NUMBER':
|
||||
return this.validateRejectNumber(state, move.userId)
|
||||
|
||||
case 'SET_INPUT':
|
||||
return this.validateSetInput(state, move.data.input)
|
||||
|
||||
case 'SHOW_RESULTS':
|
||||
return this.validateShowResults(state)
|
||||
|
||||
case 'RESET_QUIZ':
|
||||
return this.validateResetQuiz(state)
|
||||
|
||||
case 'SET_CONFIG': {
|
||||
const configMove = move as MemoryQuizSetConfigMove
|
||||
return this.validateSetConfig(state, configMove.data.field, configMove.data.value)
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
valid: false,
|
||||
error: `Unknown move type: ${(move as any).type}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateStartQuiz(state: SorobanQuizState, data: any): ValidationResult {
|
||||
// Can start quiz from setup or results phase
|
||||
if (state.gamePhase !== 'setup' && state.gamePhase !== 'results') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Can only start quiz from setup or results phase',
|
||||
}
|
||||
}
|
||||
|
||||
// Accept either numbers array (from network) or quizCards (from client)
|
||||
const numbers = data.numbers || data.quizCards?.map((c: any) => c.number)
|
||||
|
||||
if (!numbers || numbers.length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Quiz numbers are required',
|
||||
}
|
||||
}
|
||||
|
||||
// Create minimal quiz cards from numbers (server-side doesn't need React components)
|
||||
const quizCards = numbers.map((number: number) => ({
|
||||
number,
|
||||
svgComponent: null, // Not needed server-side
|
||||
element: null,
|
||||
}))
|
||||
|
||||
// Extract multiplayer data from move
|
||||
const activePlayers = data.activePlayers || state.activePlayers || []
|
||||
const playerMetadata = data.playerMetadata || state.playerMetadata || {}
|
||||
|
||||
// Initialize player scores for all active players (by userId)
|
||||
const uniqueUserIds = new Set<string>()
|
||||
for (const playerId of activePlayers) {
|
||||
const metadata = playerMetadata[playerId]
|
||||
if (metadata?.userId) {
|
||||
uniqueUserIds.add(metadata.userId)
|
||||
}
|
||||
}
|
||||
|
||||
const playerScores = Array.from(uniqueUserIds).reduce((acc: any, userId: string) => {
|
||||
acc[userId] = { correct: 0, incorrect: 0 }
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const newState: SorobanQuizState = {
|
||||
...state,
|
||||
quizCards,
|
||||
correctAnswers: numbers,
|
||||
currentCardIndex: 0,
|
||||
foundNumbers: [],
|
||||
guessesRemaining: numbers.length + Math.floor(numbers.length / 2),
|
||||
gamePhase: 'display',
|
||||
incorrectGuesses: 0,
|
||||
currentInput: '',
|
||||
wrongGuessAnimations: [],
|
||||
prefixAcceptanceTimeout: null,
|
||||
// Multiplayer state
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
playerScores,
|
||||
numberFoundBy: {},
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState,
|
||||
}
|
||||
}
|
||||
|
||||
private validateNextCard(state: SorobanQuizState): ValidationResult {
|
||||
// Must be in display phase
|
||||
if (state.gamePhase !== 'display') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'NEXT_CARD only valid in display phase',
|
||||
}
|
||||
}
|
||||
|
||||
const newState: SorobanQuizState = {
|
||||
...state,
|
||||
currentCardIndex: state.currentCardIndex + 1,
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState,
|
||||
}
|
||||
}
|
||||
|
||||
private validateShowInputPhase(state: SorobanQuizState): ValidationResult {
|
||||
// Must have shown all cards
|
||||
if (state.currentCardIndex < state.quizCards.length) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'All cards must be shown before input phase',
|
||||
}
|
||||
}
|
||||
|
||||
const newState: SorobanQuizState = {
|
||||
...state,
|
||||
gamePhase: 'input',
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState,
|
||||
}
|
||||
}
|
||||
|
||||
private validateAcceptNumber(
|
||||
state: SorobanQuizState,
|
||||
number: number,
|
||||
userId?: string
|
||||
): ValidationResult {
|
||||
// Must be in input phase
|
||||
if (state.gamePhase !== 'input') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'ACCEPT_NUMBER only valid in input phase',
|
||||
}
|
||||
}
|
||||
|
||||
// Number must be in correct answers
|
||||
console.log('[MemoryQuizValidator] Checking number:', {
|
||||
number,
|
||||
correctAnswers: state.correctAnswers,
|
||||
includes: state.correctAnswers.includes(number),
|
||||
})
|
||||
|
||||
if (!state.correctAnswers.includes(number)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Number is not a correct answer',
|
||||
}
|
||||
}
|
||||
|
||||
// Number must not be already found
|
||||
if (state.foundNumbers.includes(number)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Number already found',
|
||||
}
|
||||
}
|
||||
|
||||
// Update player scores (track by userId)
|
||||
const playerScores = state.playerScores || {}
|
||||
const newPlayerScores = { ...playerScores }
|
||||
const numberFoundBy = state.numberFoundBy || {}
|
||||
const newNumberFoundBy = { ...numberFoundBy }
|
||||
|
||||
if (userId) {
|
||||
const currentScore = newPlayerScores[userId] || { correct: 0, incorrect: 0 }
|
||||
newPlayerScores[userId] = {
|
||||
...currentScore,
|
||||
correct: currentScore.correct + 1,
|
||||
}
|
||||
// Track who found this number
|
||||
newNumberFoundBy[number] = userId
|
||||
}
|
||||
|
||||
const newState: SorobanQuizState = {
|
||||
...state,
|
||||
foundNumbers: [...state.foundNumbers, number],
|
||||
currentInput: '',
|
||||
playerScores: newPlayerScores,
|
||||
numberFoundBy: newNumberFoundBy,
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState,
|
||||
}
|
||||
}
|
||||
|
||||
private validateRejectNumber(state: SorobanQuizState, userId?: string): ValidationResult {
|
||||
// Must be in input phase
|
||||
if (state.gamePhase !== 'input') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'REJECT_NUMBER only valid in input phase',
|
||||
}
|
||||
}
|
||||
|
||||
// Must have guesses remaining
|
||||
if (state.guessesRemaining <= 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'No guesses remaining',
|
||||
}
|
||||
}
|
||||
|
||||
// Update player scores (track by userId)
|
||||
const playerScores = state.playerScores || {}
|
||||
const newPlayerScores = { ...playerScores }
|
||||
if (userId) {
|
||||
const currentScore = newPlayerScores[userId] || { correct: 0, incorrect: 0 }
|
||||
newPlayerScores[userId] = {
|
||||
...currentScore,
|
||||
incorrect: currentScore.incorrect + 1,
|
||||
}
|
||||
}
|
||||
|
||||
const newState: SorobanQuizState = {
|
||||
...state,
|
||||
guessesRemaining: state.guessesRemaining - 1,
|
||||
incorrectGuesses: state.incorrectGuesses + 1,
|
||||
currentInput: '',
|
||||
playerScores: newPlayerScores,
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState,
|
||||
}
|
||||
}
|
||||
|
||||
private validateSetInput(state: SorobanQuizState, input: string): ValidationResult {
|
||||
// Must be in input phase
|
||||
if (state.gamePhase !== 'input') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'SET_INPUT only valid in input phase',
|
||||
}
|
||||
}
|
||||
|
||||
// Input must be numeric
|
||||
if (input && !/^\d+$/.test(input)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Input must be numeric',
|
||||
}
|
||||
}
|
||||
|
||||
const newState: SorobanQuizState = {
|
||||
...state,
|
||||
currentInput: input,
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState,
|
||||
}
|
||||
}
|
||||
|
||||
private validateShowResults(state: SorobanQuizState): ValidationResult {
|
||||
// Can show results from input phase
|
||||
if (state.gamePhase !== 'input') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'SHOW_RESULTS only valid from input phase',
|
||||
}
|
||||
}
|
||||
|
||||
const newState: SorobanQuizState = {
|
||||
...state,
|
||||
gamePhase: 'results',
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState,
|
||||
}
|
||||
}
|
||||
|
||||
private validateResetQuiz(state: SorobanQuizState): ValidationResult {
|
||||
// Can reset from any phase
|
||||
const newState: SorobanQuizState = {
|
||||
...state,
|
||||
gamePhase: 'setup',
|
||||
quizCards: [],
|
||||
correctAnswers: [],
|
||||
currentCardIndex: 0,
|
||||
foundNumbers: [],
|
||||
guessesRemaining: 0,
|
||||
currentInput: '',
|
||||
incorrectGuesses: 0,
|
||||
wrongGuessAnimations: [],
|
||||
prefixAcceptanceTimeout: null,
|
||||
finishButtonsBound: false,
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
newState,
|
||||
}
|
||||
}
|
||||
|
||||
private validateSetConfig(
|
||||
state: SorobanQuizState,
|
||||
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode',
|
||||
value: any
|
||||
): ValidationResult {
|
||||
// Can only change config during setup phase
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Cannot change configuration outside of setup phase',
|
||||
}
|
||||
}
|
||||
|
||||
// Validate field-specific values
|
||||
switch (field) {
|
||||
case 'selectedCount':
|
||||
if (![2, 5, 8, 12, 15].includes(value)) {
|
||||
return { valid: false, error: `Invalid selectedCount: ${value}` }
|
||||
}
|
||||
break
|
||||
|
||||
case 'displayTime':
|
||||
if (typeof value !== 'number' || value < 0.5 || value > 10) {
|
||||
return { valid: false, error: `Invalid displayTime: ${value}` }
|
||||
}
|
||||
break
|
||||
|
||||
case 'selectedDifficulty':
|
||||
if (!['beginner', 'easy', 'medium', 'hard', 'expert'].includes(value)) {
|
||||
return { valid: false, error: `Invalid selectedDifficulty: ${value}` }
|
||||
}
|
||||
break
|
||||
|
||||
case 'playMode':
|
||||
if (!['cooperative', 'competitive'].includes(value)) {
|
||||
return { valid: false, error: `Invalid playMode: ${value}` }
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
return { valid: false, error: `Unknown config field: ${field}` }
|
||||
}
|
||||
|
||||
// Apply the configuration change
|
||||
return {
|
||||
valid: true,
|
||||
newState: {
|
||||
...state,
|
||||
[field]: value,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
isGameComplete(state: SorobanQuizState): boolean {
|
||||
return state.gamePhase === 'results'
|
||||
}
|
||||
|
||||
getInitialState(config: {
|
||||
selectedCount: number
|
||||
displayTime: number
|
||||
selectedDifficulty: DifficultyLevel
|
||||
}): SorobanQuizState {
|
||||
return {
|
||||
cards: [],
|
||||
quizCards: [],
|
||||
correctAnswers: [],
|
||||
currentCardIndex: 0,
|
||||
displayTime: config.displayTime,
|
||||
selectedCount: config.selectedCount,
|
||||
selectedDifficulty: config.selectedDifficulty,
|
||||
foundNumbers: [],
|
||||
guessesRemaining: 0,
|
||||
currentInput: '',
|
||||
incorrectGuesses: 0,
|
||||
// Multiplayer state
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
playerScores: {},
|
||||
playMode: 'cooperative',
|
||||
numberFoundBy: {},
|
||||
// UI state
|
||||
gamePhase: 'setup',
|
||||
prefixAcceptanceTimeout: null,
|
||||
finishButtonsBound: false,
|
||||
wrongGuessAnimations: [],
|
||||
hasPhysicalKeyboard: null,
|
||||
testingMode: false,
|
||||
showOnScreenKeyboard: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const memoryQuizGameValidator = new MemoryQuizGameValidator()
|
||||
@@ -4,10 +4,12 @@
|
||||
*/
|
||||
|
||||
import { matchingGameValidator } from './MatchingGameValidator'
|
||||
import { memoryQuizGameValidator } from './MemoryQuizGameValidator'
|
||||
import type { GameName, GameValidator } from './types'
|
||||
|
||||
const validators = new Map<GameName, GameValidator>([
|
||||
['matching', matchingGameValidator],
|
||||
['memory-quiz', memoryQuizGameValidator],
|
||||
// Add other game validators here as they're implemented
|
||||
])
|
||||
|
||||
@@ -20,4 +22,5 @@ export function getValidator(gameName: GameName): GameValidator {
|
||||
}
|
||||
|
||||
export { matchingGameValidator } from './MatchingGameValidator'
|
||||
export { memoryQuizGameValidator } from './MemoryQuizGameValidator'
|
||||
export * from './types'
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { MemoryPairsState } from '@/app/games/matching/context/types'
|
||||
import type { SorobanQuizState } from '@/app/arcade/memory-quiz/types'
|
||||
|
||||
export type GameName = 'matching' | 'memory-quiz' | 'complement-race'
|
||||
|
||||
@@ -13,9 +14,17 @@ export interface ValidationResult {
|
||||
newState?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel value for team moves where no specific player can be identified
|
||||
* Used in free-for-all games where all of a user's players act as a team
|
||||
*/
|
||||
export const TEAM_MOVE = '__TEAM__' as const
|
||||
export type TeamMoveSentinel = typeof TEAM_MOVE
|
||||
|
||||
export interface GameMove {
|
||||
type: string
|
||||
playerId: string
|
||||
playerId: string | TeamMoveSentinel // Individual player (turn-based) or __TEAM__ (free-for-all)
|
||||
userId: string // Room member/viewer who made the move
|
||||
timestamp: number
|
||||
data: unknown
|
||||
}
|
||||
@@ -77,8 +86,74 @@ export type MatchingGameMove =
|
||||
| MatchingResumeGameMove
|
||||
| MatchingHoverCardMove
|
||||
|
||||
// Memory Quiz game specific moves
|
||||
export interface MemoryQuizStartQuizMove extends GameMove {
|
||||
type: 'START_QUIZ'
|
||||
data: {
|
||||
quizCards: any[] // QuizCard type from memory-quiz types
|
||||
}
|
||||
}
|
||||
|
||||
export interface MemoryQuizNextCardMove extends GameMove {
|
||||
type: 'NEXT_CARD'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface MemoryQuizShowInputPhaseMove extends GameMove {
|
||||
type: 'SHOW_INPUT_PHASE'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface MemoryQuizAcceptNumberMove extends GameMove {
|
||||
type: 'ACCEPT_NUMBER'
|
||||
data: {
|
||||
number: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface MemoryQuizRejectNumberMove extends GameMove {
|
||||
type: 'REJECT_NUMBER'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface MemoryQuizSetInputMove extends GameMove {
|
||||
type: 'SET_INPUT'
|
||||
data: {
|
||||
input: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface MemoryQuizShowResultsMove extends GameMove {
|
||||
type: 'SHOW_RESULTS'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface MemoryQuizResetQuizMove extends GameMove {
|
||||
type: 'RESET_QUIZ'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface MemoryQuizSetConfigMove extends GameMove {
|
||||
type: 'SET_CONFIG'
|
||||
data: {
|
||||
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode'
|
||||
value: any
|
||||
}
|
||||
}
|
||||
|
||||
export type MemoryQuizGameMove =
|
||||
| MemoryQuizStartQuizMove
|
||||
| MemoryQuizNextCardMove
|
||||
| MemoryQuizShowInputPhaseMove
|
||||
| MemoryQuizAcceptNumberMove
|
||||
| MemoryQuizRejectNumberMove
|
||||
| MemoryQuizSetInputMove
|
||||
| MemoryQuizShowResultsMove
|
||||
| MemoryQuizResetQuizMove
|
||||
| MemoryQuizSetConfigMove
|
||||
|
||||
// Generic game state union
|
||||
export type GameState = MemoryPairsState // Add other game states as union later
|
||||
export type GameState = MemoryPairsState | SorobanQuizState // Add other game states as union later
|
||||
|
||||
/**
|
||||
* Validation context for authorization checks
|
||||
|
||||
@@ -14,7 +14,7 @@ import { createRoom, getRoomById } from './lib/arcade/room-manager'
|
||||
import { getRoomMembers, getUserRooms, setMemberOnline } from './lib/arcade/room-membership'
|
||||
import { getRoomActivePlayers, getRoomPlayerIds } from './lib/arcade/player-manager'
|
||||
import type { GameMove, GameName } from './lib/arcade/validation'
|
||||
import { matchingGameValidator } from './lib/arcade/validation/MatchingGameValidator'
|
||||
import { getValidator } from './lib/arcade/validation'
|
||||
|
||||
// Use globalThis to store socket.io instance to avoid module isolation issues
|
||||
// This ensures the same instance is accessible across dynamic imports
|
||||
@@ -76,12 +76,29 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
const roomPlayerIds = await getRoomPlayerIds(roomId)
|
||||
console.log('[join-arcade-session] Room active players:', roomPlayerIds)
|
||||
|
||||
// Get initial state from validator (starts in "setup" phase)
|
||||
const initialState = matchingGameValidator.getInitialState({
|
||||
difficulty: (room.gameConfig as any)?.difficulty || 6,
|
||||
gameType: (room.gameConfig as any)?.gameType || 'abacus-numeral',
|
||||
turnTimer: (room.gameConfig as any)?.turnTimer || 30,
|
||||
})
|
||||
// Get initial state from the correct validator based on game type
|
||||
console.log('[join-arcade-session] Room game name:', room.gameName)
|
||||
const validator = getValidator(room.gameName as GameName)
|
||||
console.log('[join-arcade-session] Got validator for:', room.gameName)
|
||||
|
||||
// Different games have different initial configs
|
||||
let initialState: any
|
||||
if (room.gameName === 'matching') {
|
||||
initialState = validator.getInitialState({
|
||||
difficulty: (room.gameConfig as any)?.difficulty || 6,
|
||||
gameType: (room.gameConfig as any)?.gameType || 'abacus-numeral',
|
||||
turnTimer: (room.gameConfig as any)?.turnTimer || 30,
|
||||
})
|
||||
} else if (room.gameName === 'memory-quiz') {
|
||||
initialState = validator.getInitialState({
|
||||
selectedCount: (room.gameConfig as any)?.selectedCount || 5,
|
||||
displayTime: (room.gameConfig as any)?.displayTime || 2.0,
|
||||
selectedDifficulty: (room.gameConfig as any)?.selectedDifficulty || 'easy',
|
||||
})
|
||||
} else {
|
||||
// Fallback for other games
|
||||
initialState = validator.getInitialState(room.gameConfig || {})
|
||||
}
|
||||
|
||||
session = await createArcadeSession({
|
||||
userId,
|
||||
@@ -162,8 +179,9 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get initial state from validator
|
||||
const initialState = matchingGameValidator.getInitialState({
|
||||
// Get initial state from validator (this code path is matching-game specific)
|
||||
const matchingValidator = getValidator('matching')
|
||||
const initialState = matchingValidator.getInitialState({
|
||||
difficulty: 6,
|
||||
gameType: 'abacus-numeral',
|
||||
turnTimer: 30,
|
||||
|
||||
92
apps/web/src/utils/__tests__/playerNames.test.ts
Normal file
92
apps/web/src/utils/__tests__/playerNames.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
generatePlayerName,
|
||||
generateUniquePlayerName,
|
||||
generateUniquePlayerNames,
|
||||
} from '../playerNames'
|
||||
|
||||
describe('playerNames', () => {
|
||||
describe('generatePlayerName', () => {
|
||||
it('should generate a player name with adjective and noun', () => {
|
||||
const name = generatePlayerName()
|
||||
expect(name).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/) // e.g., "Swift Ninja"
|
||||
expect(name.split(' ')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should generate different names on multiple calls', () => {
|
||||
const names = new Set()
|
||||
// Generate 50 names and expect at least some variety
|
||||
for (let i = 0; i < 50; i++) {
|
||||
names.add(generatePlayerName())
|
||||
}
|
||||
// With 50 adjectives and 50 nouns, we should get many unique combinations
|
||||
expect(names.size).toBeGreaterThan(30)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateUniquePlayerName', () => {
|
||||
it('should generate a unique name not in existing names', () => {
|
||||
const existingNames = ['Swift Ninja', 'Cosmic Wizard', 'Radiant Dragon']
|
||||
const newName = generateUniquePlayerName(existingNames)
|
||||
|
||||
expect(existingNames).not.toContain(newName)
|
||||
})
|
||||
|
||||
it('should be case-insensitive when checking uniqueness', () => {
|
||||
const existingNames = ['swift ninja', 'COSMIC WIZARD']
|
||||
const newName = generateUniquePlayerName(existingNames)
|
||||
|
||||
expect(existingNames.map((n) => n.toLowerCase())).not.toContain(newName.toLowerCase())
|
||||
})
|
||||
|
||||
it('should handle empty existing names array', () => {
|
||||
const name = generateUniquePlayerName([])
|
||||
expect(name).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
|
||||
})
|
||||
|
||||
it('should append number if all combinations are exhausted', () => {
|
||||
// Create a mock with limited attempts
|
||||
const existingNames = ['Swift Ninja']
|
||||
const name = generateUniquePlayerName(existingNames, 1)
|
||||
|
||||
// Should either be unique or have a number appended
|
||||
expect(name).toBeTruthy()
|
||||
expect(name).not.toBe('Swift Ninja')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateUniquePlayerNames', () => {
|
||||
it('should generate the requested number of unique names', () => {
|
||||
const names = generateUniquePlayerNames(4)
|
||||
expect(names).toHaveLength(4)
|
||||
|
||||
// All names should be unique
|
||||
const uniqueNames = new Set(names)
|
||||
expect(uniqueNames.size).toBe(4)
|
||||
})
|
||||
|
||||
it('should generate unique names across all entries', () => {
|
||||
const names = generateUniquePlayerNames(10)
|
||||
expect(names).toHaveLength(10)
|
||||
|
||||
// Check uniqueness (case-insensitive)
|
||||
const uniqueNames = new Set(names.map((n) => n.toLowerCase()))
|
||||
expect(uniqueNames.size).toBe(10)
|
||||
})
|
||||
|
||||
it('should handle generating zero names', () => {
|
||||
const names = generateUniquePlayerNames(0)
|
||||
expect(names).toHaveLength(0)
|
||||
expect(names).toEqual([])
|
||||
})
|
||||
|
||||
it('should generate names with expected format', () => {
|
||||
const names = generateUniquePlayerNames(5)
|
||||
|
||||
for (const name of names) {
|
||||
expect(name).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+( \d+)?$/)
|
||||
expect(name.split(' ').length).toBeGreaterThanOrEqual(2)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
162
apps/web/src/utils/playerNames.ts
Normal file
162
apps/web/src/utils/playerNames.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Fun automatic player name generation system
|
||||
* Generates creative names by combining adjectives with nouns/roles
|
||||
*/
|
||||
|
||||
const ADJECTIVES = [
|
||||
// Abacus-themed adjectives
|
||||
'Ancient',
|
||||
'Wooden',
|
||||
'Sliding',
|
||||
'Decimal',
|
||||
'Binary',
|
||||
'Counting',
|
||||
'Soroban',
|
||||
'Chinese',
|
||||
'Japanese',
|
||||
'Nimble',
|
||||
'Clicking',
|
||||
'Beaded',
|
||||
'Columnar',
|
||||
'Vertical',
|
||||
'Horizontal',
|
||||
'Upper',
|
||||
'Lower',
|
||||
'Heaven',
|
||||
'Earth',
|
||||
'Golden',
|
||||
'Jade',
|
||||
'Bamboo',
|
||||
'Polished',
|
||||
'Skilled',
|
||||
'Master',
|
||||
// Arithmetic/calculation adjectives
|
||||
'Adding',
|
||||
'Subtracting',
|
||||
'Multiplying',
|
||||
'Dividing',
|
||||
'Calculating',
|
||||
'Computing',
|
||||
'Estimating',
|
||||
'Rounding',
|
||||
'Summing',
|
||||
'Tallying',
|
||||
'Decimal',
|
||||
'Fractional',
|
||||
'Exponential',
|
||||
'Algebraic',
|
||||
'Geometric',
|
||||
'Prime',
|
||||
'Composite',
|
||||
'Rational',
|
||||
'Digital',
|
||||
'Numeric',
|
||||
'Precise',
|
||||
'Accurate',
|
||||
'Lightning',
|
||||
'Rapid',
|
||||
'Mental',
|
||||
]
|
||||
|
||||
const NOUNS = [
|
||||
// Abacus-themed nouns
|
||||
'Counter',
|
||||
'Abacist',
|
||||
'Calculator',
|
||||
'Bead',
|
||||
'Rod',
|
||||
'Frame',
|
||||
'Slider',
|
||||
'Merchant',
|
||||
'Trader',
|
||||
'Accountant',
|
||||
'Bookkeeper',
|
||||
'Clerk',
|
||||
'Scribe',
|
||||
'Master',
|
||||
'Apprentice',
|
||||
'Scholar',
|
||||
'Student',
|
||||
'Teacher',
|
||||
'Sensei',
|
||||
'Guru',
|
||||
'Expert',
|
||||
'Virtuoso',
|
||||
'Prodigy',
|
||||
'Wizard',
|
||||
'Sage',
|
||||
// Arithmetic/calculation nouns
|
||||
'Adder',
|
||||
'Multiplier',
|
||||
'Divider',
|
||||
'Solver',
|
||||
'Mathematician',
|
||||
'Arithmetician',
|
||||
'Analyst',
|
||||
'Computer',
|
||||
'Estimator',
|
||||
'Logician',
|
||||
'Statistician',
|
||||
'Numerologist',
|
||||
'Quantifier',
|
||||
'Tallier',
|
||||
'Sumner',
|
||||
'Keeper',
|
||||
'Reckoner',
|
||||
'Cipher',
|
||||
'Digit',
|
||||
'Figure',
|
||||
'Number',
|
||||
'Brain',
|
||||
'Thinker',
|
||||
'Genius',
|
||||
'Whiz',
|
||||
]
|
||||
|
||||
/**
|
||||
* Generate a random player name by combining an adjective and noun
|
||||
*/
|
||||
export function generatePlayerName(): string {
|
||||
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
|
||||
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]
|
||||
return `${adjective} ${noun}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique player name that doesn't conflict with existing players
|
||||
* @param existingNames - Array of names already in use
|
||||
* @param maxAttempts - Maximum attempts to find a unique name (default: 50)
|
||||
* @returns A unique player name
|
||||
*/
|
||||
export function generateUniquePlayerName(existingNames: string[], maxAttempts = 50): string {
|
||||
const existingNamesSet = new Set(existingNames.map((name) => name.toLowerCase()))
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const name = generatePlayerName()
|
||||
if (!existingNamesSet.has(name.toLowerCase())) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if we can't find a unique name, append a number
|
||||
const baseName = generatePlayerName()
|
||||
let counter = 1
|
||||
while (existingNamesSet.has(`${baseName} ${counter}`.toLowerCase())) {
|
||||
counter++
|
||||
}
|
||||
return `${baseName} ${counter}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a batch of unique player names
|
||||
* @param count - Number of names to generate
|
||||
* @returns Array of unique player names
|
||||
*/
|
||||
export function generateUniquePlayerNames(count: number): string[] {
|
||||
const names: string[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const name = generateUniquePlayerName(names)
|
||||
names.push(name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "3.3.0",
|
||||
"version": "3.17.8",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user