Compare commits
263 Commits
abacus-rea
...
v3.20.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0eed26966c | ||
|
|
49219e34cd | ||
|
|
499ee525a8 | ||
|
|
843b45b14e | ||
|
|
76a8472f12 | ||
|
|
bf02bc14fd | ||
|
|
ffb626f403 | ||
|
|
860fd607be | ||
|
|
3bae00b9a9 | ||
|
|
ff791409cf | ||
|
|
c1be0277c1 | ||
|
|
04c9944f2e | ||
|
|
260bdc2e9d | ||
|
|
8dbdc837cc | ||
|
|
1bd73544df | ||
|
|
506bfeccf2 | ||
|
|
38e554e6ea | ||
|
|
8f8f112de2 | ||
|
|
f3080b50d9 | ||
|
|
de0efd5932 | ||
|
|
c9e5c473e6 | ||
|
|
487ca7fba6 | ||
|
|
8f7eebce4b | ||
|
|
94ef39234d | ||
|
|
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 | ||
|
|
f949003870 | ||
|
|
4a6b3cabe5 | ||
|
|
2cb6a512fe | ||
|
|
e469363699 | ||
|
|
b230cd7a1f | ||
|
|
dcbb5072d8 | ||
|
|
f9ec5d32c5 | ||
|
|
85d13cc552 | ||
|
|
ef8a29e8ef | ||
|
|
f7d63b30ac | ||
|
|
441c04f9e6 | ||
|
|
a74b96bb6f | ||
|
|
6ff21c4f1d | ||
|
|
21009f8a34 | ||
|
|
97669ad084 | ||
|
|
233bd342a8 | ||
|
|
cfaf82b2cc | ||
|
|
3e0b254df9 | ||
|
|
fae5920e2f | ||
|
|
3a9016977d | ||
|
|
9e025f8a0a | ||
|
|
55ccf097d9 | ||
|
|
063a8e52fe | ||
|
|
54846bdc3f | ||
|
|
31ac958d33 | ||
|
|
2e6469bed4 | ||
|
|
087652f9e7 | ||
|
|
a2d0169f80 | ||
|
|
fd3a2d1f76 | ||
|
|
7f95032253 | ||
|
|
cd3115aa6d | ||
|
|
86ceba3df3 | ||
|
|
79a8518557 | ||
|
|
84f3c4bcfd | ||
|
|
97d16041df | ||
|
|
07696f3264 | ||
|
|
a898fbc187 | ||
|
|
91013fd632 | ||
|
|
7d652126d0 | ||
|
|
a204c83afc | ||
|
|
c5b6a82ca4 | ||
|
|
560a05266e | ||
|
|
af209fe6ac | ||
|
|
76163a0846 | ||
|
|
fe2e6a98b9 | ||
|
|
dcda826b9a | ||
|
|
5f7067a106 | ||
|
|
804096fd8a | ||
|
|
1948ba2dde | ||
|
|
95cd72e9bf | ||
|
|
3e5fa41d08 | ||
|
|
a35a7d56df | ||
|
|
ab0d8081d3 | ||
|
|
54997007b8 | ||
|
|
80cfc10f78 | ||
|
|
c1472f7865 | ||
|
|
389df29dc3 | ||
|
|
c13da68a98 | ||
|
|
2881affecc | ||
|
|
c34e28e3ab | ||
|
|
bbd1da02b5 | ||
|
|
2b8faad9d6 | ||
|
|
7c294dafff | ||
|
|
6105cae17c | ||
|
|
c4b00dd679 | ||
|
|
5357433c41 | ||
|
|
52a66d5f68 | ||
|
|
09147f95a5 | ||
|
|
53079ede13 | ||
|
|
a7309cb414 | ||
|
|
eaf17e07fc | ||
|
|
623314bd38 | ||
|
|
fc4556803b | ||
|
|
f574558dff | ||
|
|
4ec0312049 | ||
|
|
e6f96a8b99 | ||
|
|
22984b4423 | ||
|
|
cb4c061d11 | ||
|
|
39f64208ea | ||
|
|
7263828ed4 | ||
|
|
cecf07e572 | ||
|
|
5036cb00b6 | ||
|
|
b579c35db1 | ||
|
|
64fb30e7ec | ||
|
|
2c074c3444 | ||
|
|
d5473ab66a | ||
|
|
2cbc5ca5f2 | ||
|
|
5215af801f | ||
|
|
e49e42de76 | ||
|
|
3e691cb06d | ||
|
|
74136d225c | ||
|
|
f7b83f8c14 | ||
|
|
c6c3e4ac24 | ||
|
|
7bc815fd7d | ||
|
|
4165db206d | ||
|
|
eeb8d52d03 | ||
|
|
2d480ee0fa | ||
|
|
540f6b76d0 | ||
|
|
139a6d8e37 | ||
|
|
7a6f2ac6eb | ||
|
|
a7b2374493 | ||
|
|
fa78a2c001 | ||
|
|
9da4bd6ceb | ||
|
|
6ad71702f9 | ||
|
|
9353355e26 | ||
|
|
d1aa567c1e | ||
|
|
2082843c1d | ||
|
|
e39a0313cb | ||
|
|
4c0dc12204 | ||
|
|
e0fd793812 | ||
|
|
e4adabea07 | ||
|
|
1ba58b9547 | ||
|
|
48ec451689 | ||
|
|
5720c7dca3 | ||
|
|
518a4cf870 | ||
|
|
9c9270f931 | ||
|
|
6b3a440369 | ||
|
|
a6b1610993 | ||
|
|
506252358d | ||
|
|
f9af0f169e | ||
|
|
8e9980dc82 | ||
|
|
4008cd75ff | ||
|
|
2d00939f1b | ||
|
|
738dd9a0d1 | ||
|
|
4cc3de5f43 | ||
|
|
4cedfdd629 | ||
|
|
c29501f666 | ||
|
|
9ba00deaaa | ||
|
|
43f7c92f6d | ||
|
|
a578ce7f8b | ||
|
|
6fd425ce85 | ||
|
|
9e414b01b6 | ||
|
|
dba42b5925 | ||
|
|
4125d8eb41 | ||
|
|
b7f1d5a569 |
3447
CHANGELOG.md
3447
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -89,3 +89,50 @@ 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.
|
||||
|
||||
## Game Settings Persistence
|
||||
|
||||
When working on arcade room game settings, refer to:
|
||||
|
||||
- **`.claude/GAME_SETTINGS_PERSISTENCE.md`** - Complete architecture documentation
|
||||
- How settings are stored (nested by game name)
|
||||
- Three critical systems that must stay in sync
|
||||
- Common bugs and their solutions
|
||||
- Debugging checklist
|
||||
- Step-by-step guide for adding new settings
|
||||
|
||||
- **`.claude/GAME_SETTINGS_REFACTORING.md`** - Recommended improvements
|
||||
- Shared config types to prevent inconsistencies
|
||||
- Helper functions to reduce duplication
|
||||
- Type-safe validation
|
||||
- Migration strategy
|
||||
|
||||
**Quick Reference:**
|
||||
|
||||
Settings are stored as: `gameConfig[gameName][setting]`
|
||||
|
||||
Three places must handle settings correctly:
|
||||
1. **Provider** (`Room{Game}Provider.tsx`) - Merges saved config with defaults
|
||||
2. **Socket Server** (`socket-server.ts`) - Creates session from saved config
|
||||
3. **Validator** (`{Game}Validator.ts`) - `getInitialState()` must accept ALL settings
|
||||
|
||||
If a setting doesn't persist, check all three locations.
|
||||
|
||||
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
|
||||
421
apps/web/.claude/GAME_SETTINGS_PERSISTENCE.md
Normal file
421
apps/web/.claude/GAME_SETTINGS_PERSISTENCE.md
Normal file
@@ -0,0 +1,421 @@
|
||||
# Game Settings Persistence Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
Game settings in room mode persist across game switches using a normalized database schema. Settings for each game are stored in a dedicated `room_game_configs` table with one row per game per room.
|
||||
|
||||
## Database Schema
|
||||
|
||||
Settings are stored in the `room_game_configs` table:
|
||||
|
||||
```sql
|
||||
CREATE TABLE room_game_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
room_id TEXT NOT NULL REFERENCES arcade_rooms(id) ON DELETE CASCADE,
|
||||
game_name TEXT NOT NULL CHECK(game_name IN ('matching', 'memory-quiz', 'complement-race')),
|
||||
config TEXT NOT NULL, -- JSON
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
UNIQUE(room_id, game_name)
|
||||
);
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Type-safe config access with shared types
|
||||
- ✅ Smaller rows (only configs for games that have been used)
|
||||
- ✅ Easier updates (single row vs entire JSON blob)
|
||||
- ✅ Better concurrency (no lock contention between games)
|
||||
- ✅ Foundation for per-game audit trail
|
||||
- ✅ Can query/index individual game settings
|
||||
|
||||
**Example Row:**
|
||||
```json
|
||||
{
|
||||
"id": "clxyz123",
|
||||
"room_id": "room_abc",
|
||||
"game_name": "memory-quiz",
|
||||
"config": {
|
||||
"selectedCount": 8,
|
||||
"displayTime": 3.0,
|
||||
"selectedDifficulty": "medium",
|
||||
"playMode": "competitive"
|
||||
},
|
||||
"created_at": 1234567890,
|
||||
"updated_at": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
## Shared Type System
|
||||
|
||||
All game configs are defined in `src/lib/arcade/game-configs.ts`:
|
||||
|
||||
```typescript
|
||||
// Shared config types (single source of truth)
|
||||
export interface MatchingGameConfig {
|
||||
gameType: 'abacus-numeral' | 'complement-pairs'
|
||||
difficulty: number
|
||||
turnTimer: number
|
||||
}
|
||||
|
||||
export interface MemoryQuizGameConfig {
|
||||
selectedCount: 2 | 5 | 8 | 12 | 15
|
||||
displayTime: number
|
||||
selectedDifficulty: DifficultyLevel
|
||||
playMode: 'cooperative' | 'competitive'
|
||||
}
|
||||
|
||||
// Default configs
|
||||
export const DEFAULT_MATCHING_CONFIG: MatchingGameConfig = {
|
||||
gameType: 'abacus-numeral',
|
||||
difficulty: 6,
|
||||
turnTimer: 30,
|
||||
}
|
||||
|
||||
export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
|
||||
selectedCount: 5,
|
||||
displayTime: 2.0,
|
||||
selectedDifficulty: 'easy',
|
||||
playMode: 'cooperative',
|
||||
}
|
||||
```
|
||||
|
||||
**Why This Matters:**
|
||||
- TypeScript enforces that validators, helpers, and API routes all use the same types
|
||||
- Adding a new setting requires changes in only ONE place (the type definition)
|
||||
- Impossible to forget a setting or use wrong type
|
||||
|
||||
## Critical Components
|
||||
|
||||
Settings persistence requires coordination between FOUR systems:
|
||||
|
||||
### 1. Helper Functions
|
||||
**Location:** `src/lib/arcade/game-config-helpers.ts`
|
||||
|
||||
**Responsibilities:**
|
||||
- Read/write game configs from `room_game_configs` table
|
||||
- Provide type-safe access with automatic defaults
|
||||
- Validate configs at runtime
|
||||
|
||||
**Key Functions:**
|
||||
```typescript
|
||||
// Get config with defaults (type-safe)
|
||||
const config = await getGameConfig(roomId, 'memory-quiz')
|
||||
// Returns: MemoryQuizGameConfig
|
||||
|
||||
// Set/update config (upsert)
|
||||
await setGameConfig(roomId, 'memory-quiz', {
|
||||
playMode: 'competitive',
|
||||
selectedCount: 8,
|
||||
})
|
||||
|
||||
// Get all game configs for a room
|
||||
const allConfigs = await getAllGameConfigs(roomId)
|
||||
// Returns: { matching?: MatchingGameConfig, 'memory-quiz'?: MemoryQuizGameConfig }
|
||||
```
|
||||
|
||||
### 2. API Routes
|
||||
**Location:**
|
||||
- `src/app/api/arcade/rooms/current/route.ts` (read)
|
||||
- `src/app/api/arcade/rooms/[roomId]/settings/route.ts` (write)
|
||||
|
||||
**Responsibilities:**
|
||||
- Aggregate game configs from database
|
||||
- Return them to client in `room.gameConfig`
|
||||
- Write config updates to `room_game_configs` table
|
||||
|
||||
**Read Example:** `GET /api/arcade/rooms/current`
|
||||
```typescript
|
||||
const gameConfig = await getAllGameConfigs(roomId)
|
||||
|
||||
return NextResponse.json({
|
||||
room: {
|
||||
...room,
|
||||
gameConfig, // Aggregated from room_game_configs table
|
||||
},
|
||||
members,
|
||||
memberPlayers,
|
||||
})
|
||||
```
|
||||
|
||||
**Write Example:** `PATCH /api/arcade/rooms/[roomId]/settings`
|
||||
```typescript
|
||||
if (body.gameConfig !== undefined) {
|
||||
// body.gameConfig: { matching: {...}, memory-quiz: {...} }
|
||||
for (const [gameName, config] of Object.entries(body.gameConfig)) {
|
||||
await setGameConfig(roomId, gameName, config)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Socket Server (Session Creation)
|
||||
**Location:** `src/socket-server.ts:70-90`
|
||||
|
||||
**Responsibilities:**
|
||||
- Create initial arcade session when user joins room
|
||||
- Read saved settings using `getGameConfig()` helper
|
||||
- Pass settings to validator's `getInitialState()`
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const room = await getRoomById(roomId)
|
||||
const validator = getValidator(room.gameName as GameName)
|
||||
|
||||
// Get config from database (type-safe, includes defaults)
|
||||
const gameConfig = await getGameConfig(roomId, room.gameName as GameName)
|
||||
|
||||
// Pass to validator (types match automatically)
|
||||
const initialState = validator.getInitialState(gameConfig)
|
||||
|
||||
await createArcadeSession({ userId, gameName, initialState, roomId })
|
||||
```
|
||||
|
||||
**Key Point:** No more manual config extraction or default fallbacks!
|
||||
|
||||
### 4. Game Validators
|
||||
**Location:** `src/lib/arcade/validation/*Validator.ts`
|
||||
|
||||
**Responsibilities:**
|
||||
- Define `getInitialState()` method with shared config type
|
||||
- Create initial game state from config
|
||||
- TypeScript enforces all settings are handled
|
||||
|
||||
**Example:** `MemoryQuizGameValidator.ts`
|
||||
```typescript
|
||||
import type { MemoryQuizGameConfig } from '@/lib/arcade/game-configs'
|
||||
|
||||
class MemoryQuizGameValidator {
|
||||
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
|
||||
return {
|
||||
selectedCount: config.selectedCount,
|
||||
displayTime: config.displayTime,
|
||||
selectedDifficulty: config.selectedDifficulty,
|
||||
playMode: config.playMode, // TypeScript ensures this field exists!
|
||||
// ...other state
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Client Providers (Unchanged)
|
||||
**Location:** `src/app/arcade/{game}/context/Room{Game}Provider.tsx`
|
||||
|
||||
**Responsibilities:**
|
||||
- Read settings from `roomData.gameConfig[gameName]`
|
||||
- Merge with `initialState` defaults
|
||||
- Works transparently with new backend structure
|
||||
|
||||
**Example:** `RoomMemoryQuizProvider.tsx:211-233`
|
||||
```typescript
|
||||
const mergedInitialState = useMemo(() => {
|
||||
const gameConfig = roomData?.gameConfig as Record<string, any>
|
||||
const savedConfig = gameConfig?.['memory-quiz']
|
||||
|
||||
if (!savedConfig) {
|
||||
return initialState
|
||||
}
|
||||
|
||||
return {
|
||||
...initialState,
|
||||
selectedCount: savedConfig.selectedCount ?? initialState.selectedCount,
|
||||
displayTime: savedConfig.displayTime ?? initialState.displayTime,
|
||||
selectedDifficulty: savedConfig.selectedDifficulty ?? initialState.selectedDifficulty,
|
||||
playMode: savedConfig.playMode ?? initialState.playMode,
|
||||
}
|
||||
}, [roomData?.gameConfig])
|
||||
```
|
||||
|
||||
## Common Bugs and Solutions
|
||||
|
||||
### Bug #1: Settings Not Persisting
|
||||
**Symptom:** Settings reset to defaults after game switch
|
||||
|
||||
**Root Cause:** One of the following:
|
||||
1. API route not writing to `room_game_configs` table
|
||||
2. Helper function not being used correctly
|
||||
3. Validator not using shared config type
|
||||
|
||||
**Solution:** Verify the data flow:
|
||||
```bash
|
||||
# 1. Check database write
|
||||
SELECT * FROM room_game_configs WHERE room_id = '...';
|
||||
|
||||
# 2. Check API logs for setGameConfig() calls
|
||||
# Look for: [GameConfig] Updated {game} config for room {roomId}
|
||||
|
||||
# 3. Check socket server logs for getGameConfig() calls
|
||||
# Look for: [join-arcade-session] Got validator for: {game}
|
||||
|
||||
# 4. Check validator signature matches shared type
|
||||
# MemoryQuizGameValidator.getInitialState(config: MemoryQuizGameConfig)
|
||||
```
|
||||
|
||||
### Bug #2: TypeScript Errors About Missing Fields
|
||||
**Symptom:** `Property '{field}' is missing in type ...`
|
||||
|
||||
**Root Cause:** Validator's `getInitialState()` signature doesn't match shared config type
|
||||
|
||||
**Solution:** Import and use the shared config type:
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
getInitialState(config: {
|
||||
selectedCount: number
|
||||
displayTime: number
|
||||
// Missing playMode!
|
||||
}): SorobanQuizState
|
||||
|
||||
// ✅ CORRECT
|
||||
import type { MemoryQuizGameConfig } from '@/lib/arcade/game-configs'
|
||||
|
||||
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState
|
||||
```
|
||||
|
||||
### Bug #3: Settings Wiped When Returning to Game Selection
|
||||
**Symptom:** Settings reset when going back to game selection
|
||||
|
||||
**Root Cause:** Sending `gameConfig: null` in PATCH request
|
||||
|
||||
**Solution:** Only send `gameName: null`, don't touch gameConfig:
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
body: JSON.stringify({ gameName: null, gameConfig: null })
|
||||
|
||||
// ✅ CORRECT
|
||||
body: JSON.stringify({ gameName: null })
|
||||
```
|
||||
|
||||
## Debugging Checklist
|
||||
|
||||
When a setting doesn't persist:
|
||||
|
||||
1. **Check database:**
|
||||
- Query `room_game_configs` table
|
||||
- Verify row exists for room + game
|
||||
- Verify JSON config has correct structure
|
||||
|
||||
2. **Check API write path:**
|
||||
- `/api/arcade/rooms/[roomId]/settings` logs
|
||||
- Verify `setGameConfig()` is called
|
||||
- Check for errors in console
|
||||
|
||||
3. **Check API read path:**
|
||||
- `/api/arcade/rooms/current` logs
|
||||
- Verify `getAllGameConfigs()` returns data
|
||||
- Check `room.gameConfig` in response
|
||||
|
||||
4. **Check socket server:**
|
||||
- `socket-server.ts` logs for `getGameConfig()`
|
||||
- Verify config passed to validator
|
||||
- Check `initialState` has correct values
|
||||
|
||||
5. **Check validator:**
|
||||
- Signature uses shared config type
|
||||
- All config fields used (not hardcoded)
|
||||
- Add logging to see received config
|
||||
|
||||
## Adding a New Setting
|
||||
|
||||
To add a new setting to an existing game:
|
||||
|
||||
1. **Update the shared config type** (`game-configs.ts`):
|
||||
```typescript
|
||||
export interface MemoryQuizGameConfig {
|
||||
selectedCount: 2 | 5 | 8 | 12 | 15
|
||||
displayTime: number
|
||||
selectedDifficulty: DifficultyLevel
|
||||
playMode: 'cooperative' | 'competitive'
|
||||
newSetting: string // ← Add here
|
||||
}
|
||||
|
||||
export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
|
||||
selectedCount: 5,
|
||||
displayTime: 2.0,
|
||||
selectedDifficulty: 'easy',
|
||||
playMode: 'cooperative',
|
||||
newSetting: 'default', // ← Add default
|
||||
}
|
||||
```
|
||||
|
||||
2. **TypeScript will now enforce:**
|
||||
- ✅ Validator must accept `newSetting` (compile error if missing)
|
||||
- ✅ Helper functions will include it automatically
|
||||
- ✅ Client providers will need to handle it
|
||||
|
||||
3. **Update the validator** (`*Validator.ts`):
|
||||
```typescript
|
||||
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
|
||||
return {
|
||||
// ...
|
||||
newSetting: config.newSetting, // TypeScript enforces this
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Update the UI** to expose the new setting
|
||||
- No changes needed to API routes or helper functions!
|
||||
- They automatically handle any field in the config type
|
||||
|
||||
## Testing Settings Persistence
|
||||
|
||||
Manual test procedure:
|
||||
|
||||
1. Join a room and select a game
|
||||
2. Change each setting to a non-default value
|
||||
3. Go back to game selection (gameName becomes null)
|
||||
4. Select the same game again
|
||||
5. **Verify ALL settings retained their values**
|
||||
|
||||
**Expected behavior:** All settings should be exactly as you left them.
|
||||
|
||||
## Migration Notes
|
||||
|
||||
**Old Schema:**
|
||||
- Settings stored in `arcade_rooms.game_config` JSON column
|
||||
- Config stored directly for currently selected game only
|
||||
- Config lost when switching games
|
||||
|
||||
**New Schema:**
|
||||
- Settings stored in `room_game_configs` table
|
||||
- One row per game per room
|
||||
- Unique constraint on (room_id, game_name)
|
||||
- Configs persist when switching between games
|
||||
|
||||
**Migration:** See `.claude/MANUAL_MIGRATION_0011.md` for complete details
|
||||
|
||||
**Summary:**
|
||||
- Manual migration applied on 2025-10-15
|
||||
- Created `room_game_configs` table via sqlite3 CLI
|
||||
- Migrated 6000 existing configs (5991 matching, 9 memory-quiz)
|
||||
- Table created directly instead of through drizzle migration system
|
||||
|
||||
**Rollback Plan:**
|
||||
- Old `game_config` column still exists in `arcade_rooms` table
|
||||
- Old data preserved (was only read, not deleted)
|
||||
- Can revert to reading from old column if needed
|
||||
- New table can be dropped: `DROP TABLE room_game_configs`
|
||||
|
||||
## Architecture Benefits
|
||||
|
||||
**Type Safety:**
|
||||
- Single source of truth for config types
|
||||
- TypeScript enforces consistency everywhere
|
||||
- Impossible to forget a setting
|
||||
|
||||
**DRY (Don't Repeat Yourself):**
|
||||
- No duplicated default values
|
||||
- No manual config extraction
|
||||
- No manual merging with defaults
|
||||
|
||||
**Maintainability:**
|
||||
- Adding a setting touches fewer places
|
||||
- Clear separation of concerns
|
||||
- Easier to trace data flow
|
||||
|
||||
**Performance:**
|
||||
- Smaller database rows
|
||||
- Better query performance
|
||||
- Less network payload
|
||||
|
||||
**Correctness:**
|
||||
- Runtime validation available
|
||||
- Database constraints (unique index)
|
||||
- Impossible to create duplicate configs
|
||||
479
apps/web/.claude/GAME_SETTINGS_REFACTORING.md
Normal file
479
apps/web/.claude/GAME_SETTINGS_REFACTORING.md
Normal file
@@ -0,0 +1,479 @@
|
||||
# Game Settings Persistence - Refactoring Recommendations
|
||||
|
||||
## Current Pain Points
|
||||
|
||||
1. **Type safety is weak** - Easy to forget to add a setting in one place
|
||||
2. **Duplication** - Config reading logic duplicated in socket-server.ts for each game
|
||||
3. **Manual synchronization** - Have to manually keep validator signature, provider, and socket server in sync
|
||||
4. **Error-prone** - Easy to hardcode values or forget to read from config
|
||||
|
||||
## Recommended Refactorings
|
||||
|
||||
### 1. Create Shared Config Types (HIGHEST PRIORITY)
|
||||
|
||||
**Problem:** Each game's settings are defined in multiple places with no type enforcement
|
||||
|
||||
**Solution:** Define a single source of truth for each game's config
|
||||
|
||||
```typescript
|
||||
// src/lib/arcade/game-configs.ts
|
||||
|
||||
export interface MatchingGameConfig {
|
||||
gameType: 'abacus-numeral' | 'complement-pairs'
|
||||
difficulty: number
|
||||
turnTimer: number
|
||||
}
|
||||
|
||||
export interface MemoryQuizGameConfig {
|
||||
selectedCount: 2 | 5 | 8 | 12 | 15
|
||||
displayTime: number
|
||||
selectedDifficulty: DifficultyLevel
|
||||
playMode: 'cooperative' | 'competitive'
|
||||
}
|
||||
|
||||
export interface ComplementRaceGameConfig {
|
||||
// ... future settings
|
||||
}
|
||||
|
||||
export interface RoomGameConfig {
|
||||
matching?: MatchingGameConfig
|
||||
'memory-quiz'?: MemoryQuizGameConfig
|
||||
'complement-race'?: ComplementRaceGameConfig
|
||||
}
|
||||
|
||||
// Default configs
|
||||
export const DEFAULT_MATCHING_CONFIG: MatchingGameConfig = {
|
||||
gameType: 'abacus-numeral',
|
||||
difficulty: 6,
|
||||
turnTimer: 30,
|
||||
}
|
||||
|
||||
export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
|
||||
selectedCount: 5,
|
||||
displayTime: 2.0,
|
||||
selectedDifficulty: 'easy',
|
||||
playMode: 'cooperative',
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Single source of truth for each game's settings
|
||||
- TypeScript enforces consistency across codebase
|
||||
- Easy to see what settings each game has
|
||||
|
||||
### 2. Create Config Helper Functions
|
||||
|
||||
**Problem:** Config reading logic is duplicated and error-prone
|
||||
|
||||
**Solution:** Centralized helper functions with type safety
|
||||
|
||||
```typescript
|
||||
// src/lib/arcade/game-config-helpers.ts
|
||||
|
||||
import type { GameName } from './validation'
|
||||
import type { RoomGameConfig, MatchingGameConfig, MemoryQuizGameConfig } from './game-configs'
|
||||
import { DEFAULT_MATCHING_CONFIG, DEFAULT_MEMORY_QUIZ_CONFIG } from './game-configs'
|
||||
|
||||
/**
|
||||
* Get game-specific config from room's gameConfig with defaults
|
||||
*/
|
||||
export function getGameConfig<T extends GameName>(
|
||||
roomGameConfig: RoomGameConfig | null | undefined,
|
||||
gameName: T
|
||||
): T extends 'matching'
|
||||
? MatchingGameConfig
|
||||
: T extends 'memory-quiz'
|
||||
? MemoryQuizGameConfig
|
||||
: never {
|
||||
|
||||
if (!roomGameConfig) {
|
||||
return getDefaultGameConfig(gameName) as any
|
||||
}
|
||||
|
||||
const savedConfig = roomGameConfig[gameName]
|
||||
if (!savedConfig) {
|
||||
return getDefaultGameConfig(gameName) as any
|
||||
}
|
||||
|
||||
// Merge saved config with defaults to handle missing fields
|
||||
const defaults = getDefaultGameConfig(gameName)
|
||||
return { ...defaults, ...savedConfig } as any
|
||||
}
|
||||
|
||||
function getDefaultGameConfig(gameName: GameName) {
|
||||
switch (gameName) {
|
||||
case 'matching':
|
||||
return DEFAULT_MATCHING_CONFIG
|
||||
case 'memory-quiz':
|
||||
return DEFAULT_MEMORY_QUIZ_CONFIG
|
||||
case 'complement-race':
|
||||
// return DEFAULT_COMPLEMENT_RACE_CONFIG
|
||||
throw new Error('complement-race config not implemented')
|
||||
default:
|
||||
throw new Error(`Unknown game: ${gameName}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific game's config in the room's gameConfig
|
||||
*/
|
||||
export function updateGameConfig<T extends GameName>(
|
||||
currentRoomConfig: RoomGameConfig | null | undefined,
|
||||
gameName: T,
|
||||
updates: Partial<T extends 'matching' ? MatchingGameConfig : T extends 'memory-quiz' ? MemoryQuizGameConfig : never>
|
||||
): RoomGameConfig {
|
||||
const current = currentRoomConfig || {}
|
||||
const gameConfig = current[gameName] || getDefaultGameConfig(gameName)
|
||||
|
||||
return {
|
||||
...current,
|
||||
[gameName]: {
|
||||
...gameConfig,
|
||||
...updates,
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Usage in socket-server.ts:**
|
||||
```typescript
|
||||
// BEFORE (error-prone, duplicated)
|
||||
const memoryQuizConfig = (room.gameConfig as any)?.['memory-quiz'] || {}
|
||||
initialState = validator.getInitialState({
|
||||
selectedCount: memoryQuizConfig.selectedCount || 5,
|
||||
displayTime: memoryQuizConfig.displayTime || 2.0,
|
||||
selectedDifficulty: memoryQuizConfig.selectedDifficulty || 'easy',
|
||||
playMode: memoryQuizConfig.playMode || 'cooperative',
|
||||
})
|
||||
|
||||
// AFTER (type-safe, concise)
|
||||
const config = getGameConfig(room.gameConfig, 'memory-quiz')
|
||||
initialState = validator.getInitialState(config)
|
||||
```
|
||||
|
||||
**Usage in RoomMemoryQuizProvider.tsx:**
|
||||
```typescript
|
||||
// BEFORE (verbose, error-prone)
|
||||
const mergedInitialState = useMemo(() => {
|
||||
const gameConfig = roomData?.gameConfig as Record<string, any>
|
||||
const savedConfig = gameConfig?.['memory-quiz']
|
||||
|
||||
return {
|
||||
...initialState,
|
||||
selectedCount: savedConfig?.selectedCount ?? initialState.selectedCount,
|
||||
displayTime: savedConfig?.displayTime ?? initialState.displayTime,
|
||||
selectedDifficulty: savedConfig?.selectedDifficulty ?? initialState.selectedDifficulty,
|
||||
playMode: savedConfig?.playMode ?? initialState.playMode,
|
||||
}
|
||||
}, [roomData?.gameConfig])
|
||||
|
||||
// AFTER (type-safe, concise)
|
||||
const mergedInitialState = useMemo(() => {
|
||||
const config = getGameConfig(roomData?.gameConfig, 'memory-quiz')
|
||||
return {
|
||||
...initialState,
|
||||
...config, // Spread config directly - all settings included
|
||||
}
|
||||
}, [roomData?.gameConfig])
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- No more manual property-by-property merging
|
||||
- Type-safe
|
||||
- Defaults handled automatically
|
||||
- Reusable across codebase
|
||||
|
||||
### 3. Enforce Validator Config Type from Game Config
|
||||
|
||||
**Problem:** Easy to forget to add a new setting to validator's `getInitialState()` signature
|
||||
|
||||
**Solution:** Make validator use the shared config type
|
||||
|
||||
```typescript
|
||||
// src/lib/arcade/validation/MemoryQuizGameValidator.ts
|
||||
|
||||
import type { MemoryQuizGameConfig } from '@/lib/arcade/game-configs'
|
||||
|
||||
export class MemoryQuizGameValidator {
|
||||
// BEFORE: Manual type definition
|
||||
// getInitialState(config: {
|
||||
// selectedCount: number
|
||||
// displayTime: number
|
||||
// selectedDifficulty: DifficultyLevel
|
||||
// playMode?: 'cooperative' | 'competitive'
|
||||
// }): SorobanQuizState
|
||||
|
||||
// AFTER: Use shared type
|
||||
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
|
||||
return {
|
||||
// ...
|
||||
selectedCount: config.selectedCount,
|
||||
displayTime: config.displayTime,
|
||||
selectedDifficulty: config.selectedDifficulty,
|
||||
playMode: config.playMode, // TypeScript ensures all fields are handled
|
||||
// ...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- If you add a setting to `MemoryQuizGameConfig`, TypeScript forces you to handle it
|
||||
- Impossible to forget a setting
|
||||
- Impossible to use wrong type
|
||||
|
||||
### 4. Add Exhaustiveness Checking
|
||||
|
||||
**Problem:** Easy to miss handling a setting field
|
||||
|
||||
**Solution:** Use TypeScript's exhaustiveness checking
|
||||
|
||||
```typescript
|
||||
// src/lib/arcade/validation/MemoryQuizGameValidator.ts
|
||||
|
||||
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
|
||||
// Exhaustiveness check - ensures all config fields are used
|
||||
const _exhaustivenessCheck: Record<keyof MemoryQuizGameConfig, boolean> = {
|
||||
selectedCount: true,
|
||||
displayTime: true,
|
||||
selectedDifficulty: true,
|
||||
playMode: true,
|
||||
}
|
||||
|
||||
return {
|
||||
// ... use all config fields
|
||||
selectedCount: config.selectedCount,
|
||||
displayTime: config.displayTime,
|
||||
selectedDifficulty: config.selectedDifficulty,
|
||||
playMode: config.playMode,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you add a new field to `MemoryQuizGameConfig`, TypeScript will error on `_exhaustivenessCheck` until you add it.
|
||||
|
||||
### 5. Validate Config on Save
|
||||
|
||||
**Problem:** Invalid config can be saved to database
|
||||
|
||||
**Solution:** Add runtime validation
|
||||
|
||||
```typescript
|
||||
// src/lib/arcade/game-config-helpers.ts
|
||||
|
||||
export function validateGameConfig(
|
||||
gameName: GameName,
|
||||
config: any
|
||||
): config is MatchingGameConfig | MemoryQuizGameConfig {
|
||||
switch (gameName) {
|
||||
case 'matching':
|
||||
return (
|
||||
typeof config.gameType === 'string' &&
|
||||
['abacus-numeral', 'complement-pairs'].includes(config.gameType) &&
|
||||
typeof config.difficulty === 'number' &&
|
||||
config.difficulty > 0 &&
|
||||
typeof config.turnTimer === 'number' &&
|
||||
config.turnTimer > 0
|
||||
)
|
||||
|
||||
case 'memory-quiz':
|
||||
return (
|
||||
[2, 5, 8, 12, 15].includes(config.selectedCount) &&
|
||||
typeof config.displayTime === 'number' &&
|
||||
config.displayTime > 0 &&
|
||||
['beginner', 'easy', 'medium', 'hard', 'expert'].includes(config.selectedDifficulty) &&
|
||||
['cooperative', 'competitive'].includes(config.playMode)
|
||||
)
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use in settings API:
|
||||
```typescript
|
||||
// src/app/api/arcade/rooms/[roomId]/settings/route.ts
|
||||
|
||||
if (body.gameConfig !== undefined) {
|
||||
if (!validateGameConfig(room.gameName, body.gameConfig[room.gameName])) {
|
||||
return NextResponse.json({ error: 'Invalid game config' }, { status: 400 })
|
||||
}
|
||||
updateData.gameConfig = body.gameConfig
|
||||
}
|
||||
```
|
||||
|
||||
## Schema Refactoring: Separate Table for Game Configs
|
||||
|
||||
### Current Problem
|
||||
|
||||
All game configs are stored in a single JSON column in `arcade_rooms.gameConfig`:
|
||||
|
||||
```json
|
||||
{
|
||||
"matching": { "gameType": "...", "difficulty": 15 },
|
||||
"memory-quiz": { "selectedCount": 8, "playMode": "competitive" }
|
||||
}
|
||||
```
|
||||
|
||||
**Issues:**
|
||||
- No schema validation
|
||||
- Inefficient updates (read/parse/modify/serialize entire blob)
|
||||
- Grows without bounds as more games added
|
||||
- Can't query or index individual game settings
|
||||
- No audit trail
|
||||
- Potential concurrent update race conditions
|
||||
|
||||
### Recommended: Separate Table
|
||||
|
||||
Create `room_game_configs` table with one row per game per room:
|
||||
|
||||
```typescript
|
||||
// src/db/schema/room-game-configs.ts
|
||||
|
||||
export const roomGameConfigs = sqliteTable('room_game_configs', {
|
||||
id: text('id').primaryKey().$defaultFn(() => createId()),
|
||||
roomId: text('room_id')
|
||||
.notNull()
|
||||
.references(() => arcadeRooms.id, { onDelete: 'cascade' }),
|
||||
gameName: text('game_name', {
|
||||
enum: ['matching', 'memory-quiz', 'complement-race'],
|
||||
}).notNull(),
|
||||
config: text('config', { mode: 'json' }).notNull(), // Game-specific JSON
|
||||
createdAt: integer('created_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
}, (table) => ({
|
||||
uniqueRoomGame: uniqueIndex('room_game_idx').on(table.roomId, table.gameName),
|
||||
}))
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Smaller rows (only configs for games that have been used)
|
||||
- ✅ Easier updates (single row, not entire JSON blob)
|
||||
- ✅ Can track updatedAt per game
|
||||
- ✅ Better concurrency (no lock contention between games)
|
||||
- ✅ Foundation for future audit trail
|
||||
|
||||
**Migration Strategy:**
|
||||
1. Create new table
|
||||
2. Migrate existing data from `arcade_rooms.gameConfig`
|
||||
3. Update all config read/write code
|
||||
4. Deploy and test
|
||||
5. Drop old `gameConfig` column from `arcade_rooms`
|
||||
|
||||
See migration SQL below.
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
### Phase 1: Schema Migration (HIGHEST PRIORITY)
|
||||
1. **Create new table** - Add `room_game_configs` schema
|
||||
2. **Create migration** - SQL to migrate existing data
|
||||
3. **Update helper functions** - Adapt to new table structure
|
||||
4. **Update all read/write code** - Use new table
|
||||
5. **Test thoroughly** - Verify all settings persist correctly
|
||||
6. **Drop old column** - Remove `gameConfig` from `arcade_rooms`
|
||||
|
||||
### Phase 2: Type Safety (HIGH)
|
||||
1. **Create shared config types** (`game-configs.ts`) - Prevents type mismatches
|
||||
2. **Create helper functions** (`game-config-helpers.ts`) - Now queries new table
|
||||
3. **Update validators** to use shared types - Enforces consistency
|
||||
|
||||
### Phase 3: Compile-Time Safety (MEDIUM)
|
||||
1. **Add exhaustiveness checking** - Catches missing fields at compile time
|
||||
2. **Enforce validator config types** - Use shared types
|
||||
|
||||
### Phase 4: Runtime Safety (LOW)
|
||||
1. **Add runtime validation** - Prevents invalid data from being saved
|
||||
|
||||
## Detailed Migration SQL
|
||||
|
||||
```sql
|
||||
-- drizzle/migrations/XXXX_split_game_configs.sql
|
||||
|
||||
-- Create new table
|
||||
CREATE TABLE room_game_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
room_id TEXT NOT NULL REFERENCES arcade_rooms(id) ON DELETE CASCADE,
|
||||
game_name TEXT NOT NULL CHECK(game_name IN ('matching', 'memory-quiz', 'complement-race')),
|
||||
config TEXT NOT NULL, -- JSON
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX room_game_idx ON room_game_configs(room_id, game_name);
|
||||
|
||||
-- Migrate existing 'matching' configs
|
||||
INSERT INTO room_game_configs (id, room_id, game_name, config, created_at, updated_at)
|
||||
SELECT
|
||||
lower(hex(randomblob(16))),
|
||||
id,
|
||||
'matching',
|
||||
json_extract(game_config, '$.matching'),
|
||||
created_at,
|
||||
last_activity
|
||||
FROM arcade_rooms
|
||||
WHERE json_extract(game_config, '$.matching') IS NOT NULL;
|
||||
|
||||
-- Migrate existing 'memory-quiz' configs
|
||||
INSERT INTO room_game_configs (id, room_id, game_name, config, created_at, updated_at)
|
||||
SELECT
|
||||
lower(hex(randomblob(16))),
|
||||
id,
|
||||
'memory-quiz',
|
||||
json_extract(game_config, '$."memory-quiz"'),
|
||||
created_at,
|
||||
last_activity
|
||||
FROM arcade_rooms
|
||||
WHERE json_extract(game_config, '$."memory-quiz"') IS NOT NULL;
|
||||
|
||||
-- After testing and verifying all works:
|
||||
-- ALTER TABLE arcade_rooms DROP COLUMN game_config;
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Step-by-Step with Checkpoints
|
||||
|
||||
**Checkpoint 1: Schema & Migration**
|
||||
1. Create `src/db/schema/room-game-configs.ts`
|
||||
2. Export from `src/db/schema/index.ts`
|
||||
3. Generate and apply migration
|
||||
4. Verify data migrated correctly
|
||||
|
||||
**Checkpoint 2: Helper Functions**
|
||||
1. Create shared config types in `src/lib/arcade/game-configs.ts`
|
||||
2. Create helper functions in `src/lib/arcade/game-config-helpers.ts`
|
||||
3. Add unit tests for helpers
|
||||
|
||||
**Checkpoint 3: Update Config Reads**
|
||||
1. Update socket-server.ts to read from new table
|
||||
2. Update RoomMemoryQuizProvider to read from new table
|
||||
3. Update RoomMemoryPairsProvider to read from new table
|
||||
4. Test: Load room and verify settings appear
|
||||
|
||||
**Checkpoint 4: Update Config Writes**
|
||||
1. Update useRoomData.ts updateGameConfig to write to new table
|
||||
2. Update settings API to write to new table
|
||||
3. Test: Change settings and verify they persist
|
||||
|
||||
**Checkpoint 5: Update Validators**
|
||||
1. Update validators to use shared config types
|
||||
2. Test: All games work correctly
|
||||
|
||||
**Checkpoint 6: Cleanup**
|
||||
1. Remove old gameConfig column references
|
||||
2. Drop gameConfig column from arcade_rooms table
|
||||
3. Final testing of all games
|
||||
|
||||
## Benefits Summary
|
||||
|
||||
- **Type Safety:** TypeScript enforces consistency across all systems
|
||||
- **DRY:** Config reading logic not duplicated
|
||||
- **Maintainability:** Adding a setting requires changes in fewer places
|
||||
- **Correctness:** Impossible to forget a setting or use wrong type
|
||||
- **Debugging:** Centralized config logic easier to trace
|
||||
- **Testing:** Can test config helpers in isolation
|
||||
120
apps/web/.claude/MANUAL_MIGRATION_0011.md
Normal file
120
apps/web/.claude/MANUAL_MIGRATION_0011.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Manual Migration: room_game_configs Table
|
||||
|
||||
**Date:** 2025-10-15
|
||||
**Migration:** Create `room_game_configs` table (equivalent to drizzle migration 0011)
|
||||
|
||||
## Context
|
||||
|
||||
This migration was applied manually using sqlite3 CLI instead of through drizzle-kit's migration system, because the interactive prompt from `drizzle-kit push` cannot be automated in the deployment pipeline.
|
||||
|
||||
## What Was Done
|
||||
|
||||
### 1. Created Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS room_game_configs (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
game_name TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (room_id) REFERENCES arcade_rooms(id) ON UPDATE NO ACTION ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Created Index
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS room_game_idx ON room_game_configs (room_id, game_name);
|
||||
```
|
||||
|
||||
### 3. Migrated Existing Data
|
||||
|
||||
Migrated 6000 game configs from the old `arcade_rooms.game_config` column to the new normalized table:
|
||||
|
||||
```sql
|
||||
INSERT OR IGNORE INTO room_game_configs (id, room_id, game_name, config, created_at, updated_at)
|
||||
SELECT
|
||||
lower(hex(randomblob(16))) as id,
|
||||
id as room_id,
|
||||
game_name,
|
||||
game_config as config,
|
||||
created_at,
|
||||
last_activity as updated_at
|
||||
FROM arcade_rooms
|
||||
WHERE game_config IS NOT NULL
|
||||
AND game_name IS NOT NULL;
|
||||
```
|
||||
|
||||
**Results:**
|
||||
- 5991 matching game configs migrated
|
||||
- 9 memory-quiz game configs migrated
|
||||
- Total: 6000 configs
|
||||
|
||||
## Old vs New Schema
|
||||
|
||||
**Old Schema:**
|
||||
- `arcade_rooms.game_config` (TEXT/JSON) - stored config for currently selected game only
|
||||
- Config was lost when switching games
|
||||
|
||||
**New Schema:**
|
||||
- `room_game_configs` table - one row per game per room
|
||||
- Unique constraint on (room_id, game_name)
|
||||
- Configs persist when switching between games
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Verify table exists
|
||||
sqlite3 data/sqlite.db ".tables" | grep room_game_configs
|
||||
|
||||
# Verify schema
|
||||
sqlite3 data/sqlite.db ".schema room_game_configs"
|
||||
|
||||
# Count migrated data
|
||||
sqlite3 data/sqlite.db "SELECT COUNT(*) FROM room_game_configs;"
|
||||
# Expected: 6000
|
||||
|
||||
# Check data distribution
|
||||
sqlite3 data/sqlite.db "SELECT game_name, COUNT(*) FROM room_game_configs GROUP BY game_name;"
|
||||
# Expected: matching: 5991, memory-quiz: 9
|
||||
```
|
||||
|
||||
## Related Files
|
||||
|
||||
This migration supports the refactoring documented in:
|
||||
- `.claude/GAME_SETTINGS_PERSISTENCE.md` - Architecture documentation
|
||||
- `src/lib/arcade/game-configs.ts` - Shared config types
|
||||
- `src/lib/arcade/game-config-helpers.ts` - Database access helpers
|
||||
|
||||
## Note on Drizzle Migration Tracking
|
||||
|
||||
This migration was NOT recorded in drizzle's `__drizzle_migrations` table because it was applied manually. This is acceptable because:
|
||||
|
||||
1. The schema definition exists in code (`src/db/schema/room-game-configs.ts`)
|
||||
2. The table was created with the exact schema drizzle would generate
|
||||
3. Future schema changes will go through proper drizzle migrations
|
||||
4. The `arcade_rooms.game_config` column is preserved for rollback safety
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues arise, the old system can be restored by:
|
||||
|
||||
1. Reverting code changes (game-config-helpers.ts, API routes, validators)
|
||||
2. The old `game_config` column still exists in `arcade_rooms` table
|
||||
3. Data is still there (we only read from it, didn't delete it)
|
||||
|
||||
The new `room_game_configs` table can be dropped if needed:
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS room_game_configs;
|
||||
```
|
||||
|
||||
## Future Work
|
||||
|
||||
Once this migration is stable in production:
|
||||
|
||||
1. Consider dropping the old `arcade_rooms.game_config` column
|
||||
2. Add this migration to drizzle's migration journal for tracking (optional)
|
||||
3. Monitor for any issues with settings persistence
|
||||
@@ -27,7 +27,55 @@
|
||||
"Bash(docker run:*)",
|
||||
"Bash(docker rmi:*)",
|
||||
"Bash(gh run list:*)",
|
||||
"Bash(gh run view:*)"
|
||||
"Bash(gh run view:*)",
|
||||
"Bash(timeout 15 pnpm run dev:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npx biome format:*)",
|
||||
"Bash(npx biome check:*)",
|
||||
"Bash(npx @biomejs/biome lint:*)",
|
||||
"Bash(test -f /Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/hooks/__tests__/useArcadeGuard.test.ts)",
|
||||
"Bash(timeout 30 npm test -- AddPlayerButton.popover-persistence.test.tsx --run)",
|
||||
"Bash(timeout 30 npm test:*)",
|
||||
"Bash(xargs:*)",
|
||||
"Bash(for file in page.tsx practice/page.tsx sprint/page.tsx survival/page.tsx)",
|
||||
"Bash(do)",
|
||||
"Bash(done)",
|
||||
"Bash(npx playwright test:*)",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(\"\")",
|
||||
"Bash(npx @biomejs/biome check:*)",
|
||||
"Bash(printf '\\n')",
|
||||
"Bash(npm install bcryptjs)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(pnpm add:*)",
|
||||
"Bash(sqlite3:*)",
|
||||
"Bash(shasum:*)",
|
||||
"Bash(awk:*)",
|
||||
"Bash(if npx tsc --noEmit)",
|
||||
"Bash(then echo \"TypeScript errors found in our files\")",
|
||||
"Bash(else echo \"✓ No TypeScript errors in our modified files\")",
|
||||
"Bash(fi)",
|
||||
"Bash(then echo \"TypeScript errors found\")",
|
||||
"Bash(else echo \"✓ No TypeScript errors in join page\")",
|
||||
"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:*)",
|
||||
"Bash(timeout 10 npm run dev:*)",
|
||||
"Bash(timeout 30 npm run dev)",
|
||||
"Bash(pkill:*)",
|
||||
"Bash(for i in {1..30})",
|
||||
"Bash(do gh run list --limit 1 --json conclusion,status,name,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - Run ID: \\(.databaseId)\"\"')"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import type { StorybookConfig } from "@storybook/nextjs";
|
||||
import type { StorybookConfig } from '@storybook/nextjs'
|
||||
|
||||
import { dirname, join } from "path";
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
/**
|
||||
* This function is used to resolve the absolute path of a package.
|
||||
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
|
||||
*/
|
||||
function getAbsolutePath(value: string): any {
|
||||
return dirname(require.resolve(join(value, "package.json")));
|
||||
return dirname(require.resolve(join(value, 'package.json')))
|
||||
}
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: [
|
||||
getAbsolutePath("@storybook/addon-docs"),
|
||||
getAbsolutePath("@storybook/addon-onboarding"),
|
||||
getAbsolutePath('@storybook/addon-docs'),
|
||||
getAbsolutePath('@storybook/addon-onboarding'),
|
||||
],
|
||||
framework: {
|
||||
name: getAbsolutePath("@storybook/nextjs"),
|
||||
name: getAbsolutePath('@storybook/nextjs'),
|
||||
options: {
|
||||
nextConfigPath: "../next.config.js",
|
||||
nextConfigPath: '../next.config.js',
|
||||
},
|
||||
},
|
||||
staticDirs: ["../public"],
|
||||
staticDirs: ['../public'],
|
||||
typescript: {
|
||||
reactDocgen: "react-docgen-typescript",
|
||||
reactDocgen: 'react-docgen-typescript',
|
||||
},
|
||||
webpackFinal: async (config) => {
|
||||
// Handle PandaCSS styled-system imports
|
||||
@@ -31,25 +31,13 @@ const config: StorybookConfig = {
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
// Map styled-system imports to the actual directory
|
||||
"../../styled-system/css": join(
|
||||
__dirname,
|
||||
"../styled-system/css/index.mjs",
|
||||
),
|
||||
"../../styled-system/patterns": join(
|
||||
__dirname,
|
||||
"../styled-system/patterns/index.mjs",
|
||||
),
|
||||
"../styled-system/css": join(
|
||||
__dirname,
|
||||
"../styled-system/css/index.mjs",
|
||||
),
|
||||
"../styled-system/patterns": join(
|
||||
__dirname,
|
||||
"../styled-system/patterns/index.mjs",
|
||||
),
|
||||
};
|
||||
'../../styled-system/css': join(__dirname, '../styled-system/css/index.mjs'),
|
||||
'../../styled-system/patterns': join(__dirname, '../styled-system/patterns/index.mjs'),
|
||||
'../styled-system/css': join(__dirname, '../styled-system/css/index.mjs'),
|
||||
'../styled-system/patterns': join(__dirname, '../styled-system/patterns/index.mjs'),
|
||||
}
|
||||
}
|
||||
return config;
|
||||
return config
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
}
|
||||
export default config
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Preview } from "@storybook/nextjs";
|
||||
import "../styled-system/styles.css";
|
||||
import type { Preview } from '@storybook/nextjs'
|
||||
import '../styled-system/styles.css'
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
@@ -10,6 +10,6 @@ const preview: Preview = {
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default preview;
|
||||
export default preview
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { db, schema } from "../src/db";
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { db, schema } from '../src/db'
|
||||
|
||||
/**
|
||||
* API Abacus Settings E2E Tests
|
||||
@@ -12,155 +12,152 @@ import { db, schema } from "../src/db";
|
||||
* These tests verify the abacus-settings API endpoints work correctly.
|
||||
*/
|
||||
|
||||
describe("Abacus Settings API", () => {
|
||||
let testUserId: string;
|
||||
let testGuestId: string;
|
||||
describe('Abacus Settings API', () => {
|
||||
let testUserId: string
|
||||
let testGuestId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a test user with unique guest ID
|
||||
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const [user] = await db
|
||||
.insert(schema.users)
|
||||
.values({ guestId: testGuestId })
|
||||
.returning();
|
||||
testUserId = user.id;
|
||||
});
|
||||
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning()
|
||||
testUserId = user.id
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up: delete test user (cascade deletes settings)
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId));
|
||||
});
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
||||
})
|
||||
|
||||
describe("GET /api/abacus-settings", () => {
|
||||
it("creates settings with defaults if none exist", async () => {
|
||||
describe('GET /api/abacus-settings', () => {
|
||||
it('creates settings with defaults if none exist', async () => {
|
||||
const [settings] = await db
|
||||
.insert(schema.abacusSettings)
|
||||
.values({ userId: testUserId })
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(settings).toBeDefined();
|
||||
expect(settings.colorScheme).toBe("place-value");
|
||||
expect(settings.beadShape).toBe("diamond");
|
||||
expect(settings.colorPalette).toBe("default");
|
||||
expect(settings.hideInactiveBeads).toBe(false);
|
||||
expect(settings.coloredNumerals).toBe(false);
|
||||
expect(settings.scaleFactor).toBe(1.0);
|
||||
expect(settings.showNumbers).toBe(true);
|
||||
expect(settings.animated).toBe(true);
|
||||
expect(settings.interactive).toBe(false);
|
||||
expect(settings.gestures).toBe(false);
|
||||
expect(settings.soundEnabled).toBe(true);
|
||||
expect(settings.soundVolume).toBe(0.8);
|
||||
});
|
||||
expect(settings).toBeDefined()
|
||||
expect(settings.colorScheme).toBe('place-value')
|
||||
expect(settings.beadShape).toBe('diamond')
|
||||
expect(settings.colorPalette).toBe('default')
|
||||
expect(settings.hideInactiveBeads).toBe(false)
|
||||
expect(settings.coloredNumerals).toBe(false)
|
||||
expect(settings.scaleFactor).toBe(1.0)
|
||||
expect(settings.showNumbers).toBe(true)
|
||||
expect(settings.animated).toBe(true)
|
||||
expect(settings.interactive).toBe(false)
|
||||
expect(settings.gestures).toBe(false)
|
||||
expect(settings.soundEnabled).toBe(true)
|
||||
expect(settings.soundVolume).toBe(0.8)
|
||||
})
|
||||
|
||||
it("returns existing settings", async () => {
|
||||
it('returns existing settings', async () => {
|
||||
// Create settings
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: testUserId,
|
||||
colorScheme: "monochrome",
|
||||
beadShape: "circle",
|
||||
colorScheme: 'monochrome',
|
||||
beadShape: 'circle',
|
||||
soundEnabled: false,
|
||||
soundVolume: 0.5,
|
||||
});
|
||||
})
|
||||
|
||||
const settings = await db.query.abacusSettings.findFirst({
|
||||
where: eq(schema.abacusSettings.userId, testUserId),
|
||||
});
|
||||
})
|
||||
|
||||
expect(settings).toBeDefined();
|
||||
expect(settings?.colorScheme).toBe("monochrome");
|
||||
expect(settings?.beadShape).toBe("circle");
|
||||
expect(settings?.soundEnabled).toBe(false);
|
||||
expect(settings?.soundVolume).toBe(0.5);
|
||||
});
|
||||
});
|
||||
expect(settings).toBeDefined()
|
||||
expect(settings?.colorScheme).toBe('monochrome')
|
||||
expect(settings?.beadShape).toBe('circle')
|
||||
expect(settings?.soundEnabled).toBe(false)
|
||||
expect(settings?.soundVolume).toBe(0.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe("PATCH /api/abacus-settings", () => {
|
||||
it("creates new settings if none exist", async () => {
|
||||
describe('PATCH /api/abacus-settings', () => {
|
||||
it('creates new settings if none exist', async () => {
|
||||
const [settings] = await db
|
||||
.insert(schema.abacusSettings)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
soundEnabled: false,
|
||||
})
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(settings).toBeDefined();
|
||||
expect(settings.soundEnabled).toBe(false);
|
||||
});
|
||||
expect(settings).toBeDefined()
|
||||
expect(settings.soundEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it("updates existing settings", async () => {
|
||||
it('updates existing settings', async () => {
|
||||
// Create initial settings
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: testUserId,
|
||||
colorScheme: "place-value",
|
||||
beadShape: "diamond",
|
||||
});
|
||||
colorScheme: 'place-value',
|
||||
beadShape: 'diamond',
|
||||
})
|
||||
|
||||
// Update
|
||||
const [updated] = await db
|
||||
.update(schema.abacusSettings)
|
||||
.set({
|
||||
colorScheme: "heaven-earth",
|
||||
beadShape: "square",
|
||||
colorScheme: 'heaven-earth',
|
||||
beadShape: 'square',
|
||||
})
|
||||
.where(eq(schema.abacusSettings.userId, testUserId))
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(updated.colorScheme).toBe("heaven-earth");
|
||||
expect(updated.beadShape).toBe("square");
|
||||
});
|
||||
expect(updated.colorScheme).toBe('heaven-earth')
|
||||
expect(updated.beadShape).toBe('square')
|
||||
})
|
||||
|
||||
it("updates only provided fields", async () => {
|
||||
it('updates only provided fields', async () => {
|
||||
// Create initial settings
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: testUserId,
|
||||
colorScheme: "place-value",
|
||||
colorScheme: 'place-value',
|
||||
soundEnabled: true,
|
||||
soundVolume: 0.8,
|
||||
});
|
||||
})
|
||||
|
||||
// Update only soundEnabled
|
||||
const [updated] = await db
|
||||
.update(schema.abacusSettings)
|
||||
.set({ soundEnabled: false })
|
||||
.where(eq(schema.abacusSettings.userId, testUserId))
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(updated.soundEnabled).toBe(false);
|
||||
expect(updated.colorScheme).toBe("place-value"); // unchanged
|
||||
expect(updated.soundVolume).toBe(0.8); // unchanged
|
||||
});
|
||||
expect(updated.soundEnabled).toBe(false)
|
||||
expect(updated.colorScheme).toBe('place-value') // unchanged
|
||||
expect(updated.soundVolume).toBe(0.8) // unchanged
|
||||
})
|
||||
|
||||
it("prevents setting invalid userId via foreign key constraint", async () => {
|
||||
it('prevents setting invalid userId via foreign key constraint', async () => {
|
||||
// Create initial settings
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: testUserId,
|
||||
});
|
||||
})
|
||||
|
||||
// Try to update with invalid userId - should fail
|
||||
await expect(async () => {
|
||||
await db
|
||||
.update(schema.abacusSettings)
|
||||
.set({
|
||||
userId: "HACKER_ID_INVALID",
|
||||
userId: 'HACKER_ID_INVALID',
|
||||
soundEnabled: false,
|
||||
})
|
||||
.where(eq(schema.abacusSettings.userId, testUserId));
|
||||
}).rejects.toThrow();
|
||||
});
|
||||
.where(eq(schema.abacusSettings.userId, testUserId))
|
||||
}).rejects.toThrow()
|
||||
})
|
||||
|
||||
it("allows updating all display settings", async () => {
|
||||
it('allows updating all display settings', async () => {
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: testUserId,
|
||||
});
|
||||
})
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.abacusSettings)
|
||||
.set({
|
||||
colorScheme: "alternating",
|
||||
beadShape: "circle",
|
||||
colorPalette: "colorblind",
|
||||
colorScheme: 'alternating',
|
||||
beadShape: 'circle',
|
||||
colorPalette: 'colorblind',
|
||||
hideInactiveBeads: true,
|
||||
coloredNumerals: true,
|
||||
scaleFactor: 1.5,
|
||||
@@ -172,127 +169,124 @@ describe("Abacus Settings API", () => {
|
||||
soundVolume: 0.3,
|
||||
})
|
||||
.where(eq(schema.abacusSettings.userId, testUserId))
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(updated.colorScheme).toBe("alternating");
|
||||
expect(updated.beadShape).toBe("circle");
|
||||
expect(updated.colorPalette).toBe("colorblind");
|
||||
expect(updated.hideInactiveBeads).toBe(true);
|
||||
expect(updated.coloredNumerals).toBe(true);
|
||||
expect(updated.scaleFactor).toBe(1.5);
|
||||
expect(updated.showNumbers).toBe(false);
|
||||
expect(updated.animated).toBe(false);
|
||||
expect(updated.interactive).toBe(true);
|
||||
expect(updated.gestures).toBe(true);
|
||||
expect(updated.soundEnabled).toBe(false);
|
||||
expect(updated.soundVolume).toBe(0.3);
|
||||
});
|
||||
});
|
||||
expect(updated.colorScheme).toBe('alternating')
|
||||
expect(updated.beadShape).toBe('circle')
|
||||
expect(updated.colorPalette).toBe('colorblind')
|
||||
expect(updated.hideInactiveBeads).toBe(true)
|
||||
expect(updated.coloredNumerals).toBe(true)
|
||||
expect(updated.scaleFactor).toBe(1.5)
|
||||
expect(updated.showNumbers).toBe(false)
|
||||
expect(updated.animated).toBe(false)
|
||||
expect(updated.interactive).toBe(true)
|
||||
expect(updated.gestures).toBe(true)
|
||||
expect(updated.soundEnabled).toBe(false)
|
||||
expect(updated.soundVolume).toBe(0.3)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Cascade delete behavior", () => {
|
||||
it("deletes settings when user is deleted", async () => {
|
||||
describe('Cascade delete behavior', () => {
|
||||
it('deletes settings when user is deleted', async () => {
|
||||
// Create settings
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: testUserId,
|
||||
soundEnabled: false,
|
||||
});
|
||||
})
|
||||
|
||||
// Verify settings exist
|
||||
let settings = await db.query.abacusSettings.findFirst({
|
||||
where: eq(schema.abacusSettings.userId, testUserId),
|
||||
});
|
||||
expect(settings).toBeDefined();
|
||||
})
|
||||
expect(settings).toBeDefined()
|
||||
|
||||
// Delete user
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId));
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
||||
|
||||
// Verify settings are gone
|
||||
settings = await db.query.abacusSettings.findFirst({
|
||||
where: eq(schema.abacusSettings.userId, testUserId),
|
||||
});
|
||||
expect(settings).toBeUndefined();
|
||||
});
|
||||
});
|
||||
})
|
||||
expect(settings).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Data isolation", () => {
|
||||
it("ensures settings are isolated per user", async () => {
|
||||
describe('Data isolation', () => {
|
||||
it('ensures settings are isolated per user', async () => {
|
||||
// Create another user
|
||||
const testGuestId2 = `test-guest-2-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const [user2] = await db
|
||||
.insert(schema.users)
|
||||
.values({ guestId: testGuestId2 })
|
||||
.returning();
|
||||
const testGuestId2 = `test-guest-2-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const [user2] = await db.insert(schema.users).values({ guestId: testGuestId2 }).returning()
|
||||
|
||||
try {
|
||||
// Create settings for both users
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: testUserId,
|
||||
colorScheme: "monochrome",
|
||||
});
|
||||
colorScheme: 'monochrome',
|
||||
})
|
||||
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: user2.id,
|
||||
colorScheme: "place-value",
|
||||
});
|
||||
colorScheme: 'place-value',
|
||||
})
|
||||
|
||||
// Verify isolation
|
||||
const settings1 = await db.query.abacusSettings.findFirst({
|
||||
where: eq(schema.abacusSettings.userId, testUserId),
|
||||
});
|
||||
})
|
||||
const settings2 = await db.query.abacusSettings.findFirst({
|
||||
where: eq(schema.abacusSettings.userId, user2.id),
|
||||
});
|
||||
})
|
||||
|
||||
expect(settings1?.colorScheme).toBe("monochrome");
|
||||
expect(settings2?.colorScheme).toBe("place-value");
|
||||
expect(settings1?.colorScheme).toBe('monochrome')
|
||||
expect(settings2?.colorScheme).toBe('place-value')
|
||||
} finally {
|
||||
// Clean up second user
|
||||
await db.delete(schema.users).where(eq(schema.users.id, user2.id));
|
||||
await db.delete(schema.users).where(eq(schema.users.id, user2.id))
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
describe("Security: userId injection prevention", () => {
|
||||
it("rejects attempts to update settings with non-existent userId", async () => {
|
||||
describe('Security: userId injection prevention', () => {
|
||||
it('rejects attempts to update settings with non-existent userId', async () => {
|
||||
// Create initial settings
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: testUserId,
|
||||
soundEnabled: true,
|
||||
});
|
||||
})
|
||||
|
||||
// Attempt to inject a fake userId
|
||||
await expect(async () => {
|
||||
await db
|
||||
.update(schema.abacusSettings)
|
||||
.set({
|
||||
userId: "HACKER_ID_NON_EXISTENT",
|
||||
userId: 'HACKER_ID_NON_EXISTENT',
|
||||
soundEnabled: false,
|
||||
})
|
||||
.where(eq(schema.abacusSettings.userId, testUserId));
|
||||
}).rejects.toThrow(/FOREIGN KEY constraint failed/);
|
||||
});
|
||||
.where(eq(schema.abacusSettings.userId, testUserId))
|
||||
}).rejects.toThrow(/FOREIGN KEY constraint failed/)
|
||||
})
|
||||
|
||||
it("prevents modifying another user's settings via userId injection", async () => {
|
||||
// Create victim user
|
||||
const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const [victimUser] = await db
|
||||
.insert(schema.users)
|
||||
.values({ guestId: victimGuestId })
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
try {
|
||||
// Create settings for both users
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: testUserId,
|
||||
colorScheme: "monochrome",
|
||||
colorScheme: 'monochrome',
|
||||
soundEnabled: true,
|
||||
});
|
||||
})
|
||||
|
||||
await db.insert(schema.abacusSettings).values({
|
||||
userId: victimUser.id,
|
||||
colorScheme: "place-value",
|
||||
colorScheme: 'place-value',
|
||||
soundEnabled: true,
|
||||
});
|
||||
})
|
||||
|
||||
// Attacker tries to change userId to victim's ID
|
||||
// This is rejected because userId is PRIMARY KEY (UNIQUE constraint)
|
||||
@@ -303,27 +297,27 @@ describe("Abacus Settings API", () => {
|
||||
userId: victimUser.id, // Trying to inject victim's ID
|
||||
soundEnabled: false,
|
||||
})
|
||||
.where(eq(schema.abacusSettings.userId, testUserId));
|
||||
}).rejects.toThrow(/UNIQUE constraint failed/);
|
||||
.where(eq(schema.abacusSettings.userId, testUserId))
|
||||
}).rejects.toThrow(/UNIQUE constraint failed/)
|
||||
|
||||
// Verify victim's settings are unchanged
|
||||
const victimSettings = await db.query.abacusSettings.findFirst({
|
||||
where: eq(schema.abacusSettings.userId, victimUser.id),
|
||||
});
|
||||
expect(victimSettings?.soundEnabled).toBe(true);
|
||||
expect(victimSettings?.colorScheme).toBe("place-value");
|
||||
})
|
||||
expect(victimSettings?.soundEnabled).toBe(true)
|
||||
expect(victimSettings?.colorScheme).toBe('place-value')
|
||||
} finally {
|
||||
await db.delete(schema.users).where(eq(schema.users.id, victimUser.id));
|
||||
await db.delete(schema.users).where(eq(schema.users.id, victimUser.id))
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
it("prevents creating settings for another user via userId injection", async () => {
|
||||
it('prevents creating settings for another user via userId injection', async () => {
|
||||
// Create victim user
|
||||
const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const [victimUser] = await db
|
||||
.insert(schema.users)
|
||||
.values({ guestId: victimGuestId })
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
try {
|
||||
// Try to create settings for victim with attacker's data
|
||||
@@ -333,18 +327,18 @@ describe("Abacus Settings API", () => {
|
||||
.insert(schema.abacusSettings)
|
||||
.values({
|
||||
userId: victimUser.id,
|
||||
colorScheme: "alternating", // Attacker's preference
|
||||
colorScheme: 'alternating', // Attacker's preference
|
||||
})
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
// This test shows that at the DB level, we CAN insert for any valid userId
|
||||
// The security comes from the API layer filtering userId from request body
|
||||
// and deriving it from the session cookie instead
|
||||
expect(maliciousSettings.userId).toBe(victimUser.id);
|
||||
expect(maliciousSettings.colorScheme).toBe("alternating");
|
||||
expect(maliciousSettings.userId).toBe(victimUser.id)
|
||||
expect(maliciousSettings.colorScheme).toBe('alternating')
|
||||
} finally {
|
||||
await db.delete(schema.users).where(eq(schema.users.id, victimUser.id));
|
||||
await db.delete(schema.users).where(eq(schema.users.id, victimUser.id))
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { db, schema } from "../src/db";
|
||||
import { createRoom } from "../src/lib/arcade/room-manager";
|
||||
import { addRoomMember } from "../src/lib/arcade/room-membership";
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { db, schema } from '../src/db'
|
||||
import { createRoom } from '../src/lib/arcade/room-manager'
|
||||
import { addRoomMember } from '../src/lib/arcade/room-membership'
|
||||
|
||||
/**
|
||||
* Arcade Rooms API E2E Tests
|
||||
@@ -18,458 +18,438 @@ import { addRoomMember } from "../src/lib/arcade/room-membership";
|
||||
* - Room code lookups
|
||||
*/
|
||||
|
||||
describe("Arcade Rooms API", () => {
|
||||
let testUserId1: string;
|
||||
let testUserId2: string;
|
||||
let testGuestId1: string;
|
||||
let testGuestId2: string;
|
||||
let testRoomId: string;
|
||||
describe('Arcade Rooms API', () => {
|
||||
let testUserId1: string
|
||||
let testUserId2: string
|
||||
let testGuestId1: string
|
||||
let testGuestId2: string
|
||||
let testRoomId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test users
|
||||
testGuestId1 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
testGuestId2 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
testGuestId1 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
testGuestId2 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
const [user1] = await db
|
||||
.insert(schema.users)
|
||||
.values({ guestId: testGuestId1 })
|
||||
.returning();
|
||||
const [user2] = await db
|
||||
.insert(schema.users)
|
||||
.values({ guestId: testGuestId2 })
|
||||
.returning();
|
||||
const [user1] = await db.insert(schema.users).values({ guestId: testGuestId1 }).returning()
|
||||
const [user2] = await db.insert(schema.users).values({ guestId: testGuestId2 }).returning()
|
||||
|
||||
testUserId1 = user1.id;
|
||||
testUserId2 = user2.id;
|
||||
});
|
||||
testUserId1 = user1.id
|
||||
testUserId2 = user2.id
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up rooms (cascade deletes members)
|
||||
if (testRoomId) {
|
||||
await db
|
||||
.delete(schema.arcadeRooms)
|
||||
.where(eq(schema.arcadeRooms.id, testRoomId));
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, testRoomId))
|
||||
}
|
||||
|
||||
// Clean up users
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId1));
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId2));
|
||||
});
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId1))
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId2))
|
||||
})
|
||||
|
||||
describe("Room Creation", () => {
|
||||
it("creates a room with valid data", async () => {
|
||||
describe('Room Creation', () => {
|
||||
it('creates a room with valid data', async () => {
|
||||
const room = await createRoom({
|
||||
name: "Test Room",
|
||||
name: 'Test Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: "Test User",
|
||||
gameName: "matching",
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6 },
|
||||
});
|
||||
})
|
||||
|
||||
testRoomId = room.id;
|
||||
testRoomId = room.id
|
||||
|
||||
expect(room).toBeDefined();
|
||||
expect(room.name).toBe("Test Room");
|
||||
expect(room.createdBy).toBe(testGuestId1);
|
||||
expect(room.gameName).toBe("matching");
|
||||
expect(room.status).toBe("lobby");
|
||||
expect(room.isLocked).toBe(false);
|
||||
expect(room.ttlMinutes).toBe(60);
|
||||
expect(room.code).toMatch(/^[A-Z0-9]{6}$/);
|
||||
});
|
||||
expect(room).toBeDefined()
|
||||
expect(room.name).toBe('Test Room')
|
||||
expect(room.createdBy).toBe(testGuestId1)
|
||||
expect(room.gameName).toBe('matching')
|
||||
expect(room.status).toBe('lobby')
|
||||
expect(room.accessMode).toBe('open')
|
||||
expect(room.ttlMinutes).toBe(60)
|
||||
expect(room.code).toMatch(/^[A-Z0-9]{6}$/)
|
||||
})
|
||||
|
||||
it("creates room with custom TTL", async () => {
|
||||
it('creates room with custom TTL', async () => {
|
||||
const room = await createRoom({
|
||||
name: "Custom TTL Room",
|
||||
name: 'Custom TTL Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: "Test User",
|
||||
gameName: "matching",
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
ttlMinutes: 120,
|
||||
});
|
||||
})
|
||||
|
||||
testRoomId = room.id;
|
||||
testRoomId = room.id
|
||||
|
||||
expect(room.ttlMinutes).toBe(120);
|
||||
});
|
||||
expect(room.ttlMinutes).toBe(120)
|
||||
})
|
||||
|
||||
it("generates unique room codes", async () => {
|
||||
it('generates unique room codes', async () => {
|
||||
const room1 = await createRoom({
|
||||
name: "Room 1",
|
||||
name: 'Room 1',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: "User 1",
|
||||
gameName: "matching",
|
||||
creatorName: 'User 1',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
});
|
||||
})
|
||||
|
||||
const room2 = await createRoom({
|
||||
name: "Room 2",
|
||||
name: 'Room 2',
|
||||
createdBy: testGuestId2,
|
||||
creatorName: "User 2",
|
||||
gameName: "matching",
|
||||
creatorName: 'User 2',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
});
|
||||
})
|
||||
|
||||
// Clean up both rooms
|
||||
testRoomId = room1.id;
|
||||
await db
|
||||
.delete(schema.arcadeRooms)
|
||||
.where(eq(schema.arcadeRooms.id, room2.id));
|
||||
testRoomId = room1.id
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room2.id))
|
||||
|
||||
expect(room1.code).not.toBe(room2.code);
|
||||
});
|
||||
});
|
||||
expect(room1.code).not.toBe(room2.code)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Room Retrieval", () => {
|
||||
describe('Room Retrieval', () => {
|
||||
beforeEach(async () => {
|
||||
// Create a test room
|
||||
const room = await createRoom({
|
||||
name: "Retrieval Test Room",
|
||||
name: 'Retrieval Test Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: "Test User",
|
||||
gameName: "matching",
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
});
|
||||
testRoomId = room.id;
|
||||
});
|
||||
})
|
||||
testRoomId = room.id
|
||||
})
|
||||
|
||||
it("retrieves room by ID", async () => {
|
||||
it('retrieves room by ID', async () => {
|
||||
const room = await db.query.arcadeRooms.findFirst({
|
||||
where: eq(schema.arcadeRooms.id, testRoomId),
|
||||
});
|
||||
})
|
||||
|
||||
expect(room).toBeDefined();
|
||||
expect(room?.id).toBe(testRoomId);
|
||||
expect(room?.name).toBe("Retrieval Test Room");
|
||||
});
|
||||
expect(room).toBeDefined()
|
||||
expect(room?.id).toBe(testRoomId)
|
||||
expect(room?.name).toBe('Retrieval Test Room')
|
||||
})
|
||||
|
||||
it("retrieves room by code", async () => {
|
||||
it('retrieves room by code', async () => {
|
||||
const createdRoom = await db.query.arcadeRooms.findFirst({
|
||||
where: eq(schema.arcadeRooms.id, testRoomId),
|
||||
});
|
||||
})
|
||||
|
||||
const room = await db.query.arcadeRooms.findFirst({
|
||||
where: eq(schema.arcadeRooms.code, createdRoom!.code),
|
||||
});
|
||||
})
|
||||
|
||||
expect(room).toBeDefined();
|
||||
expect(room?.id).toBe(testRoomId);
|
||||
});
|
||||
expect(room).toBeDefined()
|
||||
expect(room?.id).toBe(testRoomId)
|
||||
})
|
||||
|
||||
it("returns undefined for non-existent room", async () => {
|
||||
it('returns undefined for non-existent room', async () => {
|
||||
const room = await db.query.arcadeRooms.findFirst({
|
||||
where: eq(schema.arcadeRooms.id, "nonexistent-room-id"),
|
||||
});
|
||||
where: eq(schema.arcadeRooms.id, 'nonexistent-room-id'),
|
||||
})
|
||||
|
||||
expect(room).toBeUndefined();
|
||||
});
|
||||
});
|
||||
expect(room).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Room Updates", () => {
|
||||
describe('Room Updates', () => {
|
||||
beforeEach(async () => {
|
||||
const room = await createRoom({
|
||||
name: "Update Test Room",
|
||||
name: 'Update Test Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: "Test User",
|
||||
gameName: "matching",
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
});
|
||||
testRoomId = room.id;
|
||||
});
|
||||
})
|
||||
testRoomId = room.id
|
||||
})
|
||||
|
||||
it("updates room name", async () => {
|
||||
it('updates room name', async () => {
|
||||
const [updated] = await db
|
||||
.update(schema.arcadeRooms)
|
||||
.set({ name: "Updated Name" })
|
||||
.set({ name: 'Updated Name' })
|
||||
.where(eq(schema.arcadeRooms.id, testRoomId))
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(updated.name).toBe("Updated Name");
|
||||
});
|
||||
expect(updated.name).toBe('Updated Name')
|
||||
})
|
||||
|
||||
it("locks room", async () => {
|
||||
it('locks room', async () => {
|
||||
const [updated] = await db
|
||||
.update(schema.arcadeRooms)
|
||||
.set({ isLocked: true })
|
||||
.set({ accessMode: 'locked' })
|
||||
.where(eq(schema.arcadeRooms.id, testRoomId))
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(updated.isLocked).toBe(true);
|
||||
});
|
||||
expect(updated.accessMode).toBe('locked')
|
||||
})
|
||||
|
||||
it("updates room status", async () => {
|
||||
it('updates room status', async () => {
|
||||
const [updated] = await db
|
||||
.update(schema.arcadeRooms)
|
||||
.set({ status: "playing" })
|
||||
.set({ status: 'playing' })
|
||||
.where(eq(schema.arcadeRooms.id, testRoomId))
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(updated.status).toBe("playing");
|
||||
});
|
||||
expect(updated.status).toBe('playing')
|
||||
})
|
||||
|
||||
it("updates lastActivity on any change", async () => {
|
||||
it('updates lastActivity on any change', async () => {
|
||||
const originalRoom = await db.query.arcadeRooms.findFirst({
|
||||
where: eq(schema.arcadeRooms.id, testRoomId),
|
||||
});
|
||||
})
|
||||
|
||||
// Wait a bit to ensure different timestamp (at least 1 second for SQLite timestamp resolution)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100))
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.arcadeRooms)
|
||||
.set({ name: "Activity Test", lastActivity: new Date() })
|
||||
.set({ name: 'Activity Test', lastActivity: new Date() })
|
||||
.where(eq(schema.arcadeRooms.id, testRoomId))
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(updated.lastActivity.getTime()).toBeGreaterThan(
|
||||
originalRoom!.lastActivity.getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
expect(updated.lastActivity.getTime()).toBeGreaterThan(originalRoom!.lastActivity.getTime())
|
||||
})
|
||||
})
|
||||
|
||||
describe("Room Deletion", () => {
|
||||
it("deletes room", async () => {
|
||||
describe('Room Deletion', () => {
|
||||
it('deletes room', async () => {
|
||||
const room = await createRoom({
|
||||
name: "Delete Test Room",
|
||||
name: 'Delete Test Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: "Test User",
|
||||
gameName: "matching",
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
});
|
||||
})
|
||||
|
||||
await db
|
||||
.delete(schema.arcadeRooms)
|
||||
.where(eq(schema.arcadeRooms.id, room.id));
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room.id))
|
||||
|
||||
const deleted = await db.query.arcadeRooms.findFirst({
|
||||
where: eq(schema.arcadeRooms.id, room.id),
|
||||
});
|
||||
})
|
||||
|
||||
expect(deleted).toBeUndefined();
|
||||
});
|
||||
expect(deleted).toBeUndefined()
|
||||
})
|
||||
|
||||
it("cascades delete to room members", async () => {
|
||||
it('cascades delete to room members', async () => {
|
||||
const room = await createRoom({
|
||||
name: "Cascade Test Room",
|
||||
name: 'Cascade Test Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: "Test User",
|
||||
gameName: "matching",
|
||||
creatorName: 'Test User',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
});
|
||||
})
|
||||
|
||||
// Add member
|
||||
await addRoomMember({
|
||||
roomId: room.id,
|
||||
userId: testGuestId1,
|
||||
displayName: "Test User",
|
||||
});
|
||||
displayName: 'Test User',
|
||||
})
|
||||
|
||||
// Verify member exists
|
||||
const membersBefore = await db.query.roomMembers.findMany({
|
||||
where: eq(schema.roomMembers.roomId, room.id),
|
||||
});
|
||||
expect(membersBefore).toHaveLength(1);
|
||||
})
|
||||
expect(membersBefore).toHaveLength(1)
|
||||
|
||||
// Delete room
|
||||
await db
|
||||
.delete(schema.arcadeRooms)
|
||||
.where(eq(schema.arcadeRooms.id, room.id));
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room.id))
|
||||
|
||||
// Verify members deleted
|
||||
const membersAfter = await db.query.roomMembers.findMany({
|
||||
where: eq(schema.roomMembers.roomId, room.id),
|
||||
});
|
||||
expect(membersAfter).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
})
|
||||
expect(membersAfter).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Room Members", () => {
|
||||
describe('Room Members', () => {
|
||||
beforeEach(async () => {
|
||||
const room = await createRoom({
|
||||
name: "Members Test Room",
|
||||
name: 'Members Test Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: "Test User 1",
|
||||
gameName: "matching",
|
||||
creatorName: 'Test User 1',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
});
|
||||
testRoomId = room.id;
|
||||
});
|
||||
})
|
||||
testRoomId = room.id
|
||||
})
|
||||
|
||||
it("adds member to room", async () => {
|
||||
it('adds member to room', async () => {
|
||||
const result = await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId1,
|
||||
displayName: "Test User 1",
|
||||
displayName: 'Test User 1',
|
||||
isCreator: true,
|
||||
});
|
||||
})
|
||||
|
||||
expect(result.member).toBeDefined();
|
||||
expect(result.member.roomId).toBe(testRoomId);
|
||||
expect(result.member.userId).toBe(testGuestId1);
|
||||
expect(result.member.displayName).toBe("Test User 1");
|
||||
expect(result.member.isCreator).toBe(true);
|
||||
expect(result.member.isOnline).toBe(true);
|
||||
});
|
||||
expect(result.member).toBeDefined()
|
||||
expect(result.member.roomId).toBe(testRoomId)
|
||||
expect(result.member.userId).toBe(testGuestId1)
|
||||
expect(result.member.displayName).toBe('Test User 1')
|
||||
expect(result.member.isCreator).toBe(true)
|
||||
expect(result.member.isOnline).toBe(true)
|
||||
})
|
||||
|
||||
it("adds multiple members to room", async () => {
|
||||
it('adds multiple members to room', async () => {
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId1,
|
||||
displayName: "User 1",
|
||||
});
|
||||
displayName: 'User 1',
|
||||
})
|
||||
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
displayName: "User 2",
|
||||
});
|
||||
displayName: 'User 2',
|
||||
})
|
||||
|
||||
const members = await db.query.roomMembers.findMany({
|
||||
where: eq(schema.roomMembers.roomId, testRoomId),
|
||||
});
|
||||
})
|
||||
|
||||
expect(members).toHaveLength(2);
|
||||
});
|
||||
expect(members).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("updates existing member instead of creating duplicate", async () => {
|
||||
it('updates existing member instead of creating duplicate', async () => {
|
||||
// Add member first time
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId1,
|
||||
displayName: "First Time",
|
||||
});
|
||||
displayName: 'First Time',
|
||||
})
|
||||
|
||||
// Add same member again
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId1,
|
||||
displayName: "Second Time",
|
||||
});
|
||||
displayName: 'Second Time',
|
||||
})
|
||||
|
||||
const members = await db.query.roomMembers.findMany({
|
||||
where: eq(schema.roomMembers.roomId, testRoomId),
|
||||
});
|
||||
})
|
||||
|
||||
// Should still only have 1 member
|
||||
expect(members).toHaveLength(1);
|
||||
});
|
||||
expect(members).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("removes member from room", async () => {
|
||||
it('removes member from room', async () => {
|
||||
const result = await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId1,
|
||||
displayName: "Test User",
|
||||
});
|
||||
displayName: 'Test User',
|
||||
})
|
||||
|
||||
await db
|
||||
.delete(schema.roomMembers)
|
||||
.where(eq(schema.roomMembers.id, result.member.id));
|
||||
await db.delete(schema.roomMembers).where(eq(schema.roomMembers.id, result.member.id))
|
||||
|
||||
const members = await db.query.roomMembers.findMany({
|
||||
where: eq(schema.roomMembers.roomId, testRoomId),
|
||||
});
|
||||
})
|
||||
|
||||
expect(members).toHaveLength(0);
|
||||
});
|
||||
expect(members).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("tracks online status", async () => {
|
||||
it('tracks online status', async () => {
|
||||
const result = await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId1,
|
||||
displayName: "Test User",
|
||||
});
|
||||
displayName: 'Test User',
|
||||
})
|
||||
|
||||
expect(result.member.isOnline).toBe(true);
|
||||
expect(result.member.isOnline).toBe(true)
|
||||
|
||||
// Set offline
|
||||
const [updated] = await db
|
||||
.update(schema.roomMembers)
|
||||
.set({ isOnline: false })
|
||||
.where(eq(schema.roomMembers.id, result.member.id))
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(updated.isOnline).toBe(false);
|
||||
});
|
||||
});
|
||||
expect(updated.isOnline).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Access Control", () => {
|
||||
describe('Access Control', () => {
|
||||
beforeEach(async () => {
|
||||
const room = await createRoom({
|
||||
name: "Access Test Room",
|
||||
name: 'Access Test Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: "Creator",
|
||||
gameName: "matching",
|
||||
creatorName: 'Creator',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
});
|
||||
testRoomId = room.id;
|
||||
});
|
||||
})
|
||||
testRoomId = room.id
|
||||
})
|
||||
|
||||
it("identifies room creator correctly", async () => {
|
||||
it('identifies room creator correctly', async () => {
|
||||
const room = await db.query.arcadeRooms.findFirst({
|
||||
where: eq(schema.arcadeRooms.id, testRoomId),
|
||||
});
|
||||
})
|
||||
|
||||
expect(room?.createdBy).toBe(testGuestId1);
|
||||
});
|
||||
expect(room?.createdBy).toBe(testGuestId1)
|
||||
})
|
||||
|
||||
it("distinguishes creator from other users", async () => {
|
||||
it('distinguishes creator from other users', async () => {
|
||||
const room = await db.query.arcadeRooms.findFirst({
|
||||
where: eq(schema.arcadeRooms.id, testRoomId),
|
||||
});
|
||||
})
|
||||
|
||||
expect(room?.createdBy).not.toBe(testGuestId2);
|
||||
});
|
||||
});
|
||||
expect(room?.createdBy).not.toBe(testGuestId2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Room Listing", () => {
|
||||
describe('Room Listing', () => {
|
||||
beforeEach(async () => {
|
||||
// Create multiple test rooms
|
||||
const room1 = await createRoom({
|
||||
name: "Matching Room",
|
||||
name: 'Matching Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: "User 1",
|
||||
gameName: "matching",
|
||||
creatorName: 'User 1',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
});
|
||||
})
|
||||
|
||||
const room2 = await createRoom({
|
||||
name: "Memory Quiz Room",
|
||||
name: 'Memory Quiz Room',
|
||||
createdBy: testGuestId2,
|
||||
creatorName: "User 2",
|
||||
gameName: "memory-quiz",
|
||||
creatorName: 'User 2',
|
||||
gameName: 'memory-quiz',
|
||||
gameConfig: {},
|
||||
});
|
||||
})
|
||||
|
||||
testRoomId = room1.id;
|
||||
testRoomId = room1.id
|
||||
|
||||
// Clean up room2 after test
|
||||
afterEach(async () => {
|
||||
await db
|
||||
.delete(schema.arcadeRooms)
|
||||
.where(eq(schema.arcadeRooms.id, room2.id));
|
||||
});
|
||||
});
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room2.id))
|
||||
})
|
||||
})
|
||||
|
||||
it("lists all active rooms", async () => {
|
||||
it('lists all active rooms', async () => {
|
||||
const rooms = await db.query.arcadeRooms.findMany({
|
||||
where: eq(schema.arcadeRooms.status, "lobby"),
|
||||
});
|
||||
where: eq(schema.arcadeRooms.status, 'lobby'),
|
||||
})
|
||||
|
||||
expect(rooms.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
expect(rooms.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it("excludes locked rooms from listing", async () => {
|
||||
it('excludes locked rooms from listing', async () => {
|
||||
// Lock one room
|
||||
await db
|
||||
.update(schema.arcadeRooms)
|
||||
.set({ isLocked: true })
|
||||
.where(eq(schema.arcadeRooms.id, testRoomId));
|
||||
.set({ accessMode: 'locked' })
|
||||
.where(eq(schema.arcadeRooms.id, testRoomId))
|
||||
|
||||
const unlockedRooms = await db.query.arcadeRooms.findMany({
|
||||
where: eq(schema.arcadeRooms.isLocked, false),
|
||||
});
|
||||
const openRooms = await db.query.arcadeRooms.findMany({
|
||||
where: eq(schema.arcadeRooms.accessMode, 'open'),
|
||||
})
|
||||
|
||||
expect(unlockedRooms.every((r) => !r.isLocked)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
expect(openRooms.every((r) => r.accessMode === 'open')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { db, schema } from "../src/db";
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { db, schema } from '../src/db'
|
||||
|
||||
/**
|
||||
* API Players E2E Tests
|
||||
@@ -13,33 +13,30 @@ import { db, schema } from "../src/db";
|
||||
* They use the actual database and test the full request/response cycle.
|
||||
*/
|
||||
|
||||
describe("Players API", () => {
|
||||
let testUserId: string;
|
||||
let testGuestId: string;
|
||||
describe('Players API', () => {
|
||||
let testUserId: string
|
||||
let testGuestId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a test user with unique guest ID
|
||||
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const [user] = await db
|
||||
.insert(schema.users)
|
||||
.values({ guestId: testGuestId })
|
||||
.returning();
|
||||
testUserId = user.id;
|
||||
});
|
||||
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning()
|
||||
testUserId = user.id
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up: delete test user (cascade deletes players)
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId));
|
||||
});
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
||||
})
|
||||
|
||||
describe("POST /api/players", () => {
|
||||
it("creates a player with valid data", async () => {
|
||||
describe('POST /api/players', () => {
|
||||
it('creates a player with valid data', async () => {
|
||||
const playerData = {
|
||||
name: "Test Player",
|
||||
emoji: "😀",
|
||||
color: "#3b82f6",
|
||||
name: 'Test Player',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
isActive: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Simulate creating via DB (API would do this)
|
||||
const [player] = await db
|
||||
@@ -48,377 +45,422 @@ describe("Players API", () => {
|
||||
userId: testUserId,
|
||||
...playerData,
|
||||
})
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(player).toBeDefined();
|
||||
expect(player.name).toBe(playerData.name);
|
||||
expect(player.emoji).toBe(playerData.emoji);
|
||||
expect(player.color).toBe(playerData.color);
|
||||
expect(player.isActive).toBe(true);
|
||||
expect(player.userId).toBe(testUserId);
|
||||
});
|
||||
expect(player).toBeDefined()
|
||||
expect(player.name).toBe(playerData.name)
|
||||
expect(player.emoji).toBe(playerData.emoji)
|
||||
expect(player.color).toBe(playerData.color)
|
||||
expect(player.isActive).toBe(true)
|
||||
expect(player.userId).toBe(testUserId)
|
||||
})
|
||||
|
||||
it("sets isActive to false by default", async () => {
|
||||
it('sets isActive to false by default', async () => {
|
||||
const [player] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: "Inactive Player",
|
||||
emoji: "😴",
|
||||
color: "#999999",
|
||||
name: 'Inactive Player',
|
||||
emoji: '😴',
|
||||
color: '#999999',
|
||||
})
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(player.isActive).toBe(false);
|
||||
});
|
||||
});
|
||||
expect(player.isActive).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("GET /api/players", () => {
|
||||
it("returns all players for a user", async () => {
|
||||
describe('GET /api/players', () => {
|
||||
it('returns all players for a user', async () => {
|
||||
// Create multiple players
|
||||
await db.insert(schema.players).values([
|
||||
{
|
||||
userId: testUserId,
|
||||
name: "Player 1",
|
||||
emoji: "😀",
|
||||
color: "#3b82f6",
|
||||
name: 'Player 1',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
userId: testUserId,
|
||||
name: "Player 2",
|
||||
emoji: "😎",
|
||||
color: "#8b5cf6",
|
||||
name: 'Player 2',
|
||||
emoji: '😎',
|
||||
color: '#8b5cf6',
|
||||
},
|
||||
]);
|
||||
])
|
||||
|
||||
const players = await db.query.players.findMany({
|
||||
where: eq(schema.players.userId, testUserId),
|
||||
});
|
||||
})
|
||||
|
||||
expect(players).toHaveLength(2);
|
||||
expect(players[0].name).toBe("Player 1");
|
||||
expect(players[1].name).toBe("Player 2");
|
||||
});
|
||||
expect(players).toHaveLength(2)
|
||||
expect(players[0].name).toBe('Player 1')
|
||||
expect(players[1].name).toBe('Player 2')
|
||||
})
|
||||
|
||||
it("returns empty array for user with no players", async () => {
|
||||
it('returns empty array for user with no players', async () => {
|
||||
const players = await db.query.players.findMany({
|
||||
where: eq(schema.players.userId, testUserId),
|
||||
});
|
||||
})
|
||||
|
||||
expect(players).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
expect(players).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("PATCH /api/players/[id]", () => {
|
||||
it("updates player fields", async () => {
|
||||
describe('PATCH /api/players/[id]', () => {
|
||||
it('updates player fields', async () => {
|
||||
const [player] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: "Original Name",
|
||||
emoji: "😀",
|
||||
color: "#3b82f6",
|
||||
name: 'Original Name',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
})
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.players)
|
||||
.set({
|
||||
name: "Updated Name",
|
||||
emoji: "🎉",
|
||||
name: 'Updated Name',
|
||||
emoji: '🎉',
|
||||
})
|
||||
.where(eq(schema.players.id, player.id))
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(updated.name).toBe("Updated Name");
|
||||
expect(updated.emoji).toBe("🎉");
|
||||
expect(updated.color).toBe("#3b82f6"); // unchanged
|
||||
});
|
||||
expect(updated.name).toBe('Updated Name')
|
||||
expect(updated.emoji).toBe('🎉')
|
||||
expect(updated.color).toBe('#3b82f6') // unchanged
|
||||
})
|
||||
|
||||
it("toggles isActive status", async () => {
|
||||
it('toggles isActive status', async () => {
|
||||
const [player] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: "Test Player",
|
||||
emoji: "😀",
|
||||
color: "#3b82f6",
|
||||
name: 'Test Player',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
isActive: false,
|
||||
})
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.players)
|
||||
.set({ isActive: true })
|
||||
.where(eq(schema.players.id, player.id))
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(updated.isActive).toBe(true);
|
||||
});
|
||||
});
|
||||
expect(updated.isActive).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("DELETE /api/players/[id]", () => {
|
||||
it("deletes a player", async () => {
|
||||
describe('DELETE /api/players/[id]', () => {
|
||||
it('deletes a player', async () => {
|
||||
const [player] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: "To Delete",
|
||||
emoji: "👋",
|
||||
color: "#ef4444",
|
||||
name: 'To Delete',
|
||||
emoji: '👋',
|
||||
color: '#ef4444',
|
||||
})
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(schema.players)
|
||||
.where(eq(schema.players.id, player.id))
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(deleted).toBeDefined();
|
||||
expect(deleted.id).toBe(player.id);
|
||||
expect(deleted).toBeDefined()
|
||||
expect(deleted.id).toBe(player.id)
|
||||
|
||||
// Verify it's gone
|
||||
const found = await db.query.players.findFirst({
|
||||
where: eq(schema.players.id, player.id),
|
||||
});
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
})
|
||||
expect(found).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Cascade delete behavior", () => {
|
||||
it("deletes players when user is deleted", async () => {
|
||||
describe('Cascade delete behavior', () => {
|
||||
it('deletes players when user is deleted', async () => {
|
||||
// Create players
|
||||
await db.insert(schema.players).values([
|
||||
{
|
||||
userId: testUserId,
|
||||
name: "Player 1",
|
||||
emoji: "😀",
|
||||
color: "#3b82f6",
|
||||
name: 'Player 1',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
userId: testUserId,
|
||||
name: "Player 2",
|
||||
emoji: "😎",
|
||||
color: "#8b5cf6",
|
||||
name: 'Player 2',
|
||||
emoji: '😎',
|
||||
color: '#8b5cf6',
|
||||
},
|
||||
]);
|
||||
])
|
||||
|
||||
// Verify players exist
|
||||
let players = await db.query.players.findMany({
|
||||
where: eq(schema.players.userId, testUserId),
|
||||
});
|
||||
expect(players).toHaveLength(2);
|
||||
})
|
||||
expect(players).toHaveLength(2)
|
||||
|
||||
// Delete user
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId));
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
||||
|
||||
// Verify players are gone
|
||||
players = await db.query.players.findMany({
|
||||
where: eq(schema.players.userId, testUserId),
|
||||
});
|
||||
expect(players).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
})
|
||||
expect(players).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Arcade Session: isActive Modification Restrictions", () => {
|
||||
it("prevents isActive changes when user has an active arcade session", async () => {
|
||||
describe('Arcade Session: isActive Modification Restrictions', () => {
|
||||
it('prevents isActive changes when user has an active arcade session', async () => {
|
||||
// Create a player
|
||||
const [player] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: "Test Player",
|
||||
emoji: "😀",
|
||||
color: "#3b82f6",
|
||||
name: 'Test Player',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
isActive: false,
|
||||
})
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
// Create a test room for the session
|
||||
const [testRoom] = await db
|
||||
.insert(schema.arcadeRooms)
|
||||
.values({
|
||||
code: `TEST-${Date.now()}`,
|
||||
name: 'Test Room',
|
||||
gameName: 'matching',
|
||||
gameConfig: JSON.stringify({}),
|
||||
status: 'lobby',
|
||||
createdBy: testUserId,
|
||||
creatorName: 'Test User',
|
||||
ttlMinutes: 60,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Create an active arcade session
|
||||
const now = new Date();
|
||||
const now = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
userId: testGuestId,
|
||||
currentGame: "matching",
|
||||
gameUrl: "/arcade/matching",
|
||||
roomId: testRoom.id,
|
||||
userId: testUserId,
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
gameState: JSON.stringify({}),
|
||||
activePlayers: JSON.stringify([player.id]),
|
||||
startedAt: now,
|
||||
lastActivityAt: now,
|
||||
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
|
||||
version: 1,
|
||||
});
|
||||
})
|
||||
|
||||
// Attempt to update isActive should be prevented at API level
|
||||
// This test validates the logic that the API route implements
|
||||
const activeSession = await db.query.arcadeSessions.findFirst({
|
||||
where: eq(schema.arcadeSessions.userId, testGuestId),
|
||||
});
|
||||
where: eq(schema.arcadeSessions.roomId, testRoom.id),
|
||||
})
|
||||
|
||||
expect(activeSession).toBeDefined();
|
||||
expect(activeSession?.currentGame).toBe("matching");
|
||||
expect(activeSession).toBeDefined()
|
||||
expect(activeSession?.currentGame).toBe('matching')
|
||||
|
||||
// Clean up session
|
||||
await db
|
||||
.delete(schema.arcadeSessions)
|
||||
.where(eq(schema.arcadeSessions.userId, testGuestId));
|
||||
});
|
||||
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, testRoom.id))
|
||||
})
|
||||
|
||||
it("allows isActive changes when user has no active arcade session", async () => {
|
||||
it('allows isActive changes when user has no active arcade session', async () => {
|
||||
// Create a player
|
||||
const [player] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: "Test Player",
|
||||
emoji: "😀",
|
||||
color: "#3b82f6",
|
||||
name: 'Test Player',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
isActive: false,
|
||||
})
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
// Verify no active session
|
||||
// Verify no active session for this user
|
||||
const activeSession = await db.query.arcadeSessions.findFirst({
|
||||
where: eq(schema.arcadeSessions.userId, testGuestId),
|
||||
});
|
||||
where: eq(schema.arcadeSessions.userId, testUserId),
|
||||
})
|
||||
|
||||
expect(activeSession).toBeUndefined();
|
||||
expect(activeSession).toBeUndefined()
|
||||
|
||||
// Should be able to update isActive
|
||||
const [updated] = await db
|
||||
.update(schema.players)
|
||||
.set({ isActive: true })
|
||||
.where(eq(schema.players.id, player.id))
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(updated.isActive).toBe(true);
|
||||
});
|
||||
expect(updated.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it("allows non-isActive changes even with active session", async () => {
|
||||
it('allows non-isActive changes even with active session', async () => {
|
||||
// Create a player
|
||||
const [player] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: "Test Player",
|
||||
emoji: "😀",
|
||||
color: "#3b82f6",
|
||||
name: 'Test Player',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
isActive: true,
|
||||
})
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
// Create a test room for the session
|
||||
const [testRoom] = await db
|
||||
.insert(schema.arcadeRooms)
|
||||
.values({
|
||||
code: `TEST-${Date.now()}`,
|
||||
name: 'Test Room',
|
||||
gameName: 'matching',
|
||||
gameConfig: JSON.stringify({}),
|
||||
status: 'lobby',
|
||||
createdBy: testUserId,
|
||||
creatorName: 'Test User',
|
||||
ttlMinutes: 60,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Create an active arcade session
|
||||
const now = new Date();
|
||||
const now = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
userId: testGuestId,
|
||||
currentGame: "matching",
|
||||
gameUrl: "/arcade/matching",
|
||||
roomId: testRoom.id,
|
||||
userId: testUserId,
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
gameState: JSON.stringify({}),
|
||||
activePlayers: JSON.stringify([player.id]),
|
||||
startedAt: now,
|
||||
lastActivityAt: now,
|
||||
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
|
||||
version: 1,
|
||||
});
|
||||
})
|
||||
|
||||
try {
|
||||
// Should be able to update name, emoji, color (non-isActive fields)
|
||||
const [updated] = await db
|
||||
.update(schema.players)
|
||||
.set({
|
||||
name: "Updated Name",
|
||||
emoji: "🎉",
|
||||
color: "#ff0000",
|
||||
name: 'Updated Name',
|
||||
emoji: '🎉',
|
||||
color: '#ff0000',
|
||||
})
|
||||
.where(eq(schema.players.id, player.id))
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(updated.name).toBe("Updated Name");
|
||||
expect(updated.emoji).toBe("🎉");
|
||||
expect(updated.color).toBe("#ff0000");
|
||||
expect(updated.isActive).toBe(true); // Unchanged
|
||||
expect(updated.name).toBe('Updated Name')
|
||||
expect(updated.emoji).toBe('🎉')
|
||||
expect(updated.color).toBe('#ff0000')
|
||||
expect(updated.isActive).toBe(true) // Unchanged
|
||||
} finally {
|
||||
// Clean up session
|
||||
await db
|
||||
.delete(schema.arcadeSessions)
|
||||
.where(eq(schema.arcadeSessions.userId, testGuestId));
|
||||
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, testRoom.id))
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
it("session ends, then isActive changes are allowed again", async () => {
|
||||
it('session ends, then isActive changes are allowed again', async () => {
|
||||
// Create a player
|
||||
const [player] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: "Test Player",
|
||||
emoji: "😀",
|
||||
color: "#3b82f6",
|
||||
name: 'Test Player',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
isActive: true,
|
||||
})
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
// Create a test room for the session
|
||||
const [testRoom] = await db
|
||||
.insert(schema.arcadeRooms)
|
||||
.values({
|
||||
code: `TEST-${Date.now()}`,
|
||||
name: 'Test Room',
|
||||
gameName: 'matching',
|
||||
gameConfig: JSON.stringify({}),
|
||||
status: 'lobby',
|
||||
createdBy: testUserId,
|
||||
creatorName: 'Test User',
|
||||
ttlMinutes: 60,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Create an active arcade session
|
||||
const now = new Date();
|
||||
const now = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
userId: testGuestId,
|
||||
currentGame: "matching",
|
||||
gameUrl: "/arcade/matching",
|
||||
roomId: testRoom.id,
|
||||
userId: testUserId,
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
gameState: JSON.stringify({}),
|
||||
activePlayers: JSON.stringify([player.id]),
|
||||
startedAt: now,
|
||||
lastActivityAt: now,
|
||||
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
|
||||
version: 1,
|
||||
});
|
||||
})
|
||||
|
||||
// Verify session exists
|
||||
let activeSession = await db.query.arcadeSessions.findFirst({
|
||||
where: eq(schema.arcadeSessions.userId, testGuestId),
|
||||
});
|
||||
expect(activeSession).toBeDefined();
|
||||
where: eq(schema.arcadeSessions.roomId, testRoom.id),
|
||||
})
|
||||
expect(activeSession).toBeDefined()
|
||||
|
||||
// End the session
|
||||
await db
|
||||
.delete(schema.arcadeSessions)
|
||||
.where(eq(schema.arcadeSessions.userId, testGuestId));
|
||||
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, testRoom.id))
|
||||
|
||||
// Verify session is gone
|
||||
activeSession = await db.query.arcadeSessions.findFirst({
|
||||
where: eq(schema.arcadeSessions.userId, testGuestId),
|
||||
});
|
||||
expect(activeSession).toBeUndefined();
|
||||
where: eq(schema.arcadeSessions.roomId, testRoom.id),
|
||||
})
|
||||
expect(activeSession).toBeUndefined()
|
||||
|
||||
// Now should be able to update isActive
|
||||
const [updated] = await db
|
||||
.update(schema.players)
|
||||
.set({ isActive: false })
|
||||
.where(eq(schema.players.id, player.id))
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(updated.isActive).toBe(false);
|
||||
});
|
||||
});
|
||||
expect(updated.isActive).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Security: userId injection prevention", () => {
|
||||
it("rejects creating player with non-existent userId", async () => {
|
||||
describe('Security: userId injection prevention', () => {
|
||||
it('rejects creating player with non-existent userId', async () => {
|
||||
// Attempt to create a player with a fake userId
|
||||
await expect(async () => {
|
||||
await db.insert(schema.players).values({
|
||||
userId: "HACKER_ID_NON_EXISTENT",
|
||||
name: "Hacker Player",
|
||||
emoji: "🦹",
|
||||
color: "#ff0000",
|
||||
});
|
||||
}).rejects.toThrow(/FOREIGN KEY constraint failed/);
|
||||
});
|
||||
userId: 'HACKER_ID_NON_EXISTENT',
|
||||
name: 'Hacker Player',
|
||||
emoji: '🦹',
|
||||
color: '#ff0000',
|
||||
})
|
||||
}).rejects.toThrow(/FOREIGN KEY constraint failed/)
|
||||
})
|
||||
|
||||
it("prevents modifying another user's player via userId injection (DB layer alone is insufficient)", async () => {
|
||||
// Create victim user and their player
|
||||
const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const [victimUser] = await db
|
||||
.insert(schema.users)
|
||||
.values({ guestId: victimGuestId })
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
try {
|
||||
// Create attacker's player
|
||||
@@ -426,22 +468,22 @@ describe("Players API", () => {
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: "Attacker Player",
|
||||
emoji: "😈",
|
||||
color: "#ff0000",
|
||||
name: 'Attacker Player',
|
||||
emoji: '😈',
|
||||
color: '#ff0000',
|
||||
})
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
const [_victimPlayer] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: victimUser.id,
|
||||
name: "Victim Player",
|
||||
emoji: "👤",
|
||||
color: "#00ff00",
|
||||
name: 'Victim Player',
|
||||
emoji: '👤',
|
||||
color: '#00ff00',
|
||||
isActive: true,
|
||||
})
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
// IMPORTANT: At the DB level, changing userId to another valid userId SUCCEEDS
|
||||
// This is why API layer MUST filter userId from request body!
|
||||
@@ -449,64 +491,61 @@ describe("Players API", () => {
|
||||
.update(schema.players)
|
||||
.set({
|
||||
userId: victimUser.id, // This WILL succeed at DB level!
|
||||
name: "Stolen Player",
|
||||
name: 'Stolen Player',
|
||||
})
|
||||
.where(eq(schema.players.id, attackerPlayer.id))
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
// The update succeeded - the player now belongs to victim!
|
||||
expect(updated.userId).toBe(victimUser.id);
|
||||
expect(updated.name).toBe("Stolen Player");
|
||||
expect(updated.userId).toBe(victimUser.id)
|
||||
expect(updated.name).toBe('Stolen Player')
|
||||
|
||||
// This test demonstrates why the API route MUST:
|
||||
// 1. Strip userId from request body
|
||||
// 2. Derive userId from session cookie
|
||||
// 3. Use WHERE clause to scope updates to current user's data only
|
||||
} finally {
|
||||
await db.delete(schema.users).where(eq(schema.users.id, victimUser.id));
|
||||
await db.delete(schema.users).where(eq(schema.users.id, victimUser.id))
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
it("ensures players are isolated per user", async () => {
|
||||
it('ensures players are isolated per user', async () => {
|
||||
// Create another user
|
||||
const user2GuestId = `user2-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const [user2] = await db
|
||||
.insert(schema.users)
|
||||
.values({ guestId: user2GuestId })
|
||||
.returning();
|
||||
const user2GuestId = `user2-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const [user2] = await db.insert(schema.users).values({ guestId: user2GuestId }).returning()
|
||||
|
||||
try {
|
||||
// Create players for both users
|
||||
await db.insert(schema.players).values({
|
||||
userId: testUserId,
|
||||
name: "User 1 Player",
|
||||
emoji: "🎮",
|
||||
color: "#0000ff",
|
||||
});
|
||||
name: 'User 1 Player',
|
||||
emoji: '🎮',
|
||||
color: '#0000ff',
|
||||
})
|
||||
|
||||
await db.insert(schema.players).values({
|
||||
userId: user2.id,
|
||||
name: "User 2 Player",
|
||||
emoji: "🎯",
|
||||
color: "#ff00ff",
|
||||
});
|
||||
name: 'User 2 Player',
|
||||
emoji: '🎯',
|
||||
color: '#ff00ff',
|
||||
})
|
||||
|
||||
// Verify each user only sees their own players
|
||||
const user1Players = await db.query.players.findMany({
|
||||
where: eq(schema.players.userId, testUserId),
|
||||
});
|
||||
})
|
||||
const user2Players = await db.query.players.findMany({
|
||||
where: eq(schema.players.userId, user2.id),
|
||||
});
|
||||
})
|
||||
|
||||
expect(user1Players).toHaveLength(1);
|
||||
expect(user1Players[0].name).toBe("User 1 Player");
|
||||
expect(user1Players).toHaveLength(1)
|
||||
expect(user1Players[0].name).toBe('User 1 Player')
|
||||
|
||||
expect(user2Players).toHaveLength(1);
|
||||
expect(user2Players[0].name).toBe("User 2 Player");
|
||||
expect(user2Players).toHaveLength(1)
|
||||
expect(user2Players[0].name).toBe('User 2 Player')
|
||||
} finally {
|
||||
await db.delete(schema.users).where(eq(schema.users.id, user2.id));
|
||||
await db.delete(schema.users).where(eq(schema.users.id, user2.id))
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { db, schema } from "../src/db";
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { db, schema } from '../src/db'
|
||||
|
||||
/**
|
||||
* API User Stats E2E Tests
|
||||
@@ -12,66 +12,60 @@ import { db, schema } from "../src/db";
|
||||
* These tests verify the user-stats API endpoints work correctly.
|
||||
*/
|
||||
|
||||
describe("User Stats API", () => {
|
||||
let testUserId: string;
|
||||
let testGuestId: string;
|
||||
describe('User Stats API', () => {
|
||||
let testUserId: string
|
||||
let testGuestId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a test user with unique guest ID
|
||||
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const [user] = await db
|
||||
.insert(schema.users)
|
||||
.values({ guestId: testGuestId })
|
||||
.returning();
|
||||
testUserId = user.id;
|
||||
});
|
||||
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning()
|
||||
testUserId = user.id
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up: delete test user (cascade deletes stats)
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId));
|
||||
});
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
||||
})
|
||||
|
||||
describe("GET /api/user-stats", () => {
|
||||
it("creates stats with defaults if none exist", async () => {
|
||||
const [stats] = await db
|
||||
.insert(schema.userStats)
|
||||
.values({ userId: testUserId })
|
||||
.returning();
|
||||
describe('GET /api/user-stats', () => {
|
||||
it('creates stats with defaults if none exist', async () => {
|
||||
const [stats] = await db.insert(schema.userStats).values({ userId: testUserId }).returning()
|
||||
|
||||
expect(stats).toBeDefined();
|
||||
expect(stats.gamesPlayed).toBe(0);
|
||||
expect(stats.totalWins).toBe(0);
|
||||
expect(stats.favoriteGameType).toBeNull();
|
||||
expect(stats.bestTime).toBeNull();
|
||||
expect(stats.highestAccuracy).toBe(0);
|
||||
});
|
||||
expect(stats).toBeDefined()
|
||||
expect(stats.gamesPlayed).toBe(0)
|
||||
expect(stats.totalWins).toBe(0)
|
||||
expect(stats.favoriteGameType).toBeNull()
|
||||
expect(stats.bestTime).toBeNull()
|
||||
expect(stats.highestAccuracy).toBe(0)
|
||||
})
|
||||
|
||||
it("returns existing stats", async () => {
|
||||
it('returns existing stats', async () => {
|
||||
// Create stats
|
||||
await db.insert(schema.userStats).values({
|
||||
userId: testUserId,
|
||||
gamesPlayed: 10,
|
||||
totalWins: 7,
|
||||
favoriteGameType: "abacus-numeral",
|
||||
favoriteGameType: 'abacus-numeral',
|
||||
bestTime: 5000,
|
||||
highestAccuracy: 0.95,
|
||||
});
|
||||
})
|
||||
|
||||
const stats = await db.query.userStats.findFirst({
|
||||
where: eq(schema.userStats.userId, testUserId),
|
||||
});
|
||||
})
|
||||
|
||||
expect(stats).toBeDefined();
|
||||
expect(stats?.gamesPlayed).toBe(10);
|
||||
expect(stats?.totalWins).toBe(7);
|
||||
expect(stats?.favoriteGameType).toBe("abacus-numeral");
|
||||
expect(stats?.bestTime).toBe(5000);
|
||||
expect(stats?.highestAccuracy).toBe(0.95);
|
||||
});
|
||||
});
|
||||
expect(stats).toBeDefined()
|
||||
expect(stats?.gamesPlayed).toBe(10)
|
||||
expect(stats?.totalWins).toBe(7)
|
||||
expect(stats?.favoriteGameType).toBe('abacus-numeral')
|
||||
expect(stats?.bestTime).toBe(5000)
|
||||
expect(stats?.highestAccuracy).toBe(0.95)
|
||||
})
|
||||
})
|
||||
|
||||
describe("PATCH /api/user-stats", () => {
|
||||
it("creates new stats if none exist", async () => {
|
||||
describe('PATCH /api/user-stats', () => {
|
||||
it('creates new stats if none exist', async () => {
|
||||
const [stats] = await db
|
||||
.insert(schema.userStats)
|
||||
.values({
|
||||
@@ -79,20 +73,20 @@ describe("User Stats API", () => {
|
||||
gamesPlayed: 1,
|
||||
totalWins: 1,
|
||||
})
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(stats).toBeDefined();
|
||||
expect(stats.gamesPlayed).toBe(1);
|
||||
expect(stats.totalWins).toBe(1);
|
||||
});
|
||||
expect(stats).toBeDefined()
|
||||
expect(stats.gamesPlayed).toBe(1)
|
||||
expect(stats.totalWins).toBe(1)
|
||||
})
|
||||
|
||||
it("updates existing stats", async () => {
|
||||
it('updates existing stats', async () => {
|
||||
// Create initial stats
|
||||
await db.insert(schema.userStats).values({
|
||||
userId: testUserId,
|
||||
gamesPlayed: 5,
|
||||
totalWins: 3,
|
||||
});
|
||||
})
|
||||
|
||||
// Update
|
||||
const [updated] = await db
|
||||
@@ -100,55 +94,55 @@ describe("User Stats API", () => {
|
||||
.set({
|
||||
gamesPlayed: 6,
|
||||
totalWins: 4,
|
||||
favoriteGameType: "complement-pairs",
|
||||
favoriteGameType: 'complement-pairs',
|
||||
})
|
||||
.where(eq(schema.userStats.userId, testUserId))
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(updated.gamesPlayed).toBe(6);
|
||||
expect(updated.totalWins).toBe(4);
|
||||
expect(updated.favoriteGameType).toBe("complement-pairs");
|
||||
});
|
||||
expect(updated.gamesPlayed).toBe(6)
|
||||
expect(updated.totalWins).toBe(4)
|
||||
expect(updated.favoriteGameType).toBe('complement-pairs')
|
||||
})
|
||||
|
||||
it("updates only provided fields", async () => {
|
||||
it('updates only provided fields', async () => {
|
||||
// Create initial stats
|
||||
await db.insert(schema.userStats).values({
|
||||
userId: testUserId,
|
||||
gamesPlayed: 10,
|
||||
totalWins: 5,
|
||||
bestTime: 3000,
|
||||
});
|
||||
})
|
||||
|
||||
// Update only gamesPlayed
|
||||
const [updated] = await db
|
||||
.update(schema.userStats)
|
||||
.set({ gamesPlayed: 11 })
|
||||
.where(eq(schema.userStats.userId, testUserId))
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(updated.gamesPlayed).toBe(11);
|
||||
expect(updated.totalWins).toBe(5); // unchanged
|
||||
expect(updated.bestTime).toBe(3000); // unchanged
|
||||
});
|
||||
expect(updated.gamesPlayed).toBe(11)
|
||||
expect(updated.totalWins).toBe(5) // unchanged
|
||||
expect(updated.bestTime).toBe(3000) // unchanged
|
||||
})
|
||||
|
||||
it("allows setting favoriteGameType", async () => {
|
||||
it('allows setting favoriteGameType', async () => {
|
||||
await db.insert(schema.userStats).values({
|
||||
userId: testUserId,
|
||||
});
|
||||
})
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.userStats)
|
||||
.set({ favoriteGameType: "abacus-numeral" })
|
||||
.set({ favoriteGameType: 'abacus-numeral' })
|
||||
.where(eq(schema.userStats.userId, testUserId))
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(updated.favoriteGameType).toBe("abacus-numeral");
|
||||
});
|
||||
expect(updated.favoriteGameType).toBe('abacus-numeral')
|
||||
})
|
||||
|
||||
it("allows setting bestTime and highestAccuracy", async () => {
|
||||
it('allows setting bestTime and highestAccuracy', async () => {
|
||||
await db.insert(schema.userStats).values({
|
||||
userId: testUserId,
|
||||
});
|
||||
})
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.userStats)
|
||||
@@ -157,36 +151,36 @@ describe("User Stats API", () => {
|
||||
highestAccuracy: 0.98,
|
||||
})
|
||||
.where(eq(schema.userStats.userId, testUserId))
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
expect(updated.bestTime).toBe(2500);
|
||||
expect(updated.highestAccuracy).toBe(0.98);
|
||||
});
|
||||
});
|
||||
expect(updated.bestTime).toBe(2500)
|
||||
expect(updated.highestAccuracy).toBe(0.98)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Cascade delete behavior", () => {
|
||||
it("deletes stats when user is deleted", async () => {
|
||||
describe('Cascade delete behavior', () => {
|
||||
it('deletes stats when user is deleted', async () => {
|
||||
// Create stats
|
||||
await db.insert(schema.userStats).values({
|
||||
userId: testUserId,
|
||||
gamesPlayed: 10,
|
||||
totalWins: 5,
|
||||
});
|
||||
})
|
||||
|
||||
// Verify stats exist
|
||||
let stats = await db.query.userStats.findFirst({
|
||||
where: eq(schema.userStats.userId, testUserId),
|
||||
});
|
||||
expect(stats).toBeDefined();
|
||||
})
|
||||
expect(stats).toBeDefined()
|
||||
|
||||
// Delete user
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId));
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
||||
|
||||
// Verify stats are gone
|
||||
stats = await db.query.userStats.findFirst({
|
||||
where: eq(schema.userStats.userId, testUserId),
|
||||
});
|
||||
expect(stats).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
expect(stats).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,135 +2,135 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { GUEST_COOKIE_NAME, verifyGuestToken } from "../src/lib/guest-token";
|
||||
import { middleware } from "../src/middleware";
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { GUEST_COOKIE_NAME, verifyGuestToken } from '../src/lib/guest-token'
|
||||
import { middleware } from '../src/middleware'
|
||||
|
||||
describe("Middleware E2E", () => {
|
||||
describe('Middleware E2E', () => {
|
||||
beforeEach(() => {
|
||||
process.env.AUTH_SECRET = "test-secret-for-middleware";
|
||||
});
|
||||
process.env.AUTH_SECRET = 'test-secret-for-middleware'
|
||||
})
|
||||
|
||||
it("sets guest cookie on first request", async () => {
|
||||
const req = new NextRequest("http://localhost:3000/");
|
||||
const res = await middleware(req);
|
||||
it('sets guest cookie on first request', async () => {
|
||||
const req = new NextRequest('http://localhost:3000/')
|
||||
const res = await middleware(req)
|
||||
|
||||
const cookie = res.cookies.get(GUEST_COOKIE_NAME);
|
||||
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
|
||||
|
||||
expect(cookie).toBeDefined();
|
||||
expect(cookie?.value).toBeDefined();
|
||||
expect(cookie?.httpOnly).toBe(true);
|
||||
expect(cookie?.sameSite).toBe("lax");
|
||||
expect(cookie?.path).toBe("/");
|
||||
});
|
||||
expect(cookie).toBeDefined()
|
||||
expect(cookie?.value).toBeDefined()
|
||||
expect(cookie?.httpOnly).toBe(true)
|
||||
expect(cookie?.sameSite).toBe('lax')
|
||||
expect(cookie?.path).toBe('/')
|
||||
})
|
||||
|
||||
it("creates valid guest token", async () => {
|
||||
const req = new NextRequest("http://localhost:3000/");
|
||||
const res = await middleware(req);
|
||||
it('creates valid guest token', async () => {
|
||||
const req = new NextRequest('http://localhost:3000/')
|
||||
const res = await middleware(req)
|
||||
|
||||
const cookie = res.cookies.get(GUEST_COOKIE_NAME);
|
||||
expect(cookie).toBeDefined();
|
||||
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
|
||||
expect(cookie).toBeDefined()
|
||||
|
||||
// Verify the token is valid
|
||||
const verified = await verifyGuestToken(cookie!.value);
|
||||
expect(verified.sid).toBeDefined();
|
||||
expect(typeof verified.sid).toBe("string");
|
||||
});
|
||||
const verified = await verifyGuestToken(cookie!.value)
|
||||
expect(verified.sid).toBeDefined()
|
||||
expect(typeof verified.sid).toBe('string')
|
||||
})
|
||||
|
||||
it("preserves existing guest cookie", async () => {
|
||||
it('preserves existing guest cookie', async () => {
|
||||
// First request - creates cookie
|
||||
const req1 = new NextRequest("http://localhost:3000/");
|
||||
const res1 = await middleware(req1);
|
||||
const cookie1 = res1.cookies.get(GUEST_COOKIE_NAME);
|
||||
const req1 = new NextRequest('http://localhost:3000/')
|
||||
const res1 = await middleware(req1)
|
||||
const cookie1 = res1.cookies.get(GUEST_COOKIE_NAME)
|
||||
|
||||
// Second request - with existing cookie
|
||||
const req2 = new NextRequest("http://localhost:3000/");
|
||||
req2.cookies.set(GUEST_COOKIE_NAME, cookie1!.value);
|
||||
const res2 = await middleware(req2);
|
||||
const req2 = new NextRequest('http://localhost:3000/')
|
||||
req2.cookies.set(GUEST_COOKIE_NAME, cookie1!.value)
|
||||
const res2 = await middleware(req2)
|
||||
|
||||
const cookie2 = res2.cookies.get(GUEST_COOKIE_NAME);
|
||||
const cookie2 = res2.cookies.get(GUEST_COOKIE_NAME)
|
||||
|
||||
// Cookie should not be set again (preserves existing)
|
||||
expect(cookie2).toBeUndefined();
|
||||
});
|
||||
expect(cookie2).toBeUndefined()
|
||||
})
|
||||
|
||||
it("sets different guest IDs for different visitors", async () => {
|
||||
const req1 = new NextRequest("http://localhost:3000/");
|
||||
const req2 = new NextRequest("http://localhost:3000/");
|
||||
it('sets different guest IDs for different visitors', async () => {
|
||||
const req1 = new NextRequest('http://localhost:3000/')
|
||||
const req2 = new NextRequest('http://localhost:3000/')
|
||||
|
||||
const res1 = await middleware(req1);
|
||||
const res2 = await middleware(req2);
|
||||
const res1 = await middleware(req1)
|
||||
const res2 = await middleware(req2)
|
||||
|
||||
const cookie1 = res1.cookies.get(GUEST_COOKIE_NAME);
|
||||
const cookie2 = res2.cookies.get(GUEST_COOKIE_NAME);
|
||||
const cookie1 = res1.cookies.get(GUEST_COOKIE_NAME)
|
||||
const cookie2 = res2.cookies.get(GUEST_COOKIE_NAME)
|
||||
|
||||
const verified1 = await verifyGuestToken(cookie1!.value);
|
||||
const verified2 = await verifyGuestToken(cookie2!.value);
|
||||
const verified1 = await verifyGuestToken(cookie1!.value)
|
||||
const verified2 = await verifyGuestToken(cookie2!.value)
|
||||
|
||||
// Different visitors get different guest IDs
|
||||
expect(verified1.sid).not.toBe(verified2.sid);
|
||||
});
|
||||
expect(verified1.sid).not.toBe(verified2.sid)
|
||||
})
|
||||
|
||||
it("sets secure flag in production", async () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
Object.defineProperty(process.env, "NODE_ENV", {
|
||||
value: "production",
|
||||
it('sets secure flag in production', async () => {
|
||||
const originalEnv = process.env.NODE_ENV
|
||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
||||
value: 'production',
|
||||
configurable: true,
|
||||
});
|
||||
})
|
||||
|
||||
const req = new NextRequest("http://localhost:3000/");
|
||||
const res = await middleware(req);
|
||||
const req = new NextRequest('http://localhost:3000/')
|
||||
const res = await middleware(req)
|
||||
|
||||
const cookie = res.cookies.get(GUEST_COOKIE_NAME);
|
||||
expect(cookie?.secure).toBe(true);
|
||||
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
|
||||
expect(cookie?.secure).toBe(true)
|
||||
|
||||
Object.defineProperty(process.env, "NODE_ENV", {
|
||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
||||
value: originalEnv,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
it("does not set secure flag in development", async () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
Object.defineProperty(process.env, "NODE_ENV", {
|
||||
value: "development",
|
||||
it('does not set secure flag in development', async () => {
|
||||
const originalEnv = process.env.NODE_ENV
|
||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
||||
value: 'development',
|
||||
configurable: true,
|
||||
});
|
||||
})
|
||||
|
||||
const req = new NextRequest("http://localhost:3000/");
|
||||
const res = await middleware(req);
|
||||
const req = new NextRequest('http://localhost:3000/')
|
||||
const res = await middleware(req)
|
||||
|
||||
const cookie = res.cookies.get(GUEST_COOKIE_NAME);
|
||||
expect(cookie?.secure).toBe(false);
|
||||
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
|
||||
expect(cookie?.secure).toBe(false)
|
||||
|
||||
Object.defineProperty(process.env, "NODE_ENV", {
|
||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
||||
value: originalEnv,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
it("sets maxAge correctly", async () => {
|
||||
const req = new NextRequest("http://localhost:3000/");
|
||||
const res = await middleware(req);
|
||||
it('sets maxAge correctly', async () => {
|
||||
const req = new NextRequest('http://localhost:3000/')
|
||||
const res = await middleware(req)
|
||||
|
||||
const cookie = res.cookies.get(GUEST_COOKIE_NAME);
|
||||
expect(cookie?.maxAge).toBe(60 * 60 * 24 * 30); // 30 days
|
||||
});
|
||||
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
|
||||
expect(cookie?.maxAge).toBe(60 * 60 * 24 * 30) // 30 days
|
||||
})
|
||||
|
||||
it("runs on valid paths", async () => {
|
||||
it('runs on valid paths', async () => {
|
||||
const paths = [
|
||||
"http://localhost:3000/",
|
||||
"http://localhost:3000/games",
|
||||
"http://localhost:3000/tutorial-editor",
|
||||
"http://localhost:3000/some/deep/path",
|
||||
];
|
||||
'http://localhost:3000/',
|
||||
'http://localhost:3000/games',
|
||||
'http://localhost:3000/tutorial-editor',
|
||||
'http://localhost:3000/some/deep/path',
|
||||
]
|
||||
|
||||
for (const path of paths) {
|
||||
const req = new NextRequest(path);
|
||||
const res = await middleware(req);
|
||||
const cookie = res.cookies.get(GUEST_COOKIE_NAME);
|
||||
expect(cookie).toBeDefined();
|
||||
const req = new NextRequest(path)
|
||||
const res = await middleware(req)
|
||||
const cookie = res.cookies.get(GUEST_COOKIE_NAME)
|
||||
expect(cookie).toBeDefined()
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, schema } from "../src/db";
|
||||
import {
|
||||
createArcadeSession,
|
||||
getArcadeSession,
|
||||
} from "../src/lib/arcade/session-manager";
|
||||
import {
|
||||
cleanupExpiredRooms,
|
||||
createRoom,
|
||||
} from "../src/lib/arcade/room-manager";
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db, schema } from '../src/db'
|
||||
import { createArcadeSession, getArcadeSession } from '../src/lib/arcade/session-manager'
|
||||
import { cleanupExpiredRooms, createRoom } from '../src/lib/arcade/room-manager'
|
||||
|
||||
/**
|
||||
* E2E Test: Orphaned Session After Room TTL Deletion
|
||||
@@ -20,10 +14,10 @@ import {
|
||||
* 4. System should NOT redirect to the orphaned game
|
||||
* 5. User should see the arcade lobby normally
|
||||
*/
|
||||
describe("E2E: Orphaned Session Cleanup on Navigation", () => {
|
||||
const testUserId = "e2e-user-id";
|
||||
const testGuestId = "e2e-guest-id";
|
||||
let testRoomId: string;
|
||||
describe('E2E: Orphaned Session Cleanup on Navigation', () => {
|
||||
const testUserId = 'e2e-user-id'
|
||||
const testGuestId = 'e2e-guest-id'
|
||||
let testRoomId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test user (simulating new or returning visitor)
|
||||
@@ -34,63 +28,59 @@ describe("E2E: Orphaned Session Cleanup on Navigation", () => {
|
||||
guestId: testGuestId,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
});
|
||||
.onConflictDoNothing()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test data
|
||||
await db
|
||||
.delete(schema.arcadeSessions)
|
||||
.where(eq(schema.arcadeSessions.userId, testUserId));
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId));
|
||||
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testUserId))
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
||||
if (testRoomId) {
|
||||
try {
|
||||
await db
|
||||
.delete(schema.arcadeRooms)
|
||||
.where(eq(schema.arcadeRooms.id, testRoomId));
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, testRoomId))
|
||||
} catch {
|
||||
// Room may already be deleted
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
it("should not redirect user to orphaned game after room TTL cleanup", async () => {
|
||||
it('should not redirect user to orphaned game after room TTL cleanup', async () => {
|
||||
// === SETUP PHASE ===
|
||||
// User creates or joins a room
|
||||
const room = await createRoom({
|
||||
name: "My Game Room",
|
||||
name: 'My Game Room',
|
||||
createdBy: testGuestId,
|
||||
creatorName: "Test Player",
|
||||
gameName: "matching",
|
||||
gameConfig: { difficulty: 6, gameType: "abacus-numeral", turnTimer: 30 },
|
||||
creatorName: 'Test Player',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6, gameType: 'abacus-numeral', turnTimer: 30 },
|
||||
ttlMinutes: 1, // Short TTL for testing
|
||||
});
|
||||
testRoomId = room.id;
|
||||
})
|
||||
testRoomId = room.id
|
||||
|
||||
// User starts a game session
|
||||
const session = await createArcadeSession({
|
||||
userId: testGuestId,
|
||||
gameName: "matching",
|
||||
gameUrl: "/arcade/matching",
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: {
|
||||
gamePhase: "playing",
|
||||
gamePhase: 'playing',
|
||||
cards: [],
|
||||
gameCards: [],
|
||||
flippedCards: [],
|
||||
matchedPairs: 0,
|
||||
totalPairs: 6,
|
||||
currentPlayer: "player-1",
|
||||
currentPlayer: 'player-1',
|
||||
difficulty: 6,
|
||||
gameType: "abacus-numeral",
|
||||
gameType: 'abacus-numeral',
|
||||
turnTimer: 30,
|
||||
},
|
||||
activePlayers: ["player-1"],
|
||||
activePlayers: ['player-1'],
|
||||
roomId: room.id,
|
||||
});
|
||||
})
|
||||
|
||||
// Verify session was created
|
||||
expect(session).toBeDefined();
|
||||
expect(session.roomId).toBe(room.id);
|
||||
expect(session).toBeDefined()
|
||||
expect(session.roomId).toBe(room.id)
|
||||
|
||||
// === TTL EXPIRATION PHASE ===
|
||||
// Simulate time passing - room's TTL expires
|
||||
@@ -100,118 +90,114 @@ describe("E2E: Orphaned Session Cleanup on Navigation", () => {
|
||||
.set({
|
||||
lastActivity: new Date(Date.now() - 2 * 60 * 1000), // 2 minutes ago
|
||||
})
|
||||
.where(eq(schema.arcadeRooms.id, room.id));
|
||||
.where(eq(schema.arcadeRooms.id, room.id))
|
||||
|
||||
// Run cleanup (simulating background cleanup job)
|
||||
const deletedCount = await cleanupExpiredRooms();
|
||||
expect(deletedCount).toBeGreaterThan(0); // Room should be deleted
|
||||
const deletedCount = await cleanupExpiredRooms()
|
||||
expect(deletedCount).toBeGreaterThan(0) // Room should be deleted
|
||||
|
||||
// === USER NAVIGATION PHASE ===
|
||||
// User navigates to /arcade (arcade lobby)
|
||||
// The useArcadeRedirect hook calls getArcadeSession to check for active session
|
||||
const activeSession = await getArcadeSession(testGuestId);
|
||||
// Client checks for active session
|
||||
const activeSession = await getArcadeSession(testGuestId)
|
||||
|
||||
// === ASSERTION PHASE ===
|
||||
// Expected behavior: NO active session returned
|
||||
// This prevents redirect to /arcade/matching which would be broken
|
||||
expect(activeSession).toBeUndefined();
|
||||
expect(activeSession).toBeUndefined()
|
||||
|
||||
// Verify the orphaned session was cleaned up from database
|
||||
const [orphanedSessionCheck] = await db
|
||||
.select()
|
||||
.from(schema.arcadeSessions)
|
||||
.where(eq(schema.arcadeSessions.userId, testUserId))
|
||||
.limit(1);
|
||||
.limit(1)
|
||||
|
||||
expect(orphanedSessionCheck).toBeUndefined();
|
||||
});
|
||||
expect(orphanedSessionCheck).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should allow user to start new game after orphaned session cleanup", async () => {
|
||||
it('should allow user to start new game after orphaned session cleanup', async () => {
|
||||
// === SETUP: Create and orphan a session ===
|
||||
const oldRoom = await createRoom({
|
||||
name: "Old Room",
|
||||
name: 'Old Room',
|
||||
createdBy: testGuestId,
|
||||
creatorName: "Test Player",
|
||||
gameName: "matching",
|
||||
creatorName: 'Test Player',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6 },
|
||||
ttlMinutes: 1,
|
||||
});
|
||||
})
|
||||
|
||||
await createArcadeSession({
|
||||
userId: testGuestId,
|
||||
gameName: "matching",
|
||||
gameUrl: "/arcade/matching",
|
||||
initialState: { gamePhase: "setup" },
|
||||
activePlayers: ["player-1"],
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: { gamePhase: 'setup' },
|
||||
activePlayers: ['player-1'],
|
||||
roomId: oldRoom.id,
|
||||
});
|
||||
})
|
||||
|
||||
// Delete room (TTL cleanup)
|
||||
await db
|
||||
.delete(schema.arcadeRooms)
|
||||
.where(eq(schema.arcadeRooms.id, oldRoom.id));
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, oldRoom.id))
|
||||
|
||||
// === ACTION: User tries to access arcade ===
|
||||
const orphanedSession = await getArcadeSession(testGuestId);
|
||||
expect(orphanedSession).toBeUndefined(); // Orphan cleaned up
|
||||
const orphanedSession = await getArcadeSession(testGuestId)
|
||||
expect(orphanedSession).toBeUndefined() // Orphan cleaned up
|
||||
|
||||
// === ACTION: User creates new room and session ===
|
||||
const newRoom = await createRoom({
|
||||
name: "New Room",
|
||||
name: 'New Room',
|
||||
createdBy: testGuestId,
|
||||
creatorName: "Test Player",
|
||||
gameName: "matching",
|
||||
creatorName: 'Test Player',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 8 },
|
||||
ttlMinutes: 60,
|
||||
});
|
||||
testRoomId = newRoom.id;
|
||||
})
|
||||
testRoomId = newRoom.id
|
||||
|
||||
const newSession = await createArcadeSession({
|
||||
userId: testGuestId,
|
||||
gameName: "matching",
|
||||
gameUrl: "/arcade/matching",
|
||||
initialState: { gamePhase: "setup" },
|
||||
activePlayers: ["player-1", "player-2"],
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: { gamePhase: 'setup' },
|
||||
activePlayers: ['player-1', 'player-2'],
|
||||
roomId: newRoom.id,
|
||||
});
|
||||
})
|
||||
|
||||
// === ASSERTION: New session works correctly ===
|
||||
expect(newSession).toBeDefined();
|
||||
expect(newSession.roomId).toBe(newRoom.id);
|
||||
expect(newSession).toBeDefined()
|
||||
expect(newSession.roomId).toBe(newRoom.id)
|
||||
|
||||
const activeSession = await getArcadeSession(testGuestId);
|
||||
expect(activeSession).toBeDefined();
|
||||
expect(activeSession?.roomId).toBe(newRoom.id);
|
||||
});
|
||||
const activeSession = await getArcadeSession(testGuestId)
|
||||
expect(activeSession).toBeDefined()
|
||||
expect(activeSession?.roomId).toBe(newRoom.id)
|
||||
})
|
||||
|
||||
it("should handle race condition: getArcadeSession called while room is being deleted", async () => {
|
||||
it('should handle race condition: getArcadeSession called while room is being deleted', async () => {
|
||||
// Create room and session
|
||||
const room = await createRoom({
|
||||
name: "Race Condition Room",
|
||||
name: 'Race Condition Room',
|
||||
createdBy: testGuestId,
|
||||
creatorName: "Test Player",
|
||||
gameName: "matching",
|
||||
creatorName: 'Test Player',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6 },
|
||||
ttlMinutes: 60,
|
||||
});
|
||||
testRoomId = room.id;
|
||||
})
|
||||
testRoomId = room.id
|
||||
|
||||
await createArcadeSession({
|
||||
userId: testGuestId,
|
||||
gameName: "matching",
|
||||
gameUrl: "/arcade/matching",
|
||||
initialState: { gamePhase: "setup" },
|
||||
activePlayers: ["player-1"],
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: { gamePhase: 'setup' },
|
||||
activePlayers: ['player-1'],
|
||||
roomId: room.id,
|
||||
});
|
||||
})
|
||||
|
||||
// Simulate race: delete room while getArcadeSession is checking
|
||||
await db
|
||||
.delete(schema.arcadeRooms)
|
||||
.where(eq(schema.arcadeRooms.id, room.id));
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room.id))
|
||||
|
||||
// Should gracefully handle and return undefined
|
||||
const result = await getArcadeSession(testGuestId);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
const result = await getArcadeSession(testGuestId)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,23 +2,15 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { createServer } from "http";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { io as ioClient, type Socket } from "socket.io-client";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
afterAll,
|
||||
beforeAll,
|
||||
} from "vitest";
|
||||
import { db, schema } from "../src/db";
|
||||
import { createRoom } from "../src/lib/arcade/room-manager";
|
||||
import { addRoomMember } from "../src/lib/arcade/room-membership";
|
||||
import { initializeSocketServer } from "../socket-server";
|
||||
import type { Server as SocketIOServerType } from "socket.io";
|
||||
import { createServer } from 'http'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { io as ioClient, type Socket } from 'socket.io-client'
|
||||
import { afterEach, beforeEach, describe, expect, it, afterAll, beforeAll } from 'vitest'
|
||||
import { db, schema } from '../src/db'
|
||||
import { createRoom } from '../src/lib/arcade/room-manager'
|
||||
import { addRoomMember } from '../src/lib/arcade/room-membership'
|
||||
import { initializeSocketServer } from '../socket-server'
|
||||
import type { Server as SocketIOServerType } from 'socket.io'
|
||||
|
||||
/**
|
||||
* Real-time Room Updates E2E Tests
|
||||
@@ -27,385 +19,353 @@ import type { Server as SocketIOServerType } from "socket.io";
|
||||
* Simulates multiple connected users and verifies they receive real-time updates.
|
||||
*/
|
||||
|
||||
describe("Room Real-time Updates", () => {
|
||||
let testUserId1: string;
|
||||
let testUserId2: string;
|
||||
let testGuestId1: string;
|
||||
let testGuestId2: string;
|
||||
let testRoomId: string;
|
||||
let socket1: Socket;
|
||||
let httpServer: any;
|
||||
let io: SocketIOServerType;
|
||||
let serverPort: number;
|
||||
describe('Room Real-time Updates', () => {
|
||||
let testUserId1: string
|
||||
let testUserId2: string
|
||||
let testGuestId1: string
|
||||
let testGuestId2: string
|
||||
let testRoomId: string
|
||||
let socket1: Socket
|
||||
let httpServer: any
|
||||
let io: SocketIOServerType
|
||||
let serverPort: number
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create HTTP server and initialize Socket.IO for testing
|
||||
httpServer = createServer();
|
||||
io = initializeSocketServer(httpServer);
|
||||
httpServer = createServer()
|
||||
io = initializeSocketServer(httpServer)
|
||||
|
||||
// Find an available port
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.listen(0, () => {
|
||||
serverPort = (httpServer.address() as any).port;
|
||||
console.log(`Test socket server listening on port ${serverPort}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
serverPort = (httpServer.address() as any).port
|
||||
console.log(`Test socket server listening on port ${serverPort}`)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
// Close all socket connections
|
||||
if (io) {
|
||||
io.close();
|
||||
io.close()
|
||||
}
|
||||
if (httpServer) {
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.close(() => resolve());
|
||||
});
|
||||
httpServer.close(() => resolve())
|
||||
})
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test users
|
||||
testGuestId1 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
testGuestId2 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
testGuestId1 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
testGuestId2 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
const [user1] = await db
|
||||
.insert(schema.users)
|
||||
.values({ guestId: testGuestId1 })
|
||||
.returning();
|
||||
const [user2] = await db
|
||||
.insert(schema.users)
|
||||
.values({ guestId: testGuestId2 })
|
||||
.returning();
|
||||
const [user1] = await db.insert(schema.users).values({ guestId: testGuestId1 }).returning()
|
||||
const [user2] = await db.insert(schema.users).values({ guestId: testGuestId2 }).returning()
|
||||
|
||||
testUserId1 = user1.id;
|
||||
testUserId2 = user2.id;
|
||||
testUserId1 = user1.id
|
||||
testUserId2 = user2.id
|
||||
|
||||
// Create a test room
|
||||
const room = await createRoom({
|
||||
name: "Realtime Test Room",
|
||||
name: 'Realtime Test Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: "User 1",
|
||||
gameName: "matching",
|
||||
creatorName: 'User 1',
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6 },
|
||||
ttlMinutes: 60,
|
||||
});
|
||||
testRoomId = room.id;
|
||||
});
|
||||
})
|
||||
testRoomId = room.id
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Disconnect sockets
|
||||
if (socket1?.connected) {
|
||||
socket1.disconnect();
|
||||
socket1.disconnect()
|
||||
}
|
||||
|
||||
// Clean up room members
|
||||
await db
|
||||
.delete(schema.roomMembers)
|
||||
.where(eq(schema.roomMembers.roomId, testRoomId));
|
||||
await db.delete(schema.roomMembers).where(eq(schema.roomMembers.roomId, testRoomId))
|
||||
|
||||
// Clean up rooms
|
||||
if (testRoomId) {
|
||||
await db
|
||||
.delete(schema.arcadeRooms)
|
||||
.where(eq(schema.arcadeRooms.id, testRoomId));
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, testRoomId))
|
||||
}
|
||||
|
||||
// Clean up users
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId1));
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId2));
|
||||
});
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId1))
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId2))
|
||||
})
|
||||
|
||||
it("should broadcast member-joined when a user joins via API", async () => {
|
||||
it('should broadcast member-joined when a user joins via API', async () => {
|
||||
// User 1 joins the room via API first (this is what happens when they click "Join Room")
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId1,
|
||||
displayName: "User 1",
|
||||
displayName: 'User 1',
|
||||
isCreator: false,
|
||||
});
|
||||
})
|
||||
|
||||
// User 1 connects to socket
|
||||
socket1 = ioClient(`http://localhost:${serverPort}`, {
|
||||
path: "/api/socket",
|
||||
transports: ["websocket"],
|
||||
});
|
||||
path: '/api/socket',
|
||||
transports: ['websocket'],
|
||||
})
|
||||
|
||||
// Wait for socket to connect
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket1.on("connect", () => resolve());
|
||||
socket1.on("connect_error", (err) => reject(err));
|
||||
setTimeout(() => reject(new Error("Connection timeout")), 2000);
|
||||
});
|
||||
socket1.on('connect', () => resolve())
|
||||
socket1.on('connect_error', (err) => reject(err))
|
||||
setTimeout(() => reject(new Error('Connection timeout')), 2000)
|
||||
})
|
||||
|
||||
// Small delay to ensure event handlers are set up
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
// Set up listener for room-joined BEFORE emitting
|
||||
const roomJoinedPromise = new Promise<void>((resolve, reject) => {
|
||||
socket1.on("room-joined", () => resolve());
|
||||
socket1.on("room-error", (err) => reject(new Error(err.error)));
|
||||
setTimeout(() => reject(new Error("Room-joined timeout")), 3000);
|
||||
});
|
||||
socket1.on('room-joined', () => resolve())
|
||||
socket1.on('room-error', (err) => reject(new Error(err.error)))
|
||||
setTimeout(() => reject(new Error('Room-joined timeout')), 3000)
|
||||
})
|
||||
|
||||
// Now emit the join-room event
|
||||
socket1.emit("join-room", { roomId: testRoomId, userId: testGuestId1 });
|
||||
socket1.emit('join-room', { roomId: testRoomId, userId: testGuestId1 })
|
||||
|
||||
// Wait for confirmation
|
||||
await roomJoinedPromise;
|
||||
await roomJoinedPromise
|
||||
|
||||
// Set up listener for member-joined event BEFORE User 2 joins
|
||||
const memberJoinedPromise = new Promise<any>((resolve, reject) => {
|
||||
socket1.on("member-joined", (data) => {
|
||||
resolve(data);
|
||||
});
|
||||
setTimeout(
|
||||
() => reject(new Error("Timeout waiting for member-joined event")),
|
||||
3000,
|
||||
);
|
||||
});
|
||||
socket1.on('member-joined', (data) => {
|
||||
resolve(data)
|
||||
})
|
||||
setTimeout(() => reject(new Error('Timeout waiting for member-joined event')), 3000)
|
||||
})
|
||||
|
||||
// User 2 joins the room via addRoomMember
|
||||
const { member: newMember } = await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
displayName: "User 2",
|
||||
displayName: 'User 2',
|
||||
isCreator: false,
|
||||
});
|
||||
})
|
||||
|
||||
// Manually trigger the broadcast (this is what the API route SHOULD do)
|
||||
const { getRoomMembers } = await import(
|
||||
"../src/lib/arcade/room-membership"
|
||||
);
|
||||
const { getRoomActivePlayers } = await import(
|
||||
"../src/lib/arcade/player-manager"
|
||||
);
|
||||
const { getRoomMembers } = await import('../src/lib/arcade/room-membership')
|
||||
const { getRoomActivePlayers } = await import('../src/lib/arcade/player-manager')
|
||||
|
||||
const members = await getRoomMembers(testRoomId);
|
||||
const memberPlayers = await getRoomActivePlayers(testRoomId);
|
||||
const members = await getRoomMembers(testRoomId)
|
||||
const memberPlayers = await getRoomActivePlayers(testRoomId)
|
||||
|
||||
const memberPlayersObj: Record<string, any[]> = {};
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players;
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
io.to(`room:${testRoomId}`).emit("member-joined", {
|
||||
io.to(`room:${testRoomId}`).emit('member-joined', {
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
});
|
||||
})
|
||||
|
||||
// Wait for the socket broadcast with timeout
|
||||
const data = await memberJoinedPromise;
|
||||
const data = await memberJoinedPromise
|
||||
|
||||
// Verify the broadcast data
|
||||
expect(data).toBeDefined();
|
||||
expect(data.roomId).toBe(testRoomId);
|
||||
expect(data.userId).toBe(testGuestId2);
|
||||
expect(data.members).toBeDefined();
|
||||
expect(Array.isArray(data.members)).toBe(true);
|
||||
expect(data).toBeDefined()
|
||||
expect(data.roomId).toBe(testRoomId)
|
||||
expect(data.userId).toBe(testGuestId2)
|
||||
expect(data.members).toBeDefined()
|
||||
expect(Array.isArray(data.members)).toBe(true)
|
||||
|
||||
// Verify both users are in the members list
|
||||
const memberUserIds = data.members.map((m: any) => m.userId);
|
||||
expect(memberUserIds).toContain(testGuestId1);
|
||||
expect(memberUserIds).toContain(testGuestId2);
|
||||
const memberUserIds = data.members.map((m: any) => m.userId)
|
||||
expect(memberUserIds).toContain(testGuestId1)
|
||||
expect(memberUserIds).toContain(testGuestId2)
|
||||
|
||||
// Verify the new member details
|
||||
const addedMember = data.members.find(
|
||||
(m: any) => m.userId === testGuestId2,
|
||||
);
|
||||
expect(addedMember).toBeDefined();
|
||||
expect(addedMember.displayName).toBe("User 2");
|
||||
expect(addedMember.roomId).toBe(testRoomId);
|
||||
});
|
||||
const addedMember = data.members.find((m: any) => m.userId === testGuestId2)
|
||||
expect(addedMember).toBeDefined()
|
||||
expect(addedMember.displayName).toBe('User 2')
|
||||
expect(addedMember.roomId).toBe(testRoomId)
|
||||
})
|
||||
|
||||
it("should broadcast member-left when a user leaves via API", async () => {
|
||||
it('should broadcast member-left when a user leaves via API', async () => {
|
||||
// User 1 joins the room first
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId1,
|
||||
displayName: "User 1",
|
||||
displayName: 'User 1',
|
||||
isCreator: false,
|
||||
});
|
||||
})
|
||||
|
||||
// User 2 joins the room
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
displayName: "User 2",
|
||||
displayName: 'User 2',
|
||||
isCreator: false,
|
||||
});
|
||||
})
|
||||
|
||||
// User 1 connects to socket
|
||||
socket1 = ioClient(`http://localhost:${serverPort}`, {
|
||||
path: "/api/socket",
|
||||
transports: ["websocket"],
|
||||
});
|
||||
path: '/api/socket',
|
||||
transports: ['websocket'],
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
socket1.on("connect", () => resolve());
|
||||
});
|
||||
socket1.on('connect', () => resolve())
|
||||
})
|
||||
|
||||
socket1.emit("join-room", { roomId: testRoomId, userId: testGuestId1 });
|
||||
socket1.emit('join-room', { roomId: testRoomId, userId: testGuestId1 })
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
socket1.on("room-joined", () => resolve());
|
||||
});
|
||||
socket1.on('room-joined', () => resolve())
|
||||
})
|
||||
|
||||
// Set up listener for member-left event
|
||||
const memberLeftPromise = new Promise<any>((resolve) => {
|
||||
socket1.on("member-left", (data) => {
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
socket1.on('member-left', (data) => {
|
||||
resolve(data)
|
||||
})
|
||||
})
|
||||
|
||||
// User 2 leaves the room via API
|
||||
await db
|
||||
.delete(schema.roomMembers)
|
||||
.where(eq(schema.roomMembers.userId, testGuestId2));
|
||||
await db.delete(schema.roomMembers).where(eq(schema.roomMembers.userId, testGuestId2))
|
||||
|
||||
// Manually trigger the leave broadcast (simulating what the API does)
|
||||
const { getSocketIO } = await import("../src/lib/socket-io");
|
||||
const io = await getSocketIO();
|
||||
const { getSocketIO } = await import('../src/lib/socket-io')
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
const { getRoomMembers } = await import(
|
||||
"../src/lib/arcade/room-membership"
|
||||
);
|
||||
const { getRoomActivePlayers } = await import(
|
||||
"../src/lib/arcade/player-manager"
|
||||
);
|
||||
const { getRoomMembers } = await import('../src/lib/arcade/room-membership')
|
||||
const { getRoomActivePlayers } = await import('../src/lib/arcade/player-manager')
|
||||
|
||||
const members = await getRoomMembers(testRoomId);
|
||||
const memberPlayers = await getRoomActivePlayers(testRoomId);
|
||||
const members = await getRoomMembers(testRoomId)
|
||||
const memberPlayers = await getRoomActivePlayers(testRoomId)
|
||||
|
||||
const memberPlayersObj: Record<string, any[]> = {};
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players;
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
io.to(`room:${testRoomId}`).emit("member-left", {
|
||||
io.to(`room:${testRoomId}`).emit('member-left', {
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for the socket broadcast with timeout
|
||||
const data = await Promise.race([
|
||||
memberLeftPromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("Timeout waiting for member-left event")),
|
||||
2000,
|
||||
),
|
||||
setTimeout(() => reject(new Error('Timeout waiting for member-left event')), 2000)
|
||||
),
|
||||
]);
|
||||
])
|
||||
|
||||
// Verify the broadcast data
|
||||
expect(data).toBeDefined();
|
||||
expect(data.roomId).toBe(testRoomId);
|
||||
expect(data.userId).toBe(testGuestId2);
|
||||
expect(data.members).toBeDefined();
|
||||
expect(Array.isArray(data.members)).toBe(true);
|
||||
expect(data).toBeDefined()
|
||||
expect(data.roomId).toBe(testRoomId)
|
||||
expect(data.userId).toBe(testGuestId2)
|
||||
expect(data.members).toBeDefined()
|
||||
expect(Array.isArray(data.members)).toBe(true)
|
||||
|
||||
// Verify User 2 is no longer in the members list
|
||||
const memberUserIds = data.members.map((m: any) => m.userId);
|
||||
expect(memberUserIds).toContain(testGuestId1);
|
||||
expect(memberUserIds).not.toContain(testGuestId2);
|
||||
});
|
||||
const memberUserIds = data.members.map((m: any) => m.userId)
|
||||
expect(memberUserIds).toContain(testGuestId1)
|
||||
expect(memberUserIds).not.toContain(testGuestId2)
|
||||
})
|
||||
|
||||
it("should update both members and players lists in member-joined broadcast", async () => {
|
||||
it('should update both members and players lists in member-joined broadcast', async () => {
|
||||
// Create an active player for User 2
|
||||
const [player2] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId2,
|
||||
name: "Player 2",
|
||||
emoji: "🎮",
|
||||
color: "#3b82f6",
|
||||
name: 'Player 2',
|
||||
emoji: '🎮',
|
||||
color: '#3b82f6',
|
||||
isActive: true,
|
||||
})
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
// User 1 connects and joins room
|
||||
socket1 = ioClient(`http://localhost:${serverPort}`, {
|
||||
path: "/api/socket",
|
||||
transports: ["websocket"],
|
||||
});
|
||||
path: '/api/socket',
|
||||
transports: ['websocket'],
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
socket1.on("connect", () => resolve());
|
||||
});
|
||||
socket1.on('connect', () => resolve())
|
||||
})
|
||||
|
||||
socket1.emit("join-room", { roomId: testRoomId, userId: testGuestId1 });
|
||||
socket1.emit('join-room', { roomId: testRoomId, userId: testGuestId1 })
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
socket1.on("room-joined", () => resolve());
|
||||
});
|
||||
socket1.on('room-joined', () => resolve())
|
||||
})
|
||||
|
||||
const memberJoinedPromise = new Promise<any>((resolve) => {
|
||||
socket1.on("member-joined", (data) => {
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
socket1.on('member-joined', (data) => {
|
||||
resolve(data)
|
||||
})
|
||||
})
|
||||
|
||||
// User 2 joins via API
|
||||
await addRoomMember({
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
displayName: "User 2",
|
||||
displayName: 'User 2',
|
||||
isCreator: false,
|
||||
});
|
||||
})
|
||||
|
||||
// Manually trigger the broadcast (simulating what the API does)
|
||||
const { getRoomMembers: getRoomMembers3 } = await import(
|
||||
"../src/lib/arcade/room-membership"
|
||||
);
|
||||
const { getRoomMembers: getRoomMembers3 } = await import('../src/lib/arcade/room-membership')
|
||||
const { getRoomActivePlayers: getRoomActivePlayers3 } = await import(
|
||||
"../src/lib/arcade/player-manager"
|
||||
);
|
||||
'../src/lib/arcade/player-manager'
|
||||
)
|
||||
|
||||
const members2 = await getRoomMembers3(testRoomId);
|
||||
const memberPlayers2 = await getRoomActivePlayers3(testRoomId);
|
||||
const members2 = await getRoomMembers3(testRoomId)
|
||||
const memberPlayers2 = await getRoomActivePlayers3(testRoomId)
|
||||
|
||||
const memberPlayersObj2: Record<string, any[]> = {};
|
||||
const memberPlayersObj2: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers2.entries()) {
|
||||
memberPlayersObj2[uid] = players;
|
||||
memberPlayersObj2[uid] = players
|
||||
}
|
||||
|
||||
io.to(`room:${testRoomId}`).emit("member-joined", {
|
||||
io.to(`room:${testRoomId}`).emit('member-joined', {
|
||||
roomId: testRoomId,
|
||||
userId: testGuestId2,
|
||||
members: members2,
|
||||
memberPlayers: memberPlayersObj2,
|
||||
});
|
||||
})
|
||||
|
||||
const data = await Promise.race([
|
||||
memberJoinedPromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Timeout")), 2000),
|
||||
),
|
||||
]);
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000)),
|
||||
])
|
||||
|
||||
// Verify members list is updated
|
||||
expect(data.members).toBeDefined();
|
||||
const memberUserIds = data.members.map((m: any) => m.userId);
|
||||
expect(memberUserIds).toContain(testGuestId2);
|
||||
expect(data.members).toBeDefined()
|
||||
const memberUserIds = data.members.map((m: any) => m.userId)
|
||||
expect(memberUserIds).toContain(testGuestId2)
|
||||
|
||||
// Verify players list is updated
|
||||
expect(data.memberPlayers).toBeDefined();
|
||||
expect(data.memberPlayers[testGuestId2]).toBeDefined();
|
||||
expect(Array.isArray(data.memberPlayers[testGuestId2])).toBe(true);
|
||||
expect(data.memberPlayers).toBeDefined()
|
||||
expect(data.memberPlayers[testGuestId2]).toBeDefined()
|
||||
expect(Array.isArray(data.memberPlayers[testGuestId2])).toBe(true)
|
||||
|
||||
// User 2's players should include the active player we created
|
||||
const user2Players = data.memberPlayers[testGuestId2];
|
||||
expect(user2Players.length).toBeGreaterThan(0);
|
||||
expect(user2Players.some((p: any) => p.id === player2.id)).toBe(true);
|
||||
const user2Players = data.memberPlayers[testGuestId2]
|
||||
expect(user2Players.length).toBeGreaterThan(0)
|
||||
expect(user2Players.some((p: any) => p.id === player2.id)).toBe(true)
|
||||
|
||||
// Clean up player
|
||||
await db.delete(schema.players).where(eq(schema.players.id, player2.id));
|
||||
});
|
||||
});
|
||||
await db.delete(schema.players).where(eq(schema.players.id, player2.id))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
@@ -16,19 +16,19 @@
|
||||
"noLabelWithoutControl": "off",
|
||||
"noStaticElementInteractions": "off",
|
||||
"useKeyWithClickEvents": "off",
|
||||
"useSemanticElements": "off",
|
||||
"useSemanticElements": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noArrayIndexKey": "off",
|
||||
"noImplicitAnyLet": "off",
|
||||
"noAssignInExpressions": "off",
|
||||
"useIterableCallbackReturn": "off",
|
||||
"useIterableCallbackReturn": "off"
|
||||
},
|
||||
"style": {
|
||||
"useNodejsImportProtocol": "off",
|
||||
"noNonNullAssertion": "off",
|
||||
"noDescendingSpecificity": "off",
|
||||
"noDescendingSpecificity": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "off",
|
||||
@@ -39,31 +39,31 @@
|
||||
"noInvalidUseBeforeDeclaration": "off",
|
||||
"useHookAtTopLevel": "off",
|
||||
"noNestedComponentDefinitions": "off",
|
||||
"noUnreachable": "off",
|
||||
"noUnreachable": "off"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "off",
|
||||
"noDangerouslySetInnerHtml": "off"
|
||||
},
|
||||
"performance": {
|
||||
"noAccumulatingSpread": "off",
|
||||
},
|
||||
},
|
||||
"noAccumulatingSpread": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"ignoreUnknown": true
|
||||
},
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true,
|
||||
"defaultBranch": "main",
|
||||
"defaultBranch": "main"
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"jsxQuoteStyle": "double",
|
||||
"semicolons": "asNeeded",
|
||||
"trailingCommas": "es5",
|
||||
},
|
||||
},
|
||||
"trailingCommas": "es5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
0
apps/web/data/db.sqlite
Normal file
0
apps/web/data/db.sqlite
Normal file
@@ -1,12 +1,12 @@
|
||||
import type { Config } from "drizzle-kit";
|
||||
import type { Config } from 'drizzle-kit'
|
||||
|
||||
export default {
|
||||
schema: "./src/db/schema/index.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "sqlite",
|
||||
schema: './src/db/schema/index.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'sqlite',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || "./data/sqlite.db",
|
||||
url: process.env.DATABASE_URL || './data/sqlite.db',
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
} satisfies Config;
|
||||
} satisfies Config
|
||||
|
||||
30
apps/web/drizzle/0005_flimsy_squadron_sinister.sql
Normal file
30
apps/web/drizzle/0005_flimsy_squadron_sinister.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE `room_reports` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`room_id` text NOT NULL,
|
||||
`reporter_id` text NOT NULL,
|
||||
`reporter_name` text(50) NOT NULL,
|
||||
`reported_user_id` text NOT NULL,
|
||||
`reported_user_name` text(50) NOT NULL,
|
||||
`reason` text NOT NULL,
|
||||
`details` text(500),
|
||||
`status` text DEFAULT 'pending' NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`reviewed_at` integer,
|
||||
`reviewed_by` text,
|
||||
FOREIGN KEY (`room_id`) REFERENCES `arcade_rooms`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `room_bans` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`room_id` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`user_name` text(50) NOT NULL,
|
||||
`banned_by` text NOT NULL,
|
||||
`banned_by_name` text(50) NOT NULL,
|
||||
`reason` text NOT NULL,
|
||||
`notes` text(500),
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`room_id`) REFERENCES `arcade_rooms`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `idx_room_bans_user_room` ON `room_bans` (`user_id`,`room_id`);
|
||||
21
apps/web/drizzle/0005_jazzy_mimic.sql
Normal file
21
apps/web/drizzle/0005_jazzy_mimic.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_arcade_sessions` (
|
||||
`room_id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`current_game` text NOT NULL,
|
||||
`game_url` text NOT NULL,
|
||||
`game_state` text NOT NULL,
|
||||
`active_players` text NOT NULL,
|
||||
`started_at` integer NOT NULL,
|
||||
`last_activity_at` integer NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`is_active` integer DEFAULT true NOT NULL,
|
||||
`version` integer DEFAULT 1 NOT NULL,
|
||||
FOREIGN KEY (`room_id`) REFERENCES `arcade_rooms`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_arcade_sessions`("room_id", "user_id", "current_game", "game_url", "game_state", "active_players", "started_at", "last_activity_at", "expires_at", "is_active", "version") SELECT "room_id", "user_id", "current_game", "game_url", "game_state", "active_players", "started_at", "last_activity_at", "expires_at", "is_active", "version" FROM `arcade_sessions`;--> statement-breakpoint
|
||||
DROP TABLE `arcade_sessions`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_arcade_sessions` RENAME TO `arcade_sessions`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;
|
||||
29
apps/web/drizzle/0006_pretty_invaders.sql
Normal file
29
apps/web/drizzle/0006_pretty_invaders.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE `room_member_history` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`room_id` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`display_name` text(50) NOT NULL,
|
||||
`first_joined_at` integer NOT NULL,
|
||||
`last_seen_at` integer NOT NULL,
|
||||
`last_action` text DEFAULT 'active' NOT NULL,
|
||||
`last_action_at` integer NOT NULL,
|
||||
FOREIGN KEY (`room_id`) REFERENCES `arcade_rooms`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `room_invitations` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`room_id` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`user_name` text(50) NOT NULL,
|
||||
`invited_by` text NOT NULL,
|
||||
`invited_by_name` text(50) NOT NULL,
|
||||
`status` text DEFAULT 'pending' NOT NULL,
|
||||
`invitation_type` text DEFAULT 'manual' NOT NULL,
|
||||
`message` text(500),
|
||||
`created_at` integer NOT NULL,
|
||||
`responded_at` integer,
|
||||
`expires_at` integer,
|
||||
FOREIGN KEY (`room_id`) REFERENCES `arcade_rooms`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `idx_room_invitations_user_room` ON `room_invitations` (`user_id`,`room_id`);
|
||||
18
apps/web/drizzle/0007_access_modes.sql
Normal file
18
apps/web/drizzle/0007_access_modes.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Add access control columns to arcade_rooms
|
||||
ALTER TABLE `arcade_rooms` ADD `access_mode` text DEFAULT 'open' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `arcade_rooms` ADD `password` text(255);--> statement-breakpoint
|
||||
|
||||
-- Create room_join_requests table for approval-only mode
|
||||
CREATE TABLE `room_join_requests` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`room_id` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`user_name` text(50) NOT NULL,
|
||||
`status` text DEFAULT 'pending' NOT NULL,
|
||||
`requested_at` integer NOT NULL,
|
||||
`reviewed_at` integer,
|
||||
`reviewed_by` text,
|
||||
`reviewed_by_name` text(50),
|
||||
FOREIGN KEY (`room_id`) REFERENCES `arcade_rooms`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `idx_room_join_requests_user_room` ON `room_join_requests` (`user_id`,`room_id`);
|
||||
41
apps/web/drizzle/0008_make_room_name_nullable.sql
Normal file
41
apps/web/drizzle/0008_make_room_name_nullable.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- Make room name nullable to support auto-generated names
|
||||
-- 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),
|
||||
`game_name` text NOT NULL,
|
||||
`game_config` text NOT NULL,
|
||||
`status` text DEFAULT 'lobby' NOT NULL,
|
||||
`current_session_id` text,
|
||||
`total_games_played` integer DEFAULT 0 NOT NULL
|
||||
);--> statement-breakpoint
|
||||
|
||||
-- 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`,
|
||||
`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;
|
||||
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;
|
||||
33
apps/web/drizzle/0011_add_room_game_configs.sql
Normal file
33
apps/web/drizzle/0011_add_room_game_configs.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- Create room_game_configs table for normalized game settings storage
|
||||
-- This migration is safe to run multiple times (uses IF NOT EXISTS)
|
||||
|
||||
-- Create the table
|
||||
CREATE TABLE IF NOT EXISTS `room_game_configs` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`room_id` text NOT NULL,
|
||||
`game_name` text NOT NULL,
|
||||
`config` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`room_id`) REFERENCES `arcade_rooms`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Create unique index
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `room_game_idx` ON `room_game_configs` (`room_id`,`game_name`);
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Migrate existing game configs from arcade_rooms.game_config column
|
||||
-- This INSERT will only run if data hasn't been migrated yet
|
||||
INSERT OR IGNORE INTO room_game_configs (id, room_id, game_name, config, created_at, updated_at)
|
||||
SELECT
|
||||
lower(hex(randomblob(16))) as id,
|
||||
id as room_id,
|
||||
game_name,
|
||||
game_config as config,
|
||||
created_at,
|
||||
last_activity as updated_at
|
||||
FROM arcade_rooms
|
||||
WHERE game_config IS NOT NULL
|
||||
AND game_name IS NOT NULL
|
||||
AND game_name IN ('matching', 'memory-quiz', 'complement-race');
|
||||
849
apps/web/drizzle/meta/0005_snapshot.json
Normal file
849
apps/web/drizzle/meta/0005_snapshot.json
Normal file
@@ -0,0 +1,849 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "e01e9757-73e9-413f-8126-090e6ff156c8",
|
||||
"prevId": "840cc055-2f32-4ae4-81ff-255641cbbd1c",
|
||||
"tables": {
|
||||
"abacus_settings": {
|
||||
"name": "abacus_settings",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"color_scheme": {
|
||||
"name": "color_scheme",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'place-value'"
|
||||
},
|
||||
"bead_shape": {
|
||||
"name": "bead_shape",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'diamond'"
|
||||
},
|
||||
"color_palette": {
|
||||
"name": "color_palette",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'default'"
|
||||
},
|
||||
"hide_inactive_beads": {
|
||||
"name": "hide_inactive_beads",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"colored_numerals": {
|
||||
"name": "colored_numerals",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"scale_factor": {
|
||||
"name": "scale_factor",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
},
|
||||
"show_numbers": {
|
||||
"name": "show_numbers",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"animated": {
|
||||
"name": "animated",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"interactive": {
|
||||
"name": "interactive",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"gestures": {
|
||||
"name": "gestures",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"sound_enabled": {
|
||||
"name": "sound_enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"sound_volume": {
|
||||
"name": "sound_volume",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0.8
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"abacus_settings_user_id_users_id_fk": {
|
||||
"name": "abacus_settings_user_id_users_id_fk",
|
||||
"tableFrom": "abacus_settings",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"arcade_rooms": {
|
||||
"name": "arcade_rooms",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"code": {
|
||||
"name": "code",
|
||||
"type": "text(6)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_by": {
|
||||
"name": "created_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"creator_name": {
|
||||
"name": "creator_name",
|
||||
"type": "text(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_activity": {
|
||||
"name": "last_activity",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ttl_minutes": {
|
||||
"name": "ttl_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 60
|
||||
},
|
||||
"is_locked": {
|
||||
"name": "is_locked",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"game_name": {
|
||||
"name": "game_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"game_config": {
|
||||
"name": "game_config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'lobby'"
|
||||
},
|
||||
"current_session_id": {
|
||||
"name": "current_session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"total_games_played": {
|
||||
"name": "total_games_played",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"arcade_rooms_code_unique": {
|
||||
"name": "arcade_rooms_code_unique",
|
||||
"columns": ["code"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"arcade_sessions": {
|
||||
"name": "arcade_sessions",
|
||||
"columns": {
|
||||
"room_id": {
|
||||
"name": "room_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"current_game": {
|
||||
"name": "current_game",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"game_url": {
|
||||
"name": "game_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"game_state": {
|
||||
"name": "game_state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active_players": {
|
||||
"name": "active_players",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_activity_at": {
|
||||
"name": "last_activity_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"arcade_sessions_room_id_arcade_rooms_id_fk": {
|
||||
"name": "arcade_sessions_room_id_arcade_rooms_id_fk",
|
||||
"tableFrom": "arcade_sessions",
|
||||
"tableTo": "arcade_rooms",
|
||||
"columnsFrom": ["room_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"arcade_sessions_user_id_users_id_fk": {
|
||||
"name": "arcade_sessions_user_id_users_id_fk",
|
||||
"tableFrom": "arcade_sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"players": {
|
||||
"name": "players",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"emoji": {
|
||||
"name": "emoji",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"players_user_id_idx": {
|
||||
"name": "players_user_id_idx",
|
||||
"columns": ["user_id"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"players_user_id_users_id_fk": {
|
||||
"name": "players_user_id_users_id_fk",
|
||||
"tableFrom": "players",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"room_members": {
|
||||
"name": "room_members",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"room_id": {
|
||||
"name": "room_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_creator": {
|
||||
"name": "is_creator",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"joined_at": {
|
||||
"name": "joined_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_seen": {
|
||||
"name": "last_seen",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_online": {
|
||||
"name": "is_online",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_room_members_user_id_unique": {
|
||||
"name": "idx_room_members_user_id_unique",
|
||||
"columns": ["user_id"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"room_members_room_id_arcade_rooms_id_fk": {
|
||||
"name": "room_members_room_id_arcade_rooms_id_fk",
|
||||
"tableFrom": "room_members",
|
||||
"tableTo": "arcade_rooms",
|
||||
"columnsFrom": ["room_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"room_reports": {
|
||||
"name": "room_reports",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"room_id": {
|
||||
"name": "room_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reporter_id": {
|
||||
"name": "reporter_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reporter_name": {
|
||||
"name": "reporter_name",
|
||||
"type": "text(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reported_user_id": {
|
||||
"name": "reported_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reported_user_name": {
|
||||
"name": "reported_user_name",
|
||||
"type": "text(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reason": {
|
||||
"name": "reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"details": {
|
||||
"name": "details",
|
||||
"type": "text(500)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reviewed_at": {
|
||||
"name": "reviewed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reviewed_by": {
|
||||
"name": "reviewed_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"room_reports_room_id_arcade_rooms_id_fk": {
|
||||
"name": "room_reports_room_id_arcade_rooms_id_fk",
|
||||
"tableFrom": "room_reports",
|
||||
"tableTo": "arcade_rooms",
|
||||
"columnsFrom": ["room_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"room_bans": {
|
||||
"name": "room_bans",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"room_id": {
|
||||
"name": "room_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_name": {
|
||||
"name": "user_name",
|
||||
"type": "text(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"banned_by": {
|
||||
"name": "banned_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"banned_by_name": {
|
||||
"name": "banned_by_name",
|
||||
"type": "text(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reason": {
|
||||
"name": "reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text(500)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_room_bans_user_room": {
|
||||
"name": "idx_room_bans_user_room",
|
||||
"columns": ["user_id", "room_id"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"room_bans_room_id_arcade_rooms_id_fk": {
|
||||
"name": "room_bans_room_id_arcade_rooms_id_fk",
|
||||
"tableFrom": "room_bans",
|
||||
"tableTo": "arcade_rooms",
|
||||
"columnsFrom": ["room_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"user_stats": {
|
||||
"name": "user_stats",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"games_played": {
|
||||
"name": "games_played",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"total_wins": {
|
||||
"name": "total_wins",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"favorite_game_type": {
|
||||
"name": "favorite_game_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"best_time": {
|
||||
"name": "best_time",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"highest_accuracy": {
|
||||
"name": "highest_accuracy",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"user_stats_user_id_users_id_fk": {
|
||||
"name": "user_stats_user_id_users_id_fk",
|
||||
"tableFrom": "user_stats",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"guest_id": {
|
||||
"name": "guest_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"upgraded_at": {
|
||||
"name": "upgraded_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_guest_id_unique": {
|
||||
"name": "users_guest_id_unique",
|
||||
"columns": ["guest_id"],
|
||||
"isUnique": true
|
||||
},
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"columns": ["email"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
1038
apps/web/drizzle/meta/0006_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,55 @@
|
||||
"when": 1759930182541,
|
||||
"tag": "0004_shiny_madelyne_pryor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1760362058906,
|
||||
"tag": "0005_flimsy_squadron_sinister",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1760365860888,
|
||||
"tag": "0006_pretty_invaders",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1760527200000,
|
||||
"tag": "0007_access_modes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1760800000000,
|
||||
"tag": "0011_add_room_game_configs",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Arcade Modal Session E2E Tests
|
||||
@@ -10,363 +10,329 @@ import { expect, test } from "@playwright/test";
|
||||
* - "Return to Arcade" button properly ends sessions
|
||||
*/
|
||||
|
||||
test.describe("Arcade Modal Session - Redirects", () => {
|
||||
test.describe('Arcade Modal Session - Redirects', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Clear arcade session before each test
|
||||
await page.goto("/arcade");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto('/arcade')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Click "Return to Arcade" button if it exists (to clear any existing session)
|
||||
const returnButton = page.locator('button:has-text("Return to Arcade")');
|
||||
const returnButton = page.locator('button:has-text("Return to Arcade")')
|
||||
if (await returnButton.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await returnButton.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await returnButton.click()
|
||||
await page.waitForLoadState('networkidle')
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("should stay on arcade lobby when no active session", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/arcade");
|
||||
await page.waitForLoadState("networkidle");
|
||||
test('should stay on arcade lobby when no active session', async ({ page }) => {
|
||||
await page.goto('/arcade')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Should see "Champion Arena" title
|
||||
const title = page.locator('h1:has-text("Champion Arena")');
|
||||
await expect(title).toBeVisible();
|
||||
const title = page.locator('h1:has-text("Champion Arena")')
|
||||
await expect(title).toBeVisible()
|
||||
|
||||
// Should be able to select players
|
||||
const playerSection = page.locator("text=/Player|Select|Add/i");
|
||||
await expect(playerSection.first()).toBeVisible();
|
||||
});
|
||||
const playerSection = page.locator('text=/Player|Select|Add/i')
|
||||
await expect(playerSection.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test("should redirect from arcade to active game when session exists", async ({
|
||||
page,
|
||||
}) => {
|
||||
test('should redirect from arcade to active game when session exists', async ({ page }) => {
|
||||
// Start a game to create a session
|
||||
await page.goto("/arcade");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto('/arcade')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Find and click a player card to activate
|
||||
const playerCard = page.locator('[data-testid="player-card"]').first();
|
||||
const playerCard = page.locator('[data-testid="player-card"]').first()
|
||||
if (await playerCard.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await playerCard.click();
|
||||
await page.waitForTimeout(500);
|
||||
await playerCard.click()
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
// Navigate to matching game to create session
|
||||
await page.goto("/arcade/matching");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto('/arcade/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Start the game (click Start button if visible)
|
||||
const startButton = page.locator('button:has-text("Start")');
|
||||
const startButton = page.locator('button:has-text("Start")')
|
||||
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await startButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
await startButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
// Try to navigate back to arcade lobby
|
||||
await page.goto("/arcade");
|
||||
await page.waitForTimeout(2000); // Give time for redirect
|
||||
await page.goto('/arcade')
|
||||
await page.waitForTimeout(2000) // Give time for redirect
|
||||
|
||||
// Should be redirected back to the game
|
||||
await expect(page).toHaveURL(/\/arcade\/matching/);
|
||||
const gameTitle = page.locator('h1:has-text("Memory Pairs")');
|
||||
await expect(gameTitle).toBeVisible();
|
||||
});
|
||||
await expect(page).toHaveURL(/\/arcade\/matching/)
|
||||
const gameTitle = page.locator('h1:has-text("Memory Pairs")')
|
||||
await expect(gameTitle).toBeVisible()
|
||||
})
|
||||
|
||||
test("should redirect to correct game when navigating to wrong game", async ({
|
||||
page,
|
||||
}) => {
|
||||
test('should redirect to correct game when navigating to wrong game', async ({ page }) => {
|
||||
// Create a session with matching game
|
||||
await page.goto("/arcade");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto('/arcade')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Activate a player
|
||||
const addPlayerButton = page.locator(
|
||||
'button:has-text("Add Player"), button:has-text("+")',
|
||||
);
|
||||
const addPlayerButton = page.locator('button:has-text("Add Player"), button:has-text("+")')
|
||||
if (
|
||||
await addPlayerButton
|
||||
.first()
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false)
|
||||
) {
|
||||
await addPlayerButton.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
await addPlayerButton.first().click()
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
// Go to matching game
|
||||
await page.goto("/arcade/matching");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto('/arcade/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Start game if needed
|
||||
const startButton = page.locator('button:has-text("Start")');
|
||||
const startButton = page.locator('button:has-text("Start")')
|
||||
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await startButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
await startButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
// Try to navigate to a different game
|
||||
await page.goto("/arcade/memory-quiz");
|
||||
await page.waitForTimeout(2000); // Give time for redirect
|
||||
await page.goto('/arcade/memory-quiz')
|
||||
await page.waitForTimeout(2000) // Give time for redirect
|
||||
|
||||
// Should be redirected back to matching
|
||||
await expect(page).toHaveURL(/\/arcade\/matching/);
|
||||
});
|
||||
await expect(page).toHaveURL(/\/arcade\/matching/)
|
||||
})
|
||||
|
||||
test("should NOT redirect when on correct game page", async ({ page }) => {
|
||||
test('should NOT redirect when on correct game page', async ({ page }) => {
|
||||
// Navigate to matching game
|
||||
await page.goto("/arcade/matching");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto('/arcade/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Should stay on matching page
|
||||
await expect(page).toHaveURL(/\/arcade\/matching/);
|
||||
const gameTitle = page.locator('h1:has-text("Memory Pairs")');
|
||||
await expect(gameTitle).toBeVisible();
|
||||
});
|
||||
});
|
||||
await expect(page).toHaveURL(/\/arcade\/matching/)
|
||||
const gameTitle = page.locator('h1:has-text("Memory Pairs")')
|
||||
await expect(gameTitle).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Arcade Modal Session - Player Modification Blocking", () => {
|
||||
test.describe('Arcade Modal Session - Player Modification Blocking', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Clear session
|
||||
await page.goto("/arcade");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto('/arcade')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const returnButton = page.locator('button:has-text("Return to Arcade")');
|
||||
const returnButton = page.locator('button:has-text("Return to Arcade")')
|
||||
if (await returnButton.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await returnButton.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await returnButton.click()
|
||||
await page.waitForLoadState('networkidle')
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("should allow player modification in arcade lobby with no session", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/arcade");
|
||||
await page.waitForLoadState("networkidle");
|
||||
test('should allow player modification in arcade lobby with no session', async ({ page }) => {
|
||||
await page.goto('/arcade')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Look for add player button (should be enabled)
|
||||
const addPlayerButton = page.locator(
|
||||
'button:has-text("Add Player"), button:has-text("+")',
|
||||
);
|
||||
const firstButton = addPlayerButton.first();
|
||||
const addPlayerButton = page.locator('button:has-text("Add Player"), button:has-text("+")')
|
||||
const firstButton = addPlayerButton.first()
|
||||
|
||||
if (await firstButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
// Should be clickable
|
||||
await expect(firstButton).toBeEnabled();
|
||||
await expect(firstButton).toBeEnabled()
|
||||
|
||||
// Try to click it
|
||||
await firstButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
await firstButton.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Should see player added
|
||||
const activePlayer = page.locator('[data-testid="active-player"]');
|
||||
await expect(activePlayer.first()).toBeVisible({ timeout: 3000 });
|
||||
const activePlayer = page.locator('[data-testid="active-player"]')
|
||||
await expect(activePlayer.first()).toBeVisible({ timeout: 3000 })
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("should block player modification during active game", async ({
|
||||
page,
|
||||
}) => {
|
||||
test('should block player modification during active game', async ({ page }) => {
|
||||
// Start a game
|
||||
await page.goto("/arcade/matching");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto('/arcade/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Start game
|
||||
const startButton = page.locator('button:has-text("Start")');
|
||||
const startButton = page.locator('button:has-text("Start")')
|
||||
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await startButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
await startButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
// Look for player modification controls
|
||||
// They should be disabled or have reduced opacity
|
||||
const playerControls = page.locator(
|
||||
'[data-testid="player-controls"], .player-list',
|
||||
);
|
||||
const playerControls = page.locator('[data-testid="player-controls"], .player-list')
|
||||
if (await playerControls.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
// Check if controls have pointer-events: none or low opacity
|
||||
const opacity = await playerControls.evaluate((el) => {
|
||||
return window.getComputedStyle(el).opacity;
|
||||
});
|
||||
return window.getComputedStyle(el).opacity
|
||||
})
|
||||
|
||||
// If controls are visible, they should be dimmed (opacity < 1)
|
||||
if (parseFloat(opacity) < 1) {
|
||||
expect(parseFloat(opacity)).toBeLessThan(1);
|
||||
expect(parseFloat(opacity)).toBeLessThan(1)
|
||||
}
|
||||
}
|
||||
|
||||
// "Add Player" button should not be visible during game
|
||||
const addPlayerButton = page.locator('button:has-text("Add Player")');
|
||||
const addPlayerButton = page.locator('button:has-text("Add Player")')
|
||||
if (await addPlayerButton.isVisible({ timeout: 500 }).catch(() => false)) {
|
||||
// If visible, should be disabled
|
||||
const isDisabled = await addPlayerButton.isDisabled();
|
||||
expect(isDisabled).toBe(true);
|
||||
const isDisabled = await addPlayerButton.isDisabled()
|
||||
expect(isDisabled).toBe(true)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test('should show "Return to Arcade" button during game', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('should show "Return to Arcade" button during game', async ({ page }) => {
|
||||
// Start a game
|
||||
await page.goto("/arcade/matching");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto('/arcade/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Look for "Return to Arcade" button
|
||||
const returnButton = page.locator('button:has-text("Return to Arcade")');
|
||||
const returnButton = page.locator('button:has-text("Return to Arcade")')
|
||||
|
||||
// During game setup, might see "Setup" button instead
|
||||
const setupButton = page.locator('button:has-text("Setup")');
|
||||
const setupButton = page.locator('button:has-text("Setup")')
|
||||
|
||||
// One of these should be visible
|
||||
const hasReturnButton = await returnButton
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false);
|
||||
const hasSetupButton = await setupButton
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false);
|
||||
const hasReturnButton = await returnButton.isVisible({ timeout: 2000 }).catch(() => false)
|
||||
const hasSetupButton = await setupButton.isVisible({ timeout: 2000 }).catch(() => false)
|
||||
|
||||
expect(hasReturnButton || hasSetupButton).toBe(true);
|
||||
});
|
||||
expect(hasReturnButton || hasSetupButton).toBe(true)
|
||||
})
|
||||
|
||||
test('should NOT show "Setup" button in arcade lobby with no session', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/arcade");
|
||||
await page.waitForLoadState("networkidle");
|
||||
test('should NOT show "Setup" button in arcade lobby with no session', async ({ page }) => {
|
||||
await page.goto('/arcade')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Should NOT see "Return to Arcade" or "Setup" button in lobby
|
||||
const returnButton = page.locator('button:has-text("Return to Arcade")');
|
||||
const setupButton = page.locator('button:has-text("Setup")');
|
||||
const returnButton = page.locator('button:has-text("Return to Arcade")')
|
||||
const setupButton = page.locator('button:has-text("Setup")')
|
||||
|
||||
const hasReturnButton = await returnButton
|
||||
.isVisible({ timeout: 1000 })
|
||||
.catch(() => false);
|
||||
const hasSetupButton = await setupButton
|
||||
.isVisible({ timeout: 1000 })
|
||||
.catch(() => false);
|
||||
const hasReturnButton = await returnButton.isVisible({ timeout: 1000 }).catch(() => false)
|
||||
const hasSetupButton = await setupButton.isVisible({ timeout: 1000 }).catch(() => false)
|
||||
|
||||
// Neither should be visible in empty lobby
|
||||
expect(hasReturnButton).toBe(false);
|
||||
expect(hasSetupButton).toBe(false);
|
||||
});
|
||||
});
|
||||
expect(hasReturnButton).toBe(false)
|
||||
expect(hasSetupButton).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Arcade Modal Session - Return to Arcade Button", () => {
|
||||
test.describe('Arcade Modal Session - Return to Arcade Button', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Clear session
|
||||
await page.goto("/arcade");
|
||||
await page.waitForLoadState("networkidle");
|
||||
});
|
||||
await page.goto('/arcade')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('should end session and return to arcade when clicking "Return to Arcade"', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Start a game
|
||||
await page.goto("/arcade/matching");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto('/arcade/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Start game if needed
|
||||
const startButton = page.locator('button:has-text("Start")');
|
||||
const startButton = page.locator('button:has-text("Start")')
|
||||
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await startButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
await startButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
// Find and click "Return to Arcade" button
|
||||
const returnButton = page.locator('button:has-text("Return to Arcade")');
|
||||
const returnButton = page.locator('button:has-text("Return to Arcade")')
|
||||
if (await returnButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await returnButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
await returnButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Should be redirected to arcade lobby
|
||||
await expect(page).toHaveURL(/\/arcade\/?$/);
|
||||
await expect(page).toHaveURL(/\/arcade\/?$/)
|
||||
|
||||
// Should see arcade lobby title
|
||||
const title = page.locator('h1:has-text("Champion Arena")');
|
||||
await expect(title).toBeVisible();
|
||||
const title = page.locator('h1:has-text("Champion Arena")')
|
||||
await expect(title).toBeVisible()
|
||||
|
||||
// Now should be able to modify players again
|
||||
const addPlayerButton = page.locator(
|
||||
'button:has-text("Add Player"), button:has-text("+")',
|
||||
);
|
||||
const addPlayerButton = page.locator('button:has-text("Add Player"), button:has-text("+")')
|
||||
if (
|
||||
await addPlayerButton
|
||||
.first()
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false)
|
||||
) {
|
||||
await expect(addPlayerButton.first()).toBeEnabled();
|
||||
await expect(addPlayerButton.first()).toBeEnabled()
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("should allow navigating to different game after returning to arcade", async ({
|
||||
page,
|
||||
}) => {
|
||||
test('should allow navigating to different game after returning to arcade', async ({ page }) => {
|
||||
// Start matching game
|
||||
await page.goto("/arcade/matching");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto('/arcade/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Return to arcade
|
||||
const returnButton = page.locator(
|
||||
'button:has-text("Return to Arcade"), button:has-text("Setup")',
|
||||
);
|
||||
'button:has-text("Return to Arcade"), button:has-text("Setup")'
|
||||
)
|
||||
if (
|
||||
await returnButton
|
||||
.first()
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false)
|
||||
) {
|
||||
await returnButton.first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
await returnButton.first().click()
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
// Should be in arcade lobby
|
||||
await expect(page).toHaveURL(/\/arcade\/?$/);
|
||||
await expect(page).toHaveURL(/\/arcade\/?$/)
|
||||
|
||||
// Now navigate to different game - should NOT redirect back to matching
|
||||
await page.goto("/arcade/memory-quiz");
|
||||
await page.waitForTimeout(2000);
|
||||
await page.goto('/arcade/memory-quiz')
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Should stay on memory-quiz (not redirect back to matching)
|
||||
await expect(page).toHaveURL(/\/arcade\/memory-quiz/);
|
||||
await expect(page).toHaveURL(/\/arcade\/memory-quiz/)
|
||||
|
||||
// Should see memory quiz title
|
||||
const title = page.locator('h1:has-text("Memory Lightning")');
|
||||
await expect(title).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
});
|
||||
const title = page.locator('h1:has-text("Memory Lightning")')
|
||||
await expect(title).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Arcade Modal Session - Session Persistence", () => {
|
||||
test("should maintain active session across page reloads", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.describe('Arcade Modal Session - Session Persistence', () => {
|
||||
test('should maintain active session across page reloads', async ({ page }) => {
|
||||
// Start a game
|
||||
await page.goto("/arcade/matching");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto('/arcade/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Start game
|
||||
const startButton = page.locator('button:has-text("Start")');
|
||||
const startButton = page.locator('button:has-text("Start")')
|
||||
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await startButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
await startButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Should still be on matching game
|
||||
await expect(page).toHaveURL(/\/arcade\/matching/);
|
||||
const gameTitle = page.locator('h1:has-text("Memory Pairs")');
|
||||
await expect(gameTitle).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/arcade\/matching/)
|
||||
const gameTitle = page.locator('h1:has-text("Memory Pairs")')
|
||||
await expect(gameTitle).toBeVisible()
|
||||
|
||||
// Try to navigate to arcade
|
||||
await page.goto("/arcade");
|
||||
await page.waitForTimeout(2000);
|
||||
await page.goto('/arcade')
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Should be redirected back to matching
|
||||
await expect(page).toHaveURL(/\/arcade\/matching/);
|
||||
});
|
||||
});
|
||||
await expect(page).toHaveURL(/\/arcade\/matching/)
|
||||
})
|
||||
})
|
||||
|
||||
296
apps/web/e2e/join-room-flow.spec.ts
Normal file
296
apps/web/e2e/join-room-flow.spec.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test.describe('Join Room Flow', () => {
|
||||
test.describe('Room Creation', () => {
|
||||
test('should create a room from the game page', async ({ page }) => {
|
||||
// Navigate to a game
|
||||
await page.goto('/games/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Click the (+) Add Player button to open the popover
|
||||
const addPlayerButton = page.locator('button[title="Add player"]')
|
||||
await expect(addPlayerButton).toBeVisible()
|
||||
await addPlayerButton.click()
|
||||
|
||||
// Wait for popover to appear
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Click the "Play Online" or "Invite Players" tab
|
||||
const onlineTab = page.locator('button:has-text("Play Online"), button:has-text("Invite")')
|
||||
await expect(onlineTab.first()).toBeVisible()
|
||||
await onlineTab.first().click()
|
||||
|
||||
// Click "Create New Room" button
|
||||
const createRoomButton = page.locator('button:has-text("Create New Room")')
|
||||
await expect(createRoomButton).toBeVisible()
|
||||
await createRoomButton.click()
|
||||
|
||||
// Wait for room creation to complete
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Verify we're now in a room - should see room info in nav
|
||||
const roomInfo = page.locator('text=/Room|Code/i')
|
||||
await expect(roomInfo).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Join Room by Code', () => {
|
||||
let roomCode: string
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Create a room first
|
||||
await page.goto('/games/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const addPlayerButton = page.locator('button[title="Add player"]')
|
||||
await addPlayerButton.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const onlineTab = page.locator('button:has-text("Play Online"), button:has-text("Invite")')
|
||||
await onlineTab.first().click()
|
||||
|
||||
const createRoomButton = page.locator('button:has-text("Create New Room")')
|
||||
await createRoomButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Extract the room code from the page
|
||||
const roomCodeElement = page.locator('text=/[A-Z]{3}-[0-9]{3}/')
|
||||
await expect(roomCodeElement).toBeVisible({ timeout: 5000 })
|
||||
const roomCodeText = await roomCodeElement.textContent()
|
||||
roomCode = roomCodeText?.match(/[A-Z]{3}-[0-9]{3}/)?.[0] || ''
|
||||
expect(roomCode).toMatch(/[A-Z]{3}-[0-9]{3}/)
|
||||
})
|
||||
|
||||
test('should join room via direct URL', async ({ page, context }) => {
|
||||
// Open a new page (simulating a different user)
|
||||
const newPage = await context.newPage()
|
||||
|
||||
// Navigate to the join URL
|
||||
await newPage.goto(`/join/${roomCode}`)
|
||||
await newPage.waitForLoadState('networkidle')
|
||||
|
||||
// Should show "Joining room..." or redirect to game
|
||||
await newPage.waitForTimeout(1000)
|
||||
|
||||
// Should now be in the room
|
||||
const url = newPage.url()
|
||||
expect(url).toContain('/arcade')
|
||||
})
|
||||
|
||||
test('should show error for invalid room code', async ({ page, context }) => {
|
||||
const newPage = await context.newPage()
|
||||
|
||||
// Try to join with invalid code
|
||||
await newPage.goto('/join/INVALID')
|
||||
await newPage.waitForLoadState('networkidle')
|
||||
|
||||
// Should show error message
|
||||
const errorMessage = newPage.locator('text=/not found|failed/i')
|
||||
await expect(errorMessage).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('should show confirmation when switching rooms', async ({ page }) => {
|
||||
// User is already in a room from beforeEach
|
||||
|
||||
// Try to join a different room (we'll create another one)
|
||||
const addPlayerButton = page.locator('button[title="Add player"]')
|
||||
await addPlayerButton.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const onlineTab = page.locator('button:has-text("Play Online"), button:has-text("Invite")')
|
||||
await onlineTab.first().click()
|
||||
|
||||
const createRoomButton = page.locator('button:has-text("Create New Room")')
|
||||
await createRoomButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Get the new room code
|
||||
const newRoomCodeElement = page.locator('text=/[A-Z]{3}-[0-9]{3}/')
|
||||
await expect(newRoomCodeElement).toBeVisible({ timeout: 5000 })
|
||||
const newRoomCodeText = await newRoomCodeElement.textContent()
|
||||
const newRoomCode = newRoomCodeText?.match(/[A-Z]{3}-[0-9]{3}/)?.[0] || ''
|
||||
|
||||
// Navigate to join the new room
|
||||
await page.goto(`/join/${newRoomCode}`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Should show room switch confirmation
|
||||
const confirmationDialog = page.locator('text=/Switch Rooms?|already in another room/i')
|
||||
await expect(confirmationDialog).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Should show both room codes
|
||||
await expect(page.locator(`text=${roomCode}`)).toBeVisible()
|
||||
await expect(page.locator(`text=${newRoomCode}`)).toBeVisible()
|
||||
|
||||
// Click "Switch Rooms" button
|
||||
const switchButton = page.locator('button:has-text("Switch Rooms")')
|
||||
await expect(switchButton).toBeVisible()
|
||||
await switchButton.click()
|
||||
|
||||
// Should navigate to the new room
|
||||
await page.waitForTimeout(1000)
|
||||
const url = page.url()
|
||||
expect(url).toContain('/arcade')
|
||||
})
|
||||
|
||||
test('should stay in current room when canceling switch', async ({ page }) => {
|
||||
// User is already in a room from beforeEach
|
||||
const originalRoomCode = roomCode
|
||||
|
||||
// Create another room to try switching to
|
||||
const addPlayerButton = page.locator('button[title="Add player"]')
|
||||
await addPlayerButton.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const onlineTab = page.locator('button:has-text("Play Online"), button:has-text("Invite")')
|
||||
await onlineTab.first().click()
|
||||
|
||||
const createRoomButton = page.locator('button:has-text("Create New Room")')
|
||||
await createRoomButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
const newRoomCodeElement = page.locator('text=/[A-Z]{3}-[0-9]{3}/')
|
||||
const newRoomCodeText = await newRoomCodeElement.textContent()
|
||||
const newRoomCode = newRoomCodeText?.match(/[A-Z]{3}-[0-9]{3}/)?.[0] || ''
|
||||
|
||||
// Navigate to join the new room
|
||||
await page.goto(`/join/${newRoomCode}`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Should show confirmation
|
||||
const confirmationDialog = page.locator('text=/Switch Rooms?/i')
|
||||
await expect(confirmationDialog).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Click "Cancel"
|
||||
const cancelButton = page.locator('button:has-text("Cancel")')
|
||||
await expect(cancelButton).toBeVisible()
|
||||
await cancelButton.click()
|
||||
|
||||
// Should stay on original room
|
||||
await page.waitForTimeout(500)
|
||||
const url = page.url()
|
||||
expect(url).toContain('/arcade')
|
||||
|
||||
// Should still see original room code
|
||||
await expect(page.locator(`text=${originalRoomCode}`)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Join Room Input Validation', () => {
|
||||
test('should format room code as user types', async ({ page }) => {
|
||||
await page.goto('/games/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Open the add player popover
|
||||
const addPlayerButton = page.locator('button[title="Add player"]')
|
||||
await addPlayerButton.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Switch to Play Online tab
|
||||
const onlineTab = page.locator('button:has-text("Play Online")')
|
||||
if (await onlineTab.isVisible()) {
|
||||
await onlineTab.click()
|
||||
}
|
||||
|
||||
// Find the room code input
|
||||
const codeInput = page.locator('input[placeholder*="ABC"]')
|
||||
await expect(codeInput).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Type a room code
|
||||
await codeInput.fill('abc123')
|
||||
|
||||
// Should be formatted as ABC-123
|
||||
const inputValue = await codeInput.inputValue()
|
||||
expect(inputValue).toBe('ABC-123')
|
||||
})
|
||||
|
||||
test('should validate room code in real-time', async ({ page }) => {
|
||||
await page.goto('/games/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const addPlayerButton = page.locator('button[title="Add player"]')
|
||||
await addPlayerButton.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const onlineTab = page.locator('button:has-text("Play Online")')
|
||||
if (await onlineTab.isVisible()) {
|
||||
await onlineTab.click()
|
||||
}
|
||||
|
||||
const codeInput = page.locator('input[placeholder*="ABC"]')
|
||||
await expect(codeInput).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Type an invalid code
|
||||
await codeInput.fill('INVALID')
|
||||
|
||||
// Should show validation icon (❌)
|
||||
await page.waitForTimeout(500)
|
||||
const validationIcon = page.locator('text=/❌|Room not found/i')
|
||||
await expect(validationIcon).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Recent Rooms List', () => {
|
||||
test('should show recently joined rooms', async ({ page }) => {
|
||||
// Create and join a room
|
||||
await page.goto('/games/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const addPlayerButton = page.locator('button[title="Add player"]')
|
||||
await addPlayerButton.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const onlineTab = page.locator('button:has-text("Play Online"), button:has-text("Invite")')
|
||||
await onlineTab.first().click()
|
||||
|
||||
const createRoomButton = page.locator('button:has-text("Create New Room")')
|
||||
await createRoomButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Leave the room
|
||||
const leaveButton = page.locator('button:has-text("Leave"), button:has-text("Quit")')
|
||||
if (await leaveButton.isVisible()) {
|
||||
await leaveButton.click()
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
// Open the popover again
|
||||
await addPlayerButton.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
await onlineTab.first().click()
|
||||
|
||||
// Should see "Recent Rooms" section
|
||||
const recentRoomsSection = page.locator('text=/Recent Rooms/i')
|
||||
await expect(recentRoomsSection).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Should see at least one room in the list
|
||||
const roomListItem = page.locator('text=/[A-Z]{3}-[0-9]{3}/')
|
||||
await expect(roomListItem.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Room Ownership', () => {
|
||||
test('creator should see room controls', async ({ page }) => {
|
||||
// Create a room
|
||||
await page.goto('/games/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const addPlayerButton = page.locator('button[title="Add player"]')
|
||||
await addPlayerButton.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const onlineTab = page.locator('button:has-text("Play Online"), button:has-text("Invite")')
|
||||
await onlineTab.first().click()
|
||||
|
||||
const createRoomButton = page.locator('button:has-text("Create New Room")')
|
||||
await createRoomButton.click()
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Creator should see room management controls
|
||||
// (e.g., leave room, room settings, etc.)
|
||||
const roomControls = page.locator('button:has-text("Leave"), button:has-text("Settings")')
|
||||
await expect(roomControls.first()).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,117 +1,115 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test.describe("Mini Navigation Game Name Persistence", () => {
|
||||
test("should not show game name when navigating back to games page from a specific game", async ({
|
||||
test.describe('Mini Navigation Game Name Persistence', () => {
|
||||
test('should not show game name when navigating back to games page from a specific game', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Override baseURL for this test to match running dev server
|
||||
const baseURL = "http://localhost:3000";
|
||||
const baseURL = 'http://localhost:3000'
|
||||
|
||||
// Start at home page
|
||||
await page.goto(baseURL);
|
||||
await page.goto(baseURL)
|
||||
|
||||
// Navigate to games page - should not have game name in mini nav
|
||||
await page.click('a[href="/games"]');
|
||||
await page.waitForURL("/games");
|
||||
await page.click('a[href="/games"]')
|
||||
await page.waitForURL('/games')
|
||||
|
||||
// Check that mini nav doesn't show game name initially
|
||||
const initialGameName = page.locator('[data-testid="mini-nav-game-name"]');
|
||||
await expect(initialGameName).not.toBeVisible();
|
||||
const initialGameName = page.locator('[data-testid="mini-nav-game-name"]')
|
||||
await expect(initialGameName).not.toBeVisible()
|
||||
|
||||
// Navigate to Memory Pairs game
|
||||
await page.click('a[href="/games/matching"]');
|
||||
await page.waitForURL("/games/matching");
|
||||
await page.click('a[href="/games/matching"]')
|
||||
await page.waitForURL('/games/matching')
|
||||
|
||||
// Verify game name appears in mini nav
|
||||
const memoryPairsName = page.locator("text=🧩 Memory Pairs");
|
||||
await expect(memoryPairsName).toBeVisible();
|
||||
const memoryPairsName = page.locator('text=🧩 Memory Pairs')
|
||||
await expect(memoryPairsName).toBeVisible()
|
||||
|
||||
// Navigate back to games page using mini nav
|
||||
await page.click('a[href="/games"]');
|
||||
await page.waitForURL("/games");
|
||||
await page.click('a[href="/games"]')
|
||||
await page.waitForURL('/games')
|
||||
|
||||
// BUG: Game name should disappear but it persists
|
||||
// This test should FAIL initially, demonstrating the bug
|
||||
await expect(memoryPairsName).not.toBeVisible();
|
||||
await expect(memoryPairsName).not.toBeVisible()
|
||||
|
||||
// Also test with Memory Lightning game
|
||||
await page.click('a[href="/games/memory-quiz"]');
|
||||
await page.waitForURL("/games/memory-quiz");
|
||||
await page.click('a[href="/games/memory-quiz"]')
|
||||
await page.waitForURL('/games/memory-quiz')
|
||||
|
||||
// Verify Memory Lightning name appears
|
||||
const memoryLightningName = page.locator("text=🧠 Memory Lightning");
|
||||
await expect(memoryLightningName).toBeVisible();
|
||||
const memoryLightningName = page.locator('text=🧠 Memory Lightning')
|
||||
await expect(memoryLightningName).toBeVisible()
|
||||
|
||||
// Navigate back to games page
|
||||
await page.click('a[href="/games"]');
|
||||
await page.waitForURL("/games");
|
||||
await page.click('a[href="/games"]')
|
||||
await page.waitForURL('/games')
|
||||
|
||||
// Game name should disappear
|
||||
await expect(memoryLightningName).not.toBeVisible();
|
||||
});
|
||||
await expect(memoryLightningName).not.toBeVisible()
|
||||
})
|
||||
|
||||
test("should show correct game name when switching between different games", async ({
|
||||
page,
|
||||
}) => {
|
||||
test('should show correct game name when switching between different games', async ({ page }) => {
|
||||
// Override baseURL for this test to match running dev server
|
||||
const baseURL = "http://localhost:3000";
|
||||
const baseURL = 'http://localhost:3000'
|
||||
|
||||
// Start at Memory Pairs
|
||||
await page.goto(`${baseURL}/games/matching`);
|
||||
await expect(page.locator("text=🧩 Memory Pairs")).toBeVisible();
|
||||
await page.goto(`${baseURL}/games/matching`)
|
||||
await expect(page.locator('text=🧩 Memory Pairs')).toBeVisible()
|
||||
|
||||
// Switch to Memory Lightning
|
||||
await page.click('a[href="/games/memory-quiz"]');
|
||||
await page.waitForURL("/games/memory-quiz");
|
||||
await page.click('a[href="/games/memory-quiz"]')
|
||||
await page.waitForURL('/games/memory-quiz')
|
||||
|
||||
// Should show Memory Lightning and NOT Memory Pairs
|
||||
await expect(page.locator("text=🧠 Memory Lightning")).toBeVisible();
|
||||
await expect(page.locator("text=🧩 Memory Pairs")).not.toBeVisible();
|
||||
await expect(page.locator('text=🧠 Memory Lightning')).toBeVisible()
|
||||
await expect(page.locator('text=🧩 Memory Pairs')).not.toBeVisible()
|
||||
|
||||
// Switch back to Memory Pairs
|
||||
await page.click('a[href="/games/matching"]');
|
||||
await page.waitForURL("/games/matching");
|
||||
await page.click('a[href="/games/matching"]')
|
||||
await page.waitForURL('/games/matching')
|
||||
|
||||
// Should show Memory Pairs and NOT Memory Lightning
|
||||
await expect(page.locator("text=🧩 Memory Pairs")).toBeVisible();
|
||||
await expect(page.locator("text=🧠 Memory Lightning")).not.toBeVisible();
|
||||
});
|
||||
await expect(page.locator('text=🧩 Memory Pairs')).toBeVisible()
|
||||
await expect(page.locator('text=🧠 Memory Lightning')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test("should not persist game name when navigating through intermediate pages", async ({
|
||||
test('should not persist game name when navigating through intermediate pages', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Override baseURL for this test to match running dev server
|
||||
const baseURL = "http://localhost:3000";
|
||||
const baseURL = 'http://localhost:3000'
|
||||
|
||||
// Start at Memory Pairs game - should show game name
|
||||
await page.goto(`${baseURL}/games/matching`);
|
||||
const memoryPairsName = page.locator("text=🧩 Memory Pairs");
|
||||
await expect(memoryPairsName).toBeVisible();
|
||||
await page.goto(`${baseURL}/games/matching`)
|
||||
const memoryPairsName = page.locator('text=🧩 Memory Pairs')
|
||||
await expect(memoryPairsName).toBeVisible()
|
||||
|
||||
// Navigate to Guide page - game name should disappear
|
||||
await page.click('a[href="/guide"]');
|
||||
await page.waitForURL("/guide");
|
||||
await expect(memoryPairsName).not.toBeVisible();
|
||||
await page.click('a[href="/guide"]')
|
||||
await page.waitForURL('/guide')
|
||||
await expect(memoryPairsName).not.toBeVisible()
|
||||
|
||||
// Navigate to Games page - game name should still be gone
|
||||
await page.click('a[href="/games"]');
|
||||
await page.waitForURL("/games");
|
||||
await expect(memoryPairsName).not.toBeVisible();
|
||||
await page.click('a[href="/games"]')
|
||||
await page.waitForURL('/games')
|
||||
await expect(memoryPairsName).not.toBeVisible()
|
||||
|
||||
// Test another path: Game -> Create -> Games
|
||||
await page.goto(`${baseURL}/games/memory-quiz`);
|
||||
const memoryLightningName = page.locator("text=🧠 Memory Lightning");
|
||||
await expect(memoryLightningName).toBeVisible();
|
||||
await page.goto(`${baseURL}/games/memory-quiz`)
|
||||
const memoryLightningName = page.locator('text=🧠 Memory Lightning')
|
||||
await expect(memoryLightningName).toBeVisible()
|
||||
|
||||
// Navigate to Create page
|
||||
await page.click('a[href="/create"]');
|
||||
await page.waitForURL("/create");
|
||||
await expect(memoryLightningName).not.toBeVisible();
|
||||
await page.click('a[href="/create"]')
|
||||
await page.waitForURL('/create')
|
||||
await expect(memoryLightningName).not.toBeVisible()
|
||||
|
||||
// Navigate to Games page - should not show any game name
|
||||
await page.click('a[href="/games"]');
|
||||
await page.waitForURL("/games");
|
||||
await expect(memoryLightningName).not.toBeVisible();
|
||||
await expect(memoryPairsName).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
await page.click('a[href="/games"]')
|
||||
await page.waitForURL('/games')
|
||||
await expect(memoryLightningName).not.toBeVisible()
|
||||
await expect(memoryPairsName).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,87 +1,77 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test.describe("Game navigation slots", () => {
|
||||
test("should show Memory Pairs game name in nav when navigating to matching game", async ({
|
||||
test.describe('Game navigation slots', () => {
|
||||
test('should show Memory Pairs game name in nav when navigating to matching game', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/games/matching");
|
||||
await page.goto('/games/matching')
|
||||
|
||||
// Wait for the page to load
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Look for the game name in the navigation
|
||||
const gameNav = page.locator(
|
||||
'[data-testid="nav-slot"], h1:has-text("Memory Pairs")',
|
||||
);
|
||||
await expect(gameNav).toBeVisible();
|
||||
await expect(gameNav).toContainText("Memory Pairs");
|
||||
});
|
||||
const gameNav = page.locator('[data-testid="nav-slot"], h1:has-text("Memory Pairs")')
|
||||
await expect(gameNav).toBeVisible()
|
||||
await expect(gameNav).toContainText('Memory Pairs')
|
||||
})
|
||||
|
||||
test("should show Memory Lightning game name in nav when navigating to memory quiz", async ({
|
||||
test('should show Memory Lightning game name in nav when navigating to memory quiz', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/games/memory-quiz");
|
||||
await page.goto('/games/memory-quiz')
|
||||
|
||||
// Wait for the page to load
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Look for the game name in the navigation
|
||||
const gameNav = page.locator(
|
||||
'[data-testid="nav-slot"], h1:has-text("Memory Lightning")',
|
||||
);
|
||||
await expect(gameNav).toBeVisible();
|
||||
await expect(gameNav).toContainText("Memory Lightning");
|
||||
});
|
||||
const gameNav = page.locator('[data-testid="nav-slot"], h1:has-text("Memory Lightning")')
|
||||
await expect(gameNav).toBeVisible()
|
||||
await expect(gameNav).toContainText('Memory Lightning')
|
||||
})
|
||||
|
||||
test("should maintain game name in nav after page reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
test('should maintain game name in nav after page reload', async ({ page }) => {
|
||||
// Navigate to matching game
|
||||
await page.goto("/games/matching");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto('/games/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Verify game name appears
|
||||
const gameNav = page.locator('h1:has-text("Memory Pairs")');
|
||||
await expect(gameNav).toBeVisible();
|
||||
const gameNav = page.locator('h1:has-text("Memory Pairs")')
|
||||
await expect(gameNav).toBeVisible()
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Verify game name still appears after reload
|
||||
await expect(gameNav).toBeVisible();
|
||||
await expect(gameNav).toContainText("Memory Pairs");
|
||||
});
|
||||
await expect(gameNav).toBeVisible()
|
||||
await expect(gameNav).toContainText('Memory Pairs')
|
||||
})
|
||||
|
||||
test("should show different game names when navigating between games", async ({
|
||||
page,
|
||||
}) => {
|
||||
test('should show different game names when navigating between games', async ({ page }) => {
|
||||
// Start with matching game
|
||||
await page.goto("/games/matching");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto('/games/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const matchingNav = page.locator('h1:has-text("Memory Pairs")');
|
||||
await expect(matchingNav).toBeVisible();
|
||||
const matchingNav = page.locator('h1:has-text("Memory Pairs")')
|
||||
await expect(matchingNav).toBeVisible()
|
||||
|
||||
// Navigate to memory quiz
|
||||
await page.goto("/games/memory-quiz");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto('/games/memory-quiz')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const quizNav = page.locator('h1:has-text("Memory Lightning")');
|
||||
await expect(quizNav).toBeVisible();
|
||||
const quizNav = page.locator('h1:has-text("Memory Lightning")')
|
||||
await expect(quizNav).toBeVisible()
|
||||
|
||||
// Verify the matching game name is gone
|
||||
await expect(matchingNav).not.toBeVisible();
|
||||
});
|
||||
await expect(matchingNav).not.toBeVisible()
|
||||
})
|
||||
|
||||
test("should not show game name on non-game pages", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
test('should not show game name on non-game pages', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Should not see any game names on the home page
|
||||
const gameNavs = page.locator(
|
||||
'h1:has-text("Memory Pairs"), h1:has-text("Memory Lightning")',
|
||||
);
|
||||
await expect(gameNavs).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
const gameNavs = page.locator('h1:has-text("Memory Pairs"), h1:has-text("Memory Lightning")')
|
||||
await expect(gameNavs).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test.describe("Sound Settings Persistence", () => {
|
||||
test.describe('Sound Settings Persistence', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Clear localStorage before each test
|
||||
await page.goto("/");
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
});
|
||||
await page.goto('/')
|
||||
await page.evaluate(() => localStorage.clear())
|
||||
})
|
||||
|
||||
test("should persist sound enabled setting to localStorage", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/games/memory-quiz");
|
||||
test('should persist sound enabled setting to localStorage', async ({ page }) => {
|
||||
await page.goto('/games/memory-quiz')
|
||||
|
||||
// Open style dropdown
|
||||
await page.getByRole("button", { name: /style/i }).click();
|
||||
await page.getByRole('button', { name: /style/i }).click()
|
||||
|
||||
// Find and toggle the sound switch (should be off by default)
|
||||
const soundSwitch = page
|
||||
@@ -21,109 +19,103 @@ test.describe("Sound Settings Persistence", () => {
|
||||
.filter({ hasText: /sound/i })
|
||||
.or(page.locator('input[type="checkbox"]').filter({ hasText: /sound/i }))
|
||||
.or(page.getByLabel(/sound/i))
|
||||
.or(page.locator("button").filter({ hasText: /sound/i }))
|
||||
.first();
|
||||
.or(page.locator('button').filter({ hasText: /sound/i }))
|
||||
.first()
|
||||
|
||||
await soundSwitch.click();
|
||||
await soundSwitch.click()
|
||||
|
||||
// Check localStorage was updated
|
||||
const storedConfig = await page.evaluate(() => {
|
||||
const stored = localStorage.getItem("soroban-abacus-display-config");
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
});
|
||||
const stored = localStorage.getItem('soroban-abacus-display-config')
|
||||
return stored ? JSON.parse(stored) : null
|
||||
})
|
||||
|
||||
expect(storedConfig).toBeTruthy();
|
||||
expect(storedConfig.soundEnabled).toBe(true);
|
||||
expect(storedConfig).toBeTruthy()
|
||||
expect(storedConfig.soundEnabled).toBe(true)
|
||||
|
||||
// Reload page and verify setting persists
|
||||
await page.reload();
|
||||
await page.getByRole("button", { name: /style/i }).click();
|
||||
await page.reload()
|
||||
await page.getByRole('button', { name: /style/i }).click()
|
||||
|
||||
const soundSwitchAfterReload = page
|
||||
.locator('[role="switch"]')
|
||||
.filter({ hasText: /sound/i })
|
||||
.or(page.locator('input[type="checkbox"]').filter({ hasText: /sound/i }))
|
||||
.or(page.getByLabel(/sound/i))
|
||||
.or(page.locator("button").filter({ hasText: /sound/i }))
|
||||
.first();
|
||||
.or(page.locator('button').filter({ hasText: /sound/i }))
|
||||
.first()
|
||||
|
||||
await expect(soundSwitchAfterReload).toBeChecked();
|
||||
});
|
||||
await expect(soundSwitchAfterReload).toBeChecked()
|
||||
})
|
||||
|
||||
test("should persist sound volume setting to localStorage", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/games/memory-quiz");
|
||||
test('should persist sound volume setting to localStorage', async ({ page }) => {
|
||||
await page.goto('/games/memory-quiz')
|
||||
|
||||
// Open style dropdown
|
||||
await page.getByRole("button", { name: /style/i }).click();
|
||||
await page.getByRole('button', { name: /style/i }).click()
|
||||
|
||||
// Find volume slider
|
||||
const volumeSlider = page
|
||||
.locator('input[type="range"]')
|
||||
.or(page.locator('[role="slider"]'))
|
||||
.first();
|
||||
.first()
|
||||
|
||||
// Set volume to a specific value (e.g., 0.6)
|
||||
await volumeSlider.fill("60"); // Assuming 0-100 range
|
||||
await volumeSlider.fill('60') // Assuming 0-100 range
|
||||
|
||||
// Check localStorage was updated
|
||||
const storedConfig = await page.evaluate(() => {
|
||||
const stored = localStorage.getItem("soroban-abacus-display-config");
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
});
|
||||
const stored = localStorage.getItem('soroban-abacus-display-config')
|
||||
return stored ? JSON.parse(stored) : null
|
||||
})
|
||||
|
||||
expect(storedConfig).toBeTruthy();
|
||||
expect(storedConfig.soundVolume).toBeCloseTo(0.6, 1);
|
||||
expect(storedConfig).toBeTruthy()
|
||||
expect(storedConfig.soundVolume).toBeCloseTo(0.6, 1)
|
||||
|
||||
// Reload page and verify setting persists
|
||||
await page.reload();
|
||||
await page.getByRole("button", { name: /style/i }).click();
|
||||
await page.reload()
|
||||
await page.getByRole('button', { name: /style/i }).click()
|
||||
|
||||
const volumeSliderAfterReload = page
|
||||
.locator('input[type="range"]')
|
||||
.or(page.locator('[role="slider"]'))
|
||||
.first();
|
||||
.first()
|
||||
|
||||
const volumeValue = await volumeSliderAfterReload.inputValue();
|
||||
expect(parseFloat(volumeValue)).toBeCloseTo(60, 0); // Allow for some variance
|
||||
});
|
||||
const volumeValue = await volumeSliderAfterReload.inputValue()
|
||||
expect(parseFloat(volumeValue)).toBeCloseTo(60, 0) // Allow for some variance
|
||||
})
|
||||
|
||||
test("should load default sound settings when localStorage is empty", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/games/memory-quiz");
|
||||
test('should load default sound settings when localStorage is empty', async ({ page }) => {
|
||||
await page.goto('/games/memory-quiz')
|
||||
|
||||
// Check that default settings are loaded
|
||||
const storedConfig = await page.evaluate(() => {
|
||||
const stored = localStorage.getItem("soroban-abacus-display-config");
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
});
|
||||
const stored = localStorage.getItem('soroban-abacus-display-config')
|
||||
return stored ? JSON.parse(stored) : null
|
||||
})
|
||||
|
||||
// Should have default values: soundEnabled: true, soundVolume: 0.8
|
||||
expect(storedConfig).toBeTruthy();
|
||||
expect(storedConfig.soundEnabled).toBe(true);
|
||||
expect(storedConfig.soundVolume).toBe(0.8);
|
||||
});
|
||||
expect(storedConfig).toBeTruthy()
|
||||
expect(storedConfig.soundEnabled).toBe(true)
|
||||
expect(storedConfig.soundVolume).toBe(0.8)
|
||||
})
|
||||
|
||||
test("should handle invalid localStorage data gracefully", async ({
|
||||
page,
|
||||
}) => {
|
||||
test('should handle invalid localStorage data gracefully', async ({ page }) => {
|
||||
// Set invalid localStorage data
|
||||
await page.goto("/");
|
||||
await page.goto('/')
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem("soroban-abacus-display-config", "invalid-json");
|
||||
});
|
||||
localStorage.setItem('soroban-abacus-display-config', 'invalid-json')
|
||||
})
|
||||
|
||||
await page.goto("/games/memory-quiz");
|
||||
await page.goto('/games/memory-quiz')
|
||||
|
||||
// Should fall back to defaults and not crash
|
||||
const storedConfig = await page.evaluate(() => {
|
||||
const stored = localStorage.getItem("soroban-abacus-display-config");
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
});
|
||||
const stored = localStorage.getItem('soroban-abacus-display-config')
|
||||
return stored ? JSON.parse(stored) : null
|
||||
})
|
||||
|
||||
expect(storedConfig.soundEnabled).toBe(true);
|
||||
expect(storedConfig.soundVolume).toBe(0.8);
|
||||
});
|
||||
});
|
||||
expect(storedConfig.soundEnabled).toBe(true)
|
||||
expect(storedConfig.soundVolume).toBe(0.8)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,51 +1,44 @@
|
||||
// Minimal ESLint flat config ONLY for react-hooks rules
|
||||
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import tsParser from '@typescript-eslint/parser'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
|
||||
const config = [
|
||||
{
|
||||
ignores: [
|
||||
"dist",
|
||||
".next",
|
||||
"coverage",
|
||||
"node_modules",
|
||||
"styled-system",
|
||||
"storybook-static",
|
||||
],
|
||||
ignores: ['dist', '.next', 'coverage', 'node_modules', 'styled-system', 'storybook-static'],
|
||||
},
|
||||
{
|
||||
files: ["**/*.tsx", "**/*.ts", "**/*.jsx", "**/*.js"],
|
||||
files: ['**/*.tsx', '**/*.ts', '**/*.jsx', '**/*.js'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
ecmaVersion: 2022,
|
||||
sourceType: "module",
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
React: "readonly",
|
||||
JSX: "readonly",
|
||||
console: "readonly",
|
||||
process: "readonly",
|
||||
module: "readonly",
|
||||
require: "readonly",
|
||||
window: "readonly",
|
||||
document: "readonly",
|
||||
localStorage: "readonly",
|
||||
sessionStorage: "readonly",
|
||||
fetch: "readonly",
|
||||
global: "readonly",
|
||||
Buffer: "readonly",
|
||||
__dirname: "readonly",
|
||||
__filename: "readonly",
|
||||
React: 'readonly',
|
||||
JSX: 'readonly',
|
||||
console: 'readonly',
|
||||
process: 'readonly',
|
||||
module: 'readonly',
|
||||
require: 'readonly',
|
||||
window: 'readonly',
|
||||
document: 'readonly',
|
||||
localStorage: 'readonly',
|
||||
sessionStorage: 'readonly',
|
||||
fetch: 'readonly',
|
||||
global: 'readonly',
|
||||
Buffer: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
rules: {
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
export default config;
|
||||
export default config
|
||||
|
||||
@@ -7,16 +7,16 @@ const nextConfig = {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
experimental: {
|
||||
optimizePackageImports: ["@soroban/core", "@soroban/client"],
|
||||
serverComponentsExternalPackages: ["@myriaddreamin/typst.ts"],
|
||||
optimizePackageImports: ['@soroban/core', '@soroban/client'],
|
||||
serverComponentsExternalPackages: ['@myriaddreamin/typst.ts'],
|
||||
},
|
||||
transpilePackages: ["@soroban/core", "@soroban/client"],
|
||||
transpilePackages: ['@soroban/core', '@soroban/client'],
|
||||
webpack: (config, { isServer }) => {
|
||||
config.experiments = {
|
||||
...config.experiments,
|
||||
asyncWebAssembly: true,
|
||||
layers: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Optimize WASM loading
|
||||
if (!isServer) {
|
||||
@@ -30,37 +30,37 @@ const nextConfig = {
|
||||
// Create separate chunk for WASM modules
|
||||
wasm: {
|
||||
test: /\.wasm$/,
|
||||
name: "wasm",
|
||||
chunks: "async",
|
||||
name: 'wasm',
|
||||
chunks: 'async',
|
||||
enforce: true,
|
||||
},
|
||||
// Separate typst.ts into its own chunk
|
||||
typst: {
|
||||
test: /[\\/]node_modules[\\/]@myriaddreamin[\\/]typst.*[\\/]/,
|
||||
name: "typst",
|
||||
chunks: "async",
|
||||
name: 'typst',
|
||||
chunks: 'async',
|
||||
enforce: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Add preload hints for critical WASM files
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
fs: false,
|
||||
path: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fix for WASM modules
|
||||
config.module.rules.push({
|
||||
test: /\.wasm$/,
|
||||
type: "asset/resource",
|
||||
});
|
||||
type: 'asset/resource',
|
||||
})
|
||||
|
||||
return config;
|
||||
return config
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = nextConfig;
|
||||
module.exports = nextConfig
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"@tanstack/react-form": "^0.19.0",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"drizzle-orm": "^0.44.6",
|
||||
"emojibase-data": "^16.0.3",
|
||||
@@ -77,6 +78,7 @@
|
||||
"@storybook/nextjs": "^9.1.7",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.2.0",
|
||||
|
||||
@@ -1,123 +1,118 @@
|
||||
import { defineConfig } from "@pandacss/dev";
|
||||
import { defineConfig } from '@pandacss/dev'
|
||||
|
||||
export default defineConfig({
|
||||
// Whether to use css reset
|
||||
preflight: true,
|
||||
|
||||
// Where to look for your css declarations
|
||||
include: ["./src/**/*.{js,jsx,ts,tsx}", "./pages/**/*.{js,jsx,ts,tsx}"],
|
||||
include: ['./src/**/*.{js,jsx,ts,tsx}', './pages/**/*.{js,jsx,ts,tsx}'],
|
||||
|
||||
// Files to exclude
|
||||
exclude: [],
|
||||
|
||||
// The output directory for your css system
|
||||
outdir: "styled-system",
|
||||
outdir: 'styled-system',
|
||||
|
||||
// The JSX framework to use
|
||||
jsxFramework: "react",
|
||||
jsxFramework: 'react',
|
||||
|
||||
theme: {
|
||||
extend: {
|
||||
tokens: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: { value: "#f0f9ff" },
|
||||
100: { value: "#e0f2fe" },
|
||||
200: { value: "#bae6fd" },
|
||||
300: { value: "#7dd3fc" },
|
||||
400: { value: "#38bdf8" },
|
||||
500: { value: "#0ea5e9" },
|
||||
600: { value: "#0284c7" },
|
||||
700: { value: "#0369a1" },
|
||||
800: { value: "#075985" },
|
||||
900: { value: "#0c4a6e" },
|
||||
50: { value: '#f0f9ff' },
|
||||
100: { value: '#e0f2fe' },
|
||||
200: { value: '#bae6fd' },
|
||||
300: { value: '#7dd3fc' },
|
||||
400: { value: '#38bdf8' },
|
||||
500: { value: '#0ea5e9' },
|
||||
600: { value: '#0284c7' },
|
||||
700: { value: '#0369a1' },
|
||||
800: { value: '#075985' },
|
||||
900: { value: '#0c4a6e' },
|
||||
},
|
||||
soroban: {
|
||||
wood: { value: "#8B4513" },
|
||||
bead: { value: "#2C1810" },
|
||||
inactive: { value: "#D3D3D3" },
|
||||
bar: { value: "#654321" },
|
||||
wood: { value: '#8B4513' },
|
||||
bead: { value: '#2C1810' },
|
||||
inactive: { value: '#D3D3D3' },
|
||||
bar: { value: '#654321' },
|
||||
},
|
||||
},
|
||||
fonts: {
|
||||
body: {
|
||||
value:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
},
|
||||
heading: {
|
||||
value:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
},
|
||||
mono: {
|
||||
value:
|
||||
'Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace',
|
||||
value: 'Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace',
|
||||
},
|
||||
},
|
||||
shadows: {
|
||||
card: {
|
||||
value:
|
||||
"0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
|
||||
value: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
modal: { value: "0 25px 50px -12px rgba(0, 0, 0, 0.25)" },
|
||||
modal: { value: '0 25px 50px -12px rgba(0, 0, 0, 0.25)' },
|
||||
},
|
||||
animations: {
|
||||
// Shake animation for errors (web_generator.py line 3419)
|
||||
shake: { value: "shake 0.5s ease-in-out" },
|
||||
shake: { value: 'shake 0.5s ease-in-out' },
|
||||
// Pulse animation for success feedback (line 2004)
|
||||
successPulse: { value: "successPulse 0.5s ease" },
|
||||
pulse: { value: "pulse 2s infinite" },
|
||||
successPulse: { value: 'successPulse 0.5s ease' },
|
||||
pulse: { value: 'pulse 2s infinite' },
|
||||
// Error shake with larger amplitude (line 2009)
|
||||
errorShake: { value: "errorShake 0.5s ease" },
|
||||
errorShake: { value: 'errorShake 0.5s ease' },
|
||||
// Bounce animations (line 6271, 5065)
|
||||
bounce: { value: "bounce 1s infinite alternate" },
|
||||
bounceIn: { value: "bounceIn 1s ease-out" },
|
||||
bounce: { value: 'bounce 1s infinite alternate' },
|
||||
bounceIn: { value: 'bounceIn 1s ease-out' },
|
||||
// Glow animation (line 6260)
|
||||
glow: { value: "glow 1s ease-in-out infinite alternate" },
|
||||
glow: { value: 'glow 1s ease-in-out infinite alternate' },
|
||||
},
|
||||
},
|
||||
keyframes: {
|
||||
// Shake - horizontal oscillation for errors (line 3419)
|
||||
shake: {
|
||||
"0%, 100%": { transform: "translateX(0)" },
|
||||
"25%": { transform: "translateX(-5px)" },
|
||||
"75%": { transform: "translateX(5px)" },
|
||||
'0%, 100%': { transform: 'translateX(0)' },
|
||||
'25%': { transform: 'translateX(-5px)' },
|
||||
'75%': { transform: 'translateX(5px)' },
|
||||
},
|
||||
// Success pulse - gentle scale for correct answers (line 2004)
|
||||
successPulse: {
|
||||
"0%, 100%": { transform: "scale(1)" },
|
||||
"50%": { transform: "scale(1.05)" },
|
||||
'0%, 100%': { transform: 'scale(1)' },
|
||||
'50%': { transform: 'scale(1.05)' },
|
||||
},
|
||||
// Pulse - continuous breathing effect (line 6255)
|
||||
pulse: {
|
||||
"0%, 100%": { transform: "scale(1)" },
|
||||
"50%": { transform: "scale(1.05)" },
|
||||
'0%, 100%': { transform: 'scale(1)' },
|
||||
'50%': { transform: 'scale(1.05)' },
|
||||
},
|
||||
// Error shake - stronger horizontal oscillation (line 2009)
|
||||
errorShake: {
|
||||
"0%, 100%": { transform: "translateX(0)" },
|
||||
"25%": { transform: "translateX(-10px)" },
|
||||
"75%": { transform: "translateX(10px)" },
|
||||
'0%, 100%': { transform: 'translateX(0)' },
|
||||
'25%': { transform: 'translateX(-10px)' },
|
||||
'75%': { transform: 'translateX(10px)' },
|
||||
},
|
||||
// Bounce - vertical oscillation (line 6271)
|
||||
bounce: {
|
||||
"0%, 100%": { transform: "translateY(0)" },
|
||||
"50%": { transform: "translateY(-10px)" },
|
||||
'0%, 100%': { transform: 'translateY(0)' },
|
||||
'50%': { transform: 'translateY(-10px)' },
|
||||
},
|
||||
// Bounce in - entry animation with scale and rotate (line 6265)
|
||||
bounceIn: {
|
||||
"0%": { transform: "scale(0.3) rotate(-10deg)", opacity: "0" },
|
||||
"50%": { transform: "scale(1.1) rotate(5deg)" },
|
||||
"100%": { transform: "scale(1) rotate(0deg)", opacity: "1" },
|
||||
'0%': { transform: 'scale(0.3) rotate(-10deg)', opacity: '0' },
|
||||
'50%': { transform: 'scale(1.1) rotate(5deg)' },
|
||||
'100%': { transform: 'scale(1) rotate(0deg)', opacity: '1' },
|
||||
},
|
||||
// Glow - expanding box shadow (line 6260)
|
||||
glow: {
|
||||
"0%": { boxShadow: "0 0 5px rgba(255, 255, 255, 0.5)" },
|
||||
"100%": {
|
||||
boxShadow:
|
||||
"0 0 20px rgba(255, 255, 255, 0.8), 0 0 30px rgba(255, 255, 255, 0.6)",
|
||||
'0%': { boxShadow: '0 0 5px rgba(255, 255, 255, 0.5)' },
|
||||
'100%': {
|
||||
boxShadow: '0 0 20px rgba(255, 255, 255, 0.8), 0 0 30px rgba(255, 255, 255, 0.6)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: "html",
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: "http://localhost:3002",
|
||||
trace: "on-first-retry",
|
||||
baseURL: 'http://localhost:3002',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: "pnpm dev",
|
||||
url: "http://localhost:3002",
|
||||
command: 'pnpm dev',
|
||||
url: 'http://localhost:3002',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
@@ -5,26 +5,26 @@
|
||||
* This script captures git commit, branch, timestamp, and other metadata
|
||||
*/
|
||||
|
||||
const { execSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execSync } = require('child_process')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
function exec(command) {
|
||||
try {
|
||||
return execSync(command, { encoding: "utf-8" }).trim();
|
||||
return execSync(command, { encoding: 'utf-8' }).trim()
|
||||
} catch (_error) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getBuildInfo() {
|
||||
const gitCommit = exec("git rev-parse HEAD");
|
||||
const gitCommitShort = exec("git rev-parse --short HEAD");
|
||||
const gitBranch = exec("git rev-parse --abbrev-ref HEAD");
|
||||
const gitTag = exec("git describe --tags --exact-match 2>/dev/null");
|
||||
const gitDirty = exec('git diff --quiet || echo "dirty"') === "dirty";
|
||||
const gitCommit = exec('git rev-parse HEAD')
|
||||
const gitCommitShort = exec('git rev-parse --short HEAD')
|
||||
const gitBranch = exec('git rev-parse --abbrev-ref HEAD')
|
||||
const gitTag = exec('git describe --tags --exact-match 2>/dev/null')
|
||||
const gitDirty = exec('git diff --quiet || echo "dirty"') === 'dirty'
|
||||
|
||||
const packageJson = require("../package.json");
|
||||
const packageJson = require('../package.json')
|
||||
|
||||
return {
|
||||
version: packageJson.version,
|
||||
@@ -37,28 +37,22 @@ function getBuildInfo() {
|
||||
tag: gitTag,
|
||||
isDirty: gitDirty,
|
||||
},
|
||||
environment: process.env.NODE_ENV || "development",
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
buildNumber: process.env.BUILD_NUMBER || null,
|
||||
nodeVersion: process.version,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const buildInfo = getBuildInfo();
|
||||
const outputPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"src",
|
||||
"generated",
|
||||
"build-info.json",
|
||||
);
|
||||
const buildInfo = getBuildInfo()
|
||||
const outputPath = path.join(__dirname, '..', 'src', 'generated', 'build-info.json')
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(outputPath);
|
||||
const dir = path.dirname(outputPath)
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, JSON.stringify(buildInfo, null, 2));
|
||||
fs.writeFileSync(outputPath, JSON.stringify(buildInfo, null, 2))
|
||||
|
||||
console.log("✅ Build info generated:", outputPath);
|
||||
console.log(JSON.stringify(buildInfo, null, 2));
|
||||
console.log('✅ Build info generated:', outputPath)
|
||||
console.log(JSON.stringify(buildInfo, null, 2))
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
const { createServer } = require("http");
|
||||
const { parse } = require("url");
|
||||
const next = require("next");
|
||||
const { createServer } = require('http')
|
||||
const { parse } = require('url')
|
||||
const next = require('next')
|
||||
|
||||
const dev = process.env.NODE_ENV !== "production";
|
||||
const hostname = "localhost";
|
||||
const port = parseInt(process.env.PORT || "3000", 10);
|
||||
const dev = process.env.NODE_ENV !== 'production'
|
||||
const hostname = 'localhost'
|
||||
const port = parseInt(process.env.PORT || '3000', 10)
|
||||
|
||||
const app = next({ dev, hostname, port });
|
||||
const handle = app.getRequestHandler();
|
||||
const app = next({ dev, hostname, port })
|
||||
const handle = app.getRequestHandler()
|
||||
|
||||
// Run migrations before starting server
|
||||
console.log("🔄 Running database migrations...");
|
||||
const { migrate } = require("drizzle-orm/better-sqlite3/migrator");
|
||||
const { db } = require("./dist/db/index");
|
||||
console.log('🔄 Running database migrations...')
|
||||
const { migrate } = require('drizzle-orm/better-sqlite3/migrator')
|
||||
const { db } = require('./dist/db/index')
|
||||
|
||||
try {
|
||||
migrate(db, { migrationsFolder: "./drizzle" });
|
||||
console.log("✅ Migrations complete");
|
||||
migrate(db, { migrationsFolder: './drizzle' })
|
||||
console.log('✅ Migrations complete')
|
||||
} catch (error) {
|
||||
console.error("❌ Migration failed:", error);
|
||||
process.exit(1);
|
||||
console.error('❌ Migration failed:', error)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
app.prepare().then(() => {
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const parsedUrl = parse(req.url, true);
|
||||
await handle(req, res, parsedUrl);
|
||||
const parsedUrl = parse(req.url, true)
|
||||
await handle(req, res, parsedUrl)
|
||||
} catch (err) {
|
||||
console.error("Error occurred handling", req.url, err);
|
||||
res.statusCode = 500;
|
||||
res.end("internal server error");
|
||||
console.error('Error occurred handling', req.url, err)
|
||||
res.statusCode = 500
|
||||
res.end('internal server error')
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// Initialize Socket.IO
|
||||
const { initializeSocketServer } = require("./dist/socket-server");
|
||||
initializeSocketServer(server);
|
||||
const { initializeSocketServer } = require('./dist/socket-server')
|
||||
initializeSocketServer(server)
|
||||
|
||||
server
|
||||
.once("error", (err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
.once('error', (err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
.listen(port, () => {
|
||||
console.log(`> Ready on http://${hostname}:${port}`);
|
||||
});
|
||||
});
|
||||
console.log(`> Ready on http://${hostname}:${port}`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import RootLayout from "../layout";
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import RootLayout from '../layout'
|
||||
|
||||
// Mock ClientProviders
|
||||
vi.mock("../../components/ClientProviders", () => ({
|
||||
vi.mock('../../components/ClientProviders', () => ({
|
||||
ClientProviders: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="client-providers">{children}</div>
|
||||
),
|
||||
}));
|
||||
}))
|
||||
|
||||
describe("RootLayout", () => {
|
||||
it("renders children with ClientProviders", () => {
|
||||
const pageContent = <div>Page content</div>;
|
||||
describe('RootLayout', () => {
|
||||
it('renders children with ClientProviders', () => {
|
||||
const pageContent = <div>Page content</div>
|
||||
|
||||
render(<RootLayout>{pageContent}</RootLayout>);
|
||||
render(<RootLayout>{pageContent}</RootLayout>)
|
||||
|
||||
expect(screen.getByTestId("client-providers")).toBeInTheDocument();
|
||||
expect(screen.getByText("Page content")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('client-providers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Page content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders html and body tags", () => {
|
||||
const pageContent = <div>Test content</div>;
|
||||
it('renders html and body tags', () => {
|
||||
const pageContent = <div>Test content</div>
|
||||
|
||||
const { container } = render(<RootLayout>{pageContent}</RootLayout>);
|
||||
const { container } = render(<RootLayout>{pageContent}</RootLayout>)
|
||||
|
||||
const html = container.querySelector("html");
|
||||
const body = container.querySelector("body");
|
||||
const html = container.querySelector('html')
|
||||
const body = container.querySelector('body')
|
||||
|
||||
expect(html).toBeInTheDocument();
|
||||
expect(html).toHaveAttribute("lang", "en");
|
||||
expect(body).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
expect(html).toBeInTheDocument()
|
||||
expect(html).toHaveAttribute('lang', 'en')
|
||||
expect(body).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { AbacusReact } from "@soroban/abacus-react";
|
||||
import { useState } from "react";
|
||||
import { css } from "../../../styled-system/css";
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { useState } from 'react'
|
||||
import { css } from '../../../styled-system/css'
|
||||
|
||||
export default function AbacusTestPage() {
|
||||
const [value, setValue] = useState(0);
|
||||
const [debugInfo, setDebugInfo] = useState<string>("");
|
||||
const [value, setValue] = useState(0)
|
||||
const [debugInfo, setDebugInfo] = useState<string>('')
|
||||
|
||||
const handleValueChange = (newValue: number) => {
|
||||
setValue(newValue);
|
||||
setDebugInfo(`Value changed to: ${newValue}`);
|
||||
console.log("Abacus value:", newValue);
|
||||
};
|
||||
setValue(newValue)
|
||||
setDebugInfo(`Value changed to: ${newValue}`)
|
||||
console.log('Abacus value:', newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: "fixed",
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
bg: "gray.50",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "4",
|
||||
bg: 'gray.50',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '4',
|
||||
})}
|
||||
>
|
||||
{/* Debug info */}
|
||||
<div
|
||||
className={css({
|
||||
position: "absolute",
|
||||
top: "4",
|
||||
left: "4",
|
||||
bg: "white",
|
||||
p: "3",
|
||||
rounded: "md",
|
||||
border: "1px solid",
|
||||
borderColor: "gray.300",
|
||||
fontSize: "sm",
|
||||
fontFamily: "mono",
|
||||
position: 'absolute',
|
||||
top: '4',
|
||||
left: '4',
|
||||
bg: 'white',
|
||||
p: '3',
|
||||
rounded: 'md',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
fontSize: 'sm',
|
||||
fontFamily: 'mono',
|
||||
})}
|
||||
>
|
||||
<div>Current Value: {value}</div>
|
||||
@@ -50,14 +50,14 @@ export default function AbacusTestPage() {
|
||||
<button
|
||||
onClick={() => setValue(0)}
|
||||
className={css({
|
||||
mt: "2",
|
||||
px: "2",
|
||||
py: "1",
|
||||
bg: "blue.500",
|
||||
color: "white",
|
||||
rounded: "sm",
|
||||
fontSize: "xs",
|
||||
cursor: "pointer",
|
||||
mt: '2',
|
||||
px: '2',
|
||||
py: '1',
|
||||
bg: 'blue.500',
|
||||
color: 'white',
|
||||
rounded: 'sm',
|
||||
fontSize: 'xs',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
Reset to 0
|
||||
@@ -65,14 +65,14 @@ export default function AbacusTestPage() {
|
||||
<button
|
||||
onClick={() => setValue(12345)}
|
||||
className={css({
|
||||
mt: "1",
|
||||
px: "2",
|
||||
py: "1",
|
||||
bg: "green.500",
|
||||
color: "white",
|
||||
rounded: "sm",
|
||||
fontSize: "xs",
|
||||
cursor: "pointer",
|
||||
mt: '1',
|
||||
px: '2',
|
||||
py: '1',
|
||||
bg: 'green.500',
|
||||
color: 'white',
|
||||
rounded: 'sm',
|
||||
fontSize: 'xs',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
Set to 12345
|
||||
@@ -81,11 +81,11 @@ export default function AbacusTestPage() {
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AbacusReact
|
||||
@@ -102,5 +102,5 @@ export default function AbacusTestPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import * as schema from "@/db/schema";
|
||||
import { getViewerId } from "@/lib/viewer";
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db } from '@/db'
|
||||
import * as schema from '@/db/schema'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* GET /api/abacus-settings
|
||||
@@ -10,30 +10,27 @@ import { getViewerId } from "@/lib/viewer";
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const viewerId = await getViewerId();
|
||||
const user = await getOrCreateUser(viewerId);
|
||||
const viewerId = await getViewerId()
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
|
||||
// Find or create abacus settings
|
||||
let settings = await db.query.abacusSettings.findFirst({
|
||||
where: eq(schema.abacusSettings.userId, user.id),
|
||||
});
|
||||
})
|
||||
|
||||
// If no settings exist, create with defaults
|
||||
if (!settings) {
|
||||
const [newSettings] = await db
|
||||
.insert(schema.abacusSettings)
|
||||
.values({ userId: user.id })
|
||||
.returning();
|
||||
settings = newSettings;
|
||||
.returning()
|
||||
settings = newSettings
|
||||
}
|
||||
|
||||
return NextResponse.json({ settings });
|
||||
return NextResponse.json({ settings })
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch abacus settings:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch abacus settings" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('Failed to fetch abacus settings:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch abacus settings' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,26 +40,26 @@ export async function GET() {
|
||||
*/
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const viewerId = await getViewerId();
|
||||
const body = await req.json();
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Security: Strip userId from request body - it must come from session only
|
||||
const { userId: _, ...updates } = body;
|
||||
const { userId: _, ...updates } = body
|
||||
|
||||
const user = await getOrCreateUser(viewerId);
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
|
||||
// Ensure settings exist
|
||||
const existingSettings = await db.query.abacusSettings.findFirst({
|
||||
where: eq(schema.abacusSettings.userId, user.id),
|
||||
});
|
||||
})
|
||||
|
||||
if (!existingSettings) {
|
||||
// Create new settings with updates
|
||||
const [newSettings] = await db
|
||||
.insert(schema.abacusSettings)
|
||||
.values({ userId: user.id, ...updates })
|
||||
.returning();
|
||||
return NextResponse.json({ settings: newSettings });
|
||||
.returning()
|
||||
return NextResponse.json({ settings: newSettings })
|
||||
}
|
||||
|
||||
// Update existing settings
|
||||
@@ -70,15 +67,12 @@ export async function PATCH(req: NextRequest) {
|
||||
.update(schema.abacusSettings)
|
||||
.set(updates)
|
||||
.where(eq(schema.abacusSettings.userId, user.id))
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
return NextResponse.json({ settings: updatedSettings });
|
||||
return NextResponse.json({ settings: updatedSettings })
|
||||
} catch (error) {
|
||||
console.error("Failed to update abacus settings:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update abacus settings" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('Failed to update abacus settings:', error)
|
||||
return NextResponse.json({ error: 'Failed to update abacus settings' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +83,7 @@ async function getOrCreateUser(viewerId: string) {
|
||||
// Try to find existing user by guest ID
|
||||
let user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
});
|
||||
})
|
||||
|
||||
// If no user exists, create one
|
||||
if (!user) {
|
||||
@@ -98,10 +92,10 @@ async function getOrCreateUser(viewerId: string) {
|
||||
.values({
|
||||
guestId: viewerId,
|
||||
})
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
user = newUser;
|
||||
user = newUser
|
||||
}
|
||||
|
||||
return user;
|
||||
return user
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { db, schema } from "@/db";
|
||||
import { deleteArcadeSession } from "@/lib/arcade/session-manager";
|
||||
import { DELETE, GET, POST } from "../route";
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { db, schema } from '@/db'
|
||||
import { deleteArcadeSession } from '@/lib/arcade/session-manager'
|
||||
import { DELETE, GET, POST } from '../route'
|
||||
|
||||
describe("Arcade Session API Routes", () => {
|
||||
const testUserId = "test-user-for-api-routes";
|
||||
const testGuestId = "test-guest-id-api-routes";
|
||||
const baseUrl = "http://localhost:3000";
|
||||
describe('Arcade Session API Routes', () => {
|
||||
const testUserId = 'test-user-for-api-routes'
|
||||
const testGuestId = 'test-guest-id-api-routes'
|
||||
const baseUrl = 'http://localhost:3000'
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test user
|
||||
@@ -19,167 +19,158 @@ describe("Arcade Session API Routes", () => {
|
||||
guestId: testGuestId,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
});
|
||||
.onConflictDoNothing()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up
|
||||
await deleteArcadeSession(testUserId);
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId));
|
||||
});
|
||||
await deleteArcadeSession(testUserId)
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
||||
})
|
||||
|
||||
describe("POST /api/arcade-session", () => {
|
||||
it("should create a new session", async () => {
|
||||
describe('POST /api/arcade-session', () => {
|
||||
it('should create a new session', async () => {
|
||||
const request = new NextRequest(`${baseUrl}/api/arcade-session`, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
userId: testUserId,
|
||||
gameName: "matching",
|
||||
gameUrl: "/arcade/matching",
|
||||
initialState: { test: "state" },
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: { test: 'state' },
|
||||
activePlayers: [1],
|
||||
}),
|
||||
});
|
||||
})
|
||||
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.session).toBeDefined();
|
||||
expect(data.session.currentGame).toBe("matching");
|
||||
expect(data.session.version).toBe(1);
|
||||
});
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.session).toBeDefined()
|
||||
expect(data.session.currentGame).toBe('matching')
|
||||
expect(data.session.version).toBe(1)
|
||||
})
|
||||
|
||||
it("should return 400 for missing fields", async () => {
|
||||
it('should return 400 for missing fields', async () => {
|
||||
const request = new NextRequest(`${baseUrl}/api/arcade-session`, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
userId: testUserId,
|
||||
// Missing required fields
|
||||
}),
|
||||
});
|
||||
})
|
||||
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe("Missing required fields");
|
||||
});
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.error).toBe('Missing required fields')
|
||||
})
|
||||
|
||||
it("should return 500 for non-existent user (foreign key constraint)", async () => {
|
||||
it('should return 500 for non-existent user (foreign key constraint)', async () => {
|
||||
const request = new NextRequest(`${baseUrl}/api/arcade-session`, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
userId: "non-existent-user",
|
||||
gameName: "matching",
|
||||
gameUrl: "/arcade/matching",
|
||||
userId: 'non-existent-user',
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: {},
|
||||
activePlayers: [1],
|
||||
}),
|
||||
});
|
||||
})
|
||||
|
||||
const response = await POST(request);
|
||||
const response = await POST(request)
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
expect(response.status).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe("GET /api/arcade-session", () => {
|
||||
it("should retrieve an existing session", async () => {
|
||||
describe('GET /api/arcade-session', () => {
|
||||
it('should retrieve an existing session', async () => {
|
||||
// Create session first
|
||||
const createRequest = new NextRequest(`${baseUrl}/api/arcade-session`, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
userId: testUserId,
|
||||
gameName: "matching",
|
||||
gameUrl: "/arcade/matching",
|
||||
initialState: { test: "state" },
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: { test: 'state' },
|
||||
activePlayers: [1],
|
||||
}),
|
||||
});
|
||||
await POST(createRequest);
|
||||
})
|
||||
await POST(createRequest)
|
||||
|
||||
// Now retrieve it
|
||||
const request = new NextRequest(
|
||||
`${baseUrl}/api/arcade-session?userId=${testUserId}`,
|
||||
);
|
||||
const request = new NextRequest(`${baseUrl}/api/arcade-session?userId=${testUserId}`)
|
||||
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
const response = await GET(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.session).toBeDefined();
|
||||
expect(data.session.currentGame).toBe("matching");
|
||||
});
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.session).toBeDefined()
|
||||
expect(data.session.currentGame).toBe('matching')
|
||||
})
|
||||
|
||||
it("should return 404 for non-existent session", async () => {
|
||||
const request = new NextRequest(
|
||||
`${baseUrl}/api/arcade-session?userId=non-existent`,
|
||||
);
|
||||
it('should return 404 for non-existent session', async () => {
|
||||
const request = new NextRequest(`${baseUrl}/api/arcade-session?userId=non-existent`)
|
||||
|
||||
const response = await GET(request);
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it("should return 400 for missing userId", async () => {
|
||||
const request = new NextRequest(`${baseUrl}/api/arcade-session`);
|
||||
it('should return 400 for missing userId', async () => {
|
||||
const request = new NextRequest(`${baseUrl}/api/arcade-session`)
|
||||
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
const response = await GET(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe("userId required");
|
||||
});
|
||||
});
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.error).toBe('userId required')
|
||||
})
|
||||
})
|
||||
|
||||
describe("DELETE /api/arcade-session", () => {
|
||||
it("should delete an existing session", async () => {
|
||||
describe('DELETE /api/arcade-session', () => {
|
||||
it('should delete an existing session', async () => {
|
||||
// Create session first
|
||||
const createRequest = new NextRequest(`${baseUrl}/api/arcade-session`, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
userId: testUserId,
|
||||
gameName: "matching",
|
||||
gameUrl: "/arcade/matching",
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
initialState: {},
|
||||
activePlayers: [1],
|
||||
}),
|
||||
});
|
||||
await POST(createRequest);
|
||||
})
|
||||
await POST(createRequest)
|
||||
|
||||
// Now delete it
|
||||
const request = new NextRequest(
|
||||
`${baseUrl}/api/arcade-session?userId=${testUserId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
const request = new NextRequest(`${baseUrl}/api/arcade-session?userId=${testUserId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const response = await DELETE(request);
|
||||
const data = await response.json();
|
||||
const response = await DELETE(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
|
||||
// Verify it's deleted
|
||||
const getRequest = new NextRequest(
|
||||
`${baseUrl}/api/arcade-session?userId=${testUserId}`,
|
||||
);
|
||||
const getResponse = await GET(getRequest);
|
||||
expect(getResponse.status).toBe(404);
|
||||
});
|
||||
const getRequest = new NextRequest(`${baseUrl}/api/arcade-session?userId=${testUserId}`)
|
||||
const getResponse = await GET(getRequest)
|
||||
expect(getResponse.status).toBe(404)
|
||||
})
|
||||
|
||||
it("should return 400 for missing userId", async () => {
|
||||
it('should return 400 for missing userId', async () => {
|
||||
const request = new NextRequest(`${baseUrl}/api/arcade-session`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const response = await DELETE(request);
|
||||
const data = await response.json();
|
||||
const response = await DELETE(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe("userId required");
|
||||
});
|
||||
});
|
||||
});
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.error).toBe('userId required')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
createArcadeSession,
|
||||
deleteArcadeSession,
|
||||
getArcadeSession,
|
||||
} from "@/lib/arcade/session-manager";
|
||||
import type { GameName } from "@/lib/arcade/validation";
|
||||
} from '@/lib/arcade/session-manager'
|
||||
import type { GameName } from '@/lib/arcade/validation'
|
||||
|
||||
/**
|
||||
* GET /api/arcade-session?userId=xxx
|
||||
@@ -12,16 +12,16 @@ import type { GameName } from "@/lib/arcade/validation";
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const userId = request.nextUrl.searchParams.get("userId");
|
||||
const userId = request.nextUrl.searchParams.get('userId')
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "userId required" }, { status: 400 });
|
||||
return NextResponse.json({ error: 'userId required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const session = await getArcadeSession(userId);
|
||||
const session = await getArcadeSession(userId)
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "No active session" }, { status: 404 });
|
||||
return NextResponse.json({ error: 'No active session' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -33,13 +33,10 @@ export async function GET(request: NextRequest) {
|
||||
version: session.version,
|
||||
expiresAt: session.expiresAt,
|
||||
},
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error fetching arcade session:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('Error fetching arcade session:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,25 +46,17 @@ export async function GET(request: NextRequest) {
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { userId, gameName, gameUrl, initialState, activePlayers, roomId } =
|
||||
body;
|
||||
const body = await request.json()
|
||||
const { userId, gameName, gameUrl, initialState, activePlayers, roomId } = body
|
||||
|
||||
if (
|
||||
!userId ||
|
||||
!gameName ||
|
||||
!gameUrl ||
|
||||
!initialState ||
|
||||
!activePlayers ||
|
||||
!roomId
|
||||
) {
|
||||
if (!userId || !gameName || !gameUrl || !initialState || !activePlayers || !roomId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Missing required fields (userId, gameName, gameUrl, initialState, activePlayers, roomId)",
|
||||
'Missing required fields (userId, gameName, gameUrl, initialState, activePlayers, roomId)',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const session = await createArcadeSession({
|
||||
@@ -77,7 +66,7 @@ export async function POST(request: NextRequest) {
|
||||
initialState,
|
||||
activePlayers,
|
||||
roomId,
|
||||
});
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
session: {
|
||||
@@ -88,13 +77,10 @@ export async function POST(request: NextRequest) {
|
||||
version: session.version,
|
||||
expiresAt: session.expiresAt,
|
||||
},
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error creating arcade session:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('Error creating arcade session:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,20 +90,17 @@ export async function POST(request: NextRequest) {
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const userId = request.nextUrl.searchParams.get("userId");
|
||||
const userId = request.nextUrl.searchParams.get('userId')
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "userId required" }, { status: 400 });
|
||||
return NextResponse.json({ error: 'userId required' }, { status: 400 })
|
||||
}
|
||||
|
||||
await deleteArcadeSession(userId);
|
||||
await deleteArcadeSession(userId)
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Error deleting arcade session:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('Error deleting arcade session:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
|
||||
export interface ArcadeSessionResponse {
|
||||
session: {
|
||||
currentGame: string;
|
||||
gameUrl: string;
|
||||
gameState: unknown;
|
||||
activePlayers: number[];
|
||||
version: number;
|
||||
expiresAt: Date | string;
|
||||
};
|
||||
currentGame: string
|
||||
gameUrl: string
|
||||
gameState: unknown
|
||||
activePlayers: number[]
|
||||
version: number
|
||||
expiresAt: Date | string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ArcadeSessionErrorResponse {
|
||||
error: string;
|
||||
error: string
|
||||
}
|
||||
|
||||
55
apps/web/src/app/api/arcade/invitations/pending/route.ts
Normal file
55
apps/web/src/app/api/arcade/invitations/pending/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* GET /api/arcade/invitations/pending
|
||||
* Get all pending invitations for the current user with room details
|
||||
* Excludes invitations for rooms where the user is currently banned
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Get pending invitations with room details
|
||||
const invitations = await db
|
||||
.select({
|
||||
id: schema.roomInvitations.id,
|
||||
roomId: schema.roomInvitations.roomId,
|
||||
roomName: schema.arcadeRooms.name,
|
||||
roomGameName: schema.arcadeRooms.gameName,
|
||||
userId: schema.roomInvitations.userId,
|
||||
userName: schema.roomInvitations.userName,
|
||||
invitedBy: schema.roomInvitations.invitedBy,
|
||||
invitedByName: schema.roomInvitations.invitedByName,
|
||||
status: schema.roomInvitations.status,
|
||||
invitationType: schema.roomInvitations.invitationType,
|
||||
message: schema.roomInvitations.message,
|
||||
createdAt: schema.roomInvitations.createdAt,
|
||||
expiresAt: schema.roomInvitations.expiresAt,
|
||||
})
|
||||
.from(schema.roomInvitations)
|
||||
.innerJoin(schema.arcadeRooms, eq(schema.roomInvitations.roomId, schema.arcadeRooms.id))
|
||||
.where(eq(schema.roomInvitations.userId, viewerId))
|
||||
.orderBy(schema.roomInvitations.createdAt)
|
||||
|
||||
// Get all active bans for this user (bans are deleted when unbanned, so any existing ban is active)
|
||||
const activeBans = await db
|
||||
.select({ roomId: schema.roomBans.roomId })
|
||||
.from(schema.roomBans)
|
||||
.where(eq(schema.roomBans.userId, viewerId))
|
||||
|
||||
const bannedRoomIds = new Set(activeBans.map((ban) => ban.roomId))
|
||||
|
||||
// Filter to only pending invitations, excluding banned rooms
|
||||
const pendingInvitations = invitations.filter(
|
||||
(inv) => inv.status === 'pending' && !bannedRoomIds.has(inv.roomId)
|
||||
)
|
||||
|
||||
return NextResponse.json({ invitations: pendingInvitations }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to get pending invitations:', error)
|
||||
return NextResponse.json({ error: 'Failed to get pending invitations' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
223
apps/web/src/app/api/arcade/rooms/[roomId]/ban/route.ts
Normal file
223
apps/web/src/app/api/arcade/rooms/[roomId]/ban/route.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { banUserFromRoom, getRoomBans, unbanUserFromRoom } from '@/lib/arcade/room-moderation'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getUserRoomHistory } from '@/lib/arcade/room-member-history'
|
||||
import { createInvitation } from '@/lib/arcade/room-invitations'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/arcade/rooms/:roomId/ban
|
||||
* Ban a user from the room (host only)
|
||||
* Body:
|
||||
* - userId: string
|
||||
* - reason: string (enum)
|
||||
* - notes?: string (optional)
|
||||
*/
|
||||
export async function POST(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.userId || !body.reason) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: userId, reason' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate reason
|
||||
const validReasons = ['harassment', 'cheating', 'inappropriate-name', 'spam', 'afk', 'other']
|
||||
if (!validReasons.includes(body.reason)) {
|
||||
return NextResponse.json({ error: 'Invalid reason' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if user is the host
|
||||
const members = await getRoomMembers(roomId)
|
||||
const currentMember = members.find((m) => m.userId === viewerId)
|
||||
|
||||
if (!currentMember) {
|
||||
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!currentMember.isCreator) {
|
||||
return NextResponse.json({ error: 'Only the host can ban users' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Can't ban yourself
|
||||
if (body.userId === viewerId) {
|
||||
return NextResponse.json({ error: 'Cannot ban yourself' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get the user to ban (they might not be in the room anymore)
|
||||
const targetUser = members.find((m) => m.userId === body.userId)
|
||||
const userName = targetUser?.displayName || body.userId.slice(-4)
|
||||
|
||||
// Ban the user
|
||||
await banUserFromRoom({
|
||||
roomId,
|
||||
userId: body.userId,
|
||||
userName,
|
||||
bannedBy: viewerId,
|
||||
bannedByName: currentMember.displayName,
|
||||
reason: body.reason,
|
||||
notes: body.notes,
|
||||
})
|
||||
|
||||
// Broadcast updates via socket
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
// Get updated member list
|
||||
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
|
||||
}
|
||||
|
||||
// Tell the banned user they've been removed
|
||||
io.to(`user:${body.userId}`).emit('banned-from-room', {
|
||||
roomId,
|
||||
bannedBy: currentMember.displayName,
|
||||
reason: body.reason,
|
||||
})
|
||||
|
||||
// Notify everyone else in the room
|
||||
io.to(`room:${roomId}`).emit('member-left', {
|
||||
roomId,
|
||||
userId: body.userId,
|
||||
members: updatedMembers,
|
||||
memberPlayers: memberPlayersObj,
|
||||
reason: 'banned',
|
||||
})
|
||||
|
||||
console.log(`[Ban API] User ${body.userId} banned from room ${roomId}`)
|
||||
} catch (socketError) {
|
||||
console.error('[Ban API] Failed to broadcast ban:', socketError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to ban user:', error)
|
||||
return NextResponse.json({ error: 'Failed to ban user' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/arcade/rooms/:roomId/ban
|
||||
* Unban a user from the room (host only)
|
||||
* Body:
|
||||
* - userId: string
|
||||
*/
|
||||
export async function DELETE(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.userId) {
|
||||
return NextResponse.json({ error: 'Missing required field: userId' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if user is the host
|
||||
const members = await getRoomMembers(roomId)
|
||||
const currentMember = members.find((m) => m.userId === viewerId)
|
||||
|
||||
if (!currentMember) {
|
||||
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!currentMember.isCreator) {
|
||||
return NextResponse.json({ error: 'Only the host can unban users' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Unban the user
|
||||
await unbanUserFromRoom(roomId, body.userId)
|
||||
|
||||
// Auto-invite the unbanned user back to the room
|
||||
const history = await getUserRoomHistory(roomId, body.userId)
|
||||
if (history) {
|
||||
const invitation = await createInvitation({
|
||||
roomId,
|
||||
userId: body.userId,
|
||||
userName: history.displayName,
|
||||
invitedBy: viewerId,
|
||||
invitedByName: currentMember.displayName,
|
||||
invitationType: 'auto-unban',
|
||||
message: 'You have been unbanned and are welcome to rejoin.',
|
||||
})
|
||||
|
||||
// Broadcast invitation via socket
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
io.to(`user:${body.userId}`).emit('room-invitation-received', {
|
||||
invitation: {
|
||||
id: invitation.id,
|
||||
roomId: invitation.roomId,
|
||||
invitedBy: invitation.invitedBy,
|
||||
invitedByName: invitation.invitedByName,
|
||||
message: invitation.message,
|
||||
createdAt: invitation.createdAt,
|
||||
invitationType: 'auto-unban',
|
||||
},
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[Unban API] Auto-invited user ${body.userId} after unban from room ${roomId}`
|
||||
)
|
||||
} catch (socketError) {
|
||||
console.error('[Unban API] Failed to broadcast invitation:', socketError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to unban user:', error)
|
||||
return NextResponse.json({ error: 'Failed to unban user' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/arcade/rooms/:roomId/ban
|
||||
* Get all bans for a room (host only)
|
||||
*/
|
||||
export async function GET(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Check if user is the host
|
||||
const members = await getRoomMembers(roomId)
|
||||
const currentMember = members.find((m) => m.userId === viewerId)
|
||||
|
||||
if (!currentMember) {
|
||||
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!currentMember.isCreator) {
|
||||
return NextResponse.json({ error: 'Only the host can view bans' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get all bans
|
||||
const bans = await getRoomBans(roomId)
|
||||
|
||||
return NextResponse.json({ bans }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to get bans:', error)
|
||||
return NextResponse.json({ error: 'Failed to get bans' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
40
apps/web/src/app/api/arcade/rooms/[roomId]/history/route.ts
Normal file
40
apps/web/src/app/api/arcade/rooms/[roomId]/history/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getRoomHistoricalMembersWithStatus } from '@/lib/arcade/room-member-history'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/arcade/rooms/:roomId/history
|
||||
* Get all historical members with their current status (host only)
|
||||
* Returns: array of historical members with status info
|
||||
*/
|
||||
export async function GET(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Check if user is the host
|
||||
const members = await getRoomMembers(roomId)
|
||||
const currentMember = members.find((m) => m.userId === viewerId)
|
||||
|
||||
if (!currentMember) {
|
||||
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!currentMember.isCreator) {
|
||||
return NextResponse.json({ error: 'Only the host can view room history' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get all historical members with status
|
||||
const historicalMembers = await getRoomHistoricalMembersWithStatus(roomId)
|
||||
|
||||
return NextResponse.json({ historicalMembers }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to get room history:', error)
|
||||
return NextResponse.json({ error: 'Failed to get room history' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
175
apps/web/src/app/api/arcade/rooms/[roomId]/invite/route.ts
Normal file
175
apps/web/src/app/api/arcade/rooms/[roomId]/invite/route.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
createInvitation,
|
||||
declineInvitation,
|
||||
getInvitation,
|
||||
getRoomInvitations,
|
||||
} from '@/lib/arcade/room-invitations'
|
||||
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 }>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/arcade/rooms/:roomId/invite
|
||||
* Send an invitation to a user (host only)
|
||||
* Body:
|
||||
* - userId: string
|
||||
* - userName: string
|
||||
* - message?: string (optional)
|
||||
*/
|
||||
export async function POST(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.userId || !body.userName) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: userId, userName' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
if (!currentMember) {
|
||||
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!currentMember.isCreator) {
|
||||
return NextResponse.json({ error: 'Only the host can send invitations' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Can't invite yourself
|
||||
if (body.userId === viewerId) {
|
||||
return NextResponse.json({ error: 'Cannot invite yourself' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Can't invite someone who's already in the room
|
||||
const targetUser = members.find((m) => m.userId === body.userId)
|
||||
if (targetUser) {
|
||||
return NextResponse.json({ error: 'User is already in this room' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Create invitation
|
||||
const invitation = await createInvitation({
|
||||
roomId,
|
||||
userId: body.userId,
|
||||
userName: body.userName,
|
||||
invitedBy: viewerId,
|
||||
invitedByName: currentMember.displayName,
|
||||
invitationType: 'manual',
|
||||
message: body.message,
|
||||
})
|
||||
|
||||
// Broadcast invitation via socket
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
// Send to the invited user's channel
|
||||
io.to(`user:${body.userId}`).emit('room-invitation-received', {
|
||||
invitation: {
|
||||
id: invitation.id,
|
||||
roomId: invitation.roomId,
|
||||
invitedBy: invitation.invitedBy,
|
||||
invitedByName: invitation.invitedByName,
|
||||
message: invitation.message,
|
||||
createdAt: invitation.createdAt,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`[Invite API] Sent invitation to user ${body.userId} for room ${roomId}`)
|
||||
} catch (socketError) {
|
||||
console.error('[Invite API] Failed to broadcast invitation:', socketError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ invitation }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to send invitation:', error)
|
||||
return NextResponse.json({ error: 'Failed to send invitation' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/arcade/rooms/:roomId/invite
|
||||
* Get all invitations for a room (host only)
|
||||
*/
|
||||
export async function GET(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Check if user is the host
|
||||
const members = await getRoomMembers(roomId)
|
||||
const currentMember = members.find((m) => m.userId === viewerId)
|
||||
|
||||
if (!currentMember) {
|
||||
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!currentMember.isCreator) {
|
||||
return NextResponse.json({ error: 'Only the host can view invitations' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get all invitations
|
||||
const invitations = await getRoomInvitations(roomId)
|
||||
|
||||
return NextResponse.json({ invitations }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to get invitations:', error)
|
||||
return NextResponse.json({ error: 'Failed to get invitations' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/arcade/rooms/:roomId/invite
|
||||
* Decline an invitation (invited user only)
|
||||
*/
|
||||
export async function DELETE(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Check if there's an invitation for this user
|
||||
const invitation = await getInvitation(roomId, viewerId)
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json({ error: 'No invitation found for this room' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (invitation.status !== 'pending') {
|
||||
return NextResponse.json({ error: 'Invitation is not pending' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Decline the invitation
|
||||
await declineInvitation(invitation.id)
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to decline invitation:', error)
|
||||
return NextResponse.json({ error: 'Failed to decline invitation' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
101
apps/web/src/app/api/arcade/rooms/[roomId]/join-request/route.ts
Normal file
101
apps/web/src/app/api/arcade/rooms/[roomId]/join-request/route.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { createJoinRequest, getJoinRequest } from '@/lib/arcade/room-join-requests'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/arcade/rooms/:roomId/join-request
|
||||
* Request to join an approval-only room
|
||||
* Body:
|
||||
* - userName: string
|
||||
*/
|
||||
export async function POST(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.userName) {
|
||||
return NextResponse.json({ error: 'Missing required field: userName' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get room details
|
||||
const [room] = await db
|
||||
.select()
|
||||
.from(schema.arcadeRooms)
|
||||
.where(eq(schema.arcadeRooms.id, roomId))
|
||||
.limit(1)
|
||||
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if room is approval-only
|
||||
if (room.accessMode !== 'approval-only') {
|
||||
return NextResponse.json(
|
||||
{ error: 'This room does not require approval to join' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user is already in the room
|
||||
const members = await getRoomMembers(roomId)
|
||||
const existingMember = members.find((m) => m.userId === viewerId)
|
||||
if (existingMember) {
|
||||
return NextResponse.json({ error: 'You are already in this room' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if user already has a pending request
|
||||
const existingRequest = await getJoinRequest(roomId, viewerId)
|
||||
if (existingRequest && existingRequest.status === 'pending') {
|
||||
return NextResponse.json(
|
||||
{ error: 'You already have a pending join request' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create join request
|
||||
const request = await createJoinRequest({
|
||||
roomId,
|
||||
userId: viewerId,
|
||||
userName: body.userName,
|
||||
})
|
||||
|
||||
// Broadcast to host via socket
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
// Get host user ID
|
||||
const host = members.find((m) => m.isCreator)
|
||||
if (host) {
|
||||
io.to(`user:${host.userId}`).emit('join-request-received', {
|
||||
roomId,
|
||||
request: {
|
||||
id: request.id,
|
||||
userId: request.userId,
|
||||
userName: request.userName,
|
||||
requestedAt: request.requestedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`[Join Request API] User ${viewerId} requested to join room ${roomId}`)
|
||||
} catch (socketError) {
|
||||
console.error('[Join Request API] Failed to broadcast request:', socketError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ request }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create join request:', error)
|
||||
return NextResponse.json({ error: 'Failed to create join request' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { approveJoinRequest } from '@/lib/arcade/room-join-requests'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string; requestId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/arcade/rooms/:roomId/join-requests/:requestId/approve
|
||||
* Approve a join request (host only)
|
||||
*/
|
||||
export async function POST(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId, requestId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Check if user is the host
|
||||
const members = await getRoomMembers(roomId)
|
||||
const currentMember = members.find((m) => m.userId === viewerId)
|
||||
|
||||
if (!currentMember) {
|
||||
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!currentMember.isCreator) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only the host can approve join requests' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get the request
|
||||
const [request] = await db
|
||||
.select()
|
||||
.from(schema.roomJoinRequests)
|
||||
.where(eq(schema.roomJoinRequests.id, requestId))
|
||||
.limit(1)
|
||||
|
||||
if (!request) {
|
||||
return NextResponse.json({ error: 'Join request not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (request.status !== 'pending') {
|
||||
return NextResponse.json({ error: 'Join request is not pending' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Approve the request
|
||||
const approvedRequest = await approveJoinRequest(requestId, viewerId, currentMember.displayName)
|
||||
|
||||
// Notify the requesting user via socket
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
io.to(`user:${request.userId}`).emit('join-request-approved', {
|
||||
roomId,
|
||||
requestId,
|
||||
approvedBy: currentMember.displayName,
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[Approve Join Request API] Request ${requestId} approved for user ${request.userId} to join room ${roomId}`
|
||||
)
|
||||
} catch (socketError) {
|
||||
console.error('[Approve Join Request API] Failed to broadcast approval:', socketError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ request: approvedRequest }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to approve join request:', error)
|
||||
return NextResponse.json({ error: 'Failed to approve join request' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { denyJoinRequest } from '@/lib/arcade/room-join-requests'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string; requestId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/arcade/rooms/:roomId/join-requests/:requestId/deny
|
||||
* Deny a join request (host only)
|
||||
*/
|
||||
export async function POST(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId, requestId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Check if user is the host
|
||||
const members = await getRoomMembers(roomId)
|
||||
const currentMember = members.find((m) => m.userId === viewerId)
|
||||
|
||||
if (!currentMember) {
|
||||
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!currentMember.isCreator) {
|
||||
return NextResponse.json({ error: 'Only the host can deny join requests' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get the request
|
||||
const [request] = await db
|
||||
.select()
|
||||
.from(schema.roomJoinRequests)
|
||||
.where(eq(schema.roomJoinRequests.id, requestId))
|
||||
.limit(1)
|
||||
|
||||
if (!request) {
|
||||
return NextResponse.json({ error: 'Join request not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (request.status !== 'pending') {
|
||||
return NextResponse.json({ error: 'Join request is not pending' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Deny the request
|
||||
const deniedRequest = await denyJoinRequest(requestId, viewerId, currentMember.displayName)
|
||||
|
||||
// Notify the requesting user via socket
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
io.to(`user:${request.userId}`).emit('join-request-denied', {
|
||||
roomId,
|
||||
requestId,
|
||||
deniedBy: currentMember.displayName,
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[Deny Join Request API] Request ${requestId} denied for user ${request.userId} to join room ${roomId}`
|
||||
)
|
||||
} catch (socketError) {
|
||||
console.error('[Deny Join Request API] Failed to broadcast denial:', socketError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ request: deniedRequest }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to deny join request:', error)
|
||||
return NextResponse.json({ error: 'Failed to deny join request' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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 { getSocketIO } from '@/lib/socket-io'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/arcade/rooms/:roomId/join-requests
|
||||
* Get all pending join requests for a room (host only)
|
||||
*/
|
||||
export async function GET(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Check if user is the host
|
||||
const members = await getRoomMembers(roomId)
|
||||
const currentMember = members.find((m) => m.userId === viewerId)
|
||||
|
||||
if (!currentMember) {
|
||||
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!currentMember.isCreator) {
|
||||
return NextResponse.json({ error: 'Only the host can view join requests' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get all pending requests
|
||||
const requests = await getPendingJoinRequests(roomId)
|
||||
|
||||
return NextResponse.json({ requests }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to get join requests:', error)
|
||||
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,49 +1,130 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getRoomById, touchRoom } from "@/lib/arcade/room-manager";
|
||||
import { addRoomMember, getRoomMembers } from "@/lib/arcade/room-membership";
|
||||
import {
|
||||
getActivePlayers,
|
||||
getRoomActivePlayers,
|
||||
} from "@/lib/arcade/player-manager";
|
||||
import { getViewerId } from "@/lib/viewer";
|
||||
import { getSocketIO } from "@/lib/socket-io";
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getActivePlayers, getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getInvitation } from '@/lib/arcade/room-invitations'
|
||||
import { getJoinRequest } from '@/lib/arcade/room-join-requests'
|
||||
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 { getViewerId } from '@/lib/viewer'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>;
|
||||
};
|
||||
params: Promise<{ roomId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/arcade/rooms/:roomId/join
|
||||
* Join a room
|
||||
* Body:
|
||||
* - displayName?: string (optional, will generate from viewerId if not provided)
|
||||
* - password?: string (required for password-protected rooms)
|
||||
*/
|
||||
export async function POST(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params;
|
||||
const viewerId = await getViewerId();
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json().catch(() => ({}))
|
||||
|
||||
// Get room
|
||||
const room = await getRoomById(roomId);
|
||||
const room = await getRoomById(roomId)
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: "Room not found" }, { status: 404 });
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if room is locked
|
||||
if (room.isLocked) {
|
||||
return NextResponse.json({ error: "Room is locked" }, { status: 403 });
|
||||
// Check if user is banned
|
||||
const banned = await isUserBanned(roomId, viewerId)
|
||||
if (banned) {
|
||||
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':
|
||||
// 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':
|
||||
// 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) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Password required to join this room' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
if (!room.password) {
|
||||
return NextResponse.json({ error: 'Room password not configured' }, { status: 500 })
|
||||
}
|
||||
const passwordMatch = await bcrypt.compare(body.password, room.password)
|
||||
if (!passwordMatch) {
|
||||
return NextResponse.json({ error: 'Incorrect password' }, { status: 401 })
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'restricted': {
|
||||
// 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': {
|
||||
// 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
|
||||
}
|
||||
default:
|
||||
// No additional checks needed
|
||||
break
|
||||
}
|
||||
|
||||
// Get or generate display name
|
||||
const displayName = body.displayName || `Guest ${viewerId.slice(-4)}`;
|
||||
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 },
|
||||
);
|
||||
{ error: 'Display name too long (max 50 characters)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Add member (with auto-leave logic for modal room enforcement)
|
||||
@@ -52,44 +133,39 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
userId: viewerId,
|
||||
displayName,
|
||||
isCreator: false,
|
||||
});
|
||||
})
|
||||
|
||||
// Fetch user's active players (these will participate in the game)
|
||||
const activePlayers = await getActivePlayers(viewerId);
|
||||
const activePlayers = await getActivePlayers(viewerId)
|
||||
|
||||
// Update room activity to refresh TTL
|
||||
await touchRoom(roomId);
|
||||
await touchRoom(roomId)
|
||||
|
||||
// Broadcast to all users in the room via socket
|
||||
const io = await getSocketIO();
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
const members = await getRoomMembers(roomId);
|
||||
const memberPlayers = await getRoomActivePlayers(roomId);
|
||||
const members = await getRoomMembers(roomId)
|
||||
const memberPlayers = await getRoomActivePlayers(roomId)
|
||||
|
||||
// Convert memberPlayers Map to object for JSON serialization
|
||||
const memberPlayersObj: Record<string, any[]> = {};
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players;
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
// Broadcast to all users in this room
|
||||
io.to(`room:${roomId}`).emit("member-joined", {
|
||||
io.to(`room:${roomId}`).emit('member-joined', {
|
||||
roomId,
|
||||
userId: viewerId,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
});
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[Join API] Broadcasted member-joined for user ${viewerId} in room ${roomId}`,
|
||||
);
|
||||
console.log(`[Join API] Broadcasted member-joined for user ${viewerId} in room ${roomId}`)
|
||||
} catch (socketError) {
|
||||
// Log but don't fail the request if socket broadcast fails
|
||||
console.error(
|
||||
"[Join API] Failed to broadcast member-joined:",
|
||||
socketError,
|
||||
);
|
||||
console.error('[Join API] Failed to broadcast member-joined:', socketError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,27 +183,27 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error: any) {
|
||||
console.error("Failed to join room:", error);
|
||||
console.error('Failed to join room:', error)
|
||||
|
||||
// Handle specific constraint violation error
|
||||
if (error.message?.includes("ROOM_MEMBERSHIP_CONFLICT")) {
|
||||
if (error.message?.includes('ROOM_MEMBERSHIP_CONFLICT')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "You are already in another room",
|
||||
code: "ROOM_MEMBERSHIP_CONFLICT",
|
||||
error: 'You are already in another room',
|
||||
code: 'ROOM_MEMBERSHIP_CONFLICT',
|
||||
message:
|
||||
"You can only be in one room at a time. Please leave your current room before joining a new one.",
|
||||
'You can only be in one room at a time. Please leave your current room before joining a new one.',
|
||||
userMessage:
|
||||
"⚠️ Already in Another Room\n\nYou can only be in one room at a time. Please refresh the page and try again.",
|
||||
'⚠️ Already in Another Room\n\nYou can only be in one room at a time. Please refresh the page and try again.',
|
||||
},
|
||||
{ status: 409 }, // 409 Conflict
|
||||
);
|
||||
{ status: 409 } // 409 Conflict
|
||||
)
|
||||
}
|
||||
|
||||
// Generic error
|
||||
return NextResponse.json({ error: "Failed to join room" }, { status: 500 });
|
||||
return NextResponse.json({ error: 'Failed to join room' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
95
apps/web/src/app/api/arcade/rooms/[roomId]/kick/route.ts
Normal file
95
apps/web/src/app/api/arcade/rooms/[roomId]/kick/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { kickUserFromRoom } from '@/lib/arcade/room-moderation'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/arcade/rooms/:roomId/kick
|
||||
* Kick a user from the room (host only)
|
||||
* Body:
|
||||
* - userId: string
|
||||
*/
|
||||
export async function POST(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.userId) {
|
||||
return NextResponse.json({ error: 'Missing required field: userId' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if user is the host
|
||||
const members = await getRoomMembers(roomId)
|
||||
const currentMember = members.find((m) => m.userId === viewerId)
|
||||
|
||||
if (!currentMember) {
|
||||
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!currentMember.isCreator) {
|
||||
return NextResponse.json({ error: 'Only the host can kick users' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Can't kick yourself
|
||||
if (body.userId === viewerId) {
|
||||
return NextResponse.json({ error: 'Cannot kick yourself' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify the user to kick is in the room
|
||||
const targetUser = members.find((m) => m.userId === body.userId)
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User is not in this room' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Kick the user
|
||||
await kickUserFromRoom(roomId, body.userId)
|
||||
|
||||
// Broadcast updates via socket
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
// Get updated member list
|
||||
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
|
||||
}
|
||||
|
||||
// Tell the kicked user they've been removed
|
||||
io.to(`user:${body.userId}`).emit('kicked-from-room', {
|
||||
roomId,
|
||||
kickedBy: currentMember.displayName,
|
||||
})
|
||||
|
||||
// Notify everyone else in the room
|
||||
io.to(`room:${roomId}`).emit('member-left', {
|
||||
roomId,
|
||||
userId: body.userId,
|
||||
members: updatedMembers,
|
||||
memberPlayers: memberPlayersObj,
|
||||
reason: 'kicked',
|
||||
})
|
||||
|
||||
console.log(`[Kick API] User ${body.userId} kicked from room ${roomId}`)
|
||||
} catch (socketError) {
|
||||
console.error('[Kick API] Failed to broadcast kick:', socketError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to kick user:', error)
|
||||
return NextResponse.json({ error: 'Failed to kick user' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,13 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getRoomById } from "@/lib/arcade/room-manager";
|
||||
import {
|
||||
getRoomMembers,
|
||||
isMember,
|
||||
removeMember,
|
||||
} from "@/lib/arcade/room-membership";
|
||||
import { getRoomActivePlayers } from "@/lib/arcade/player-manager";
|
||||
import { getViewerId } from "@/lib/viewer";
|
||||
import { getSocketIO } from "@/lib/socket-io";
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getRoomById } from '@/lib/arcade/room-manager'
|
||||
import { getRoomMembers, isMember, removeMember } from '@/lib/arcade/room-membership'
|
||||
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>;
|
||||
};
|
||||
params: Promise<{ roomId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/arcade/rooms/:roomId/leave
|
||||
@@ -19,66 +15,55 @@ type RouteContext = {
|
||||
*/
|
||||
export async function POST(_req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params;
|
||||
const viewerId = await getViewerId();
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Get room
|
||||
const room = await getRoomById(roomId);
|
||||
const room = await getRoomById(roomId)
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: "Room not found" }, { status: 404 });
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if member
|
||||
const isMemberOfRoom = await isMember(roomId, viewerId);
|
||||
const isMemberOfRoom = await isMember(roomId, viewerId)
|
||||
if (!isMemberOfRoom) {
|
||||
return NextResponse.json(
|
||||
{ error: "Not a member of this room" },
|
||||
{ status: 400 },
|
||||
);
|
||||
return NextResponse.json({ error: 'Not a member of this room' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Remove member
|
||||
await removeMember(roomId, viewerId);
|
||||
await removeMember(roomId, viewerId)
|
||||
|
||||
// Broadcast to all remaining users in the room via socket
|
||||
const io = await getSocketIO();
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
const members = await getRoomMembers(roomId);
|
||||
const memberPlayers = await getRoomActivePlayers(roomId);
|
||||
const members = await getRoomMembers(roomId)
|
||||
const memberPlayers = await getRoomActivePlayers(roomId)
|
||||
|
||||
// Convert memberPlayers Map to object for JSON serialization
|
||||
const memberPlayersObj: Record<string, any[]> = {};
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players;
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
// Broadcast to all users in this room
|
||||
io.to(`room:${roomId}`).emit("member-left", {
|
||||
io.to(`room:${roomId}`).emit('member-left', {
|
||||
roomId,
|
||||
userId: viewerId,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
});
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[Leave API] Broadcasted member-left for user ${viewerId} in room ${roomId}`,
|
||||
);
|
||||
console.log(`[Leave API] Broadcasted member-left for user ${viewerId} in room ${roomId}`)
|
||||
} catch (socketError) {
|
||||
// Log but don't fail the request if socket broadcast fails
|
||||
console.error(
|
||||
"[Leave API] Failed to broadcast member-left:",
|
||||
socketError,
|
||||
);
|
||||
console.error('[Leave API] Failed to broadcast member-left:', socketError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Failed to leave room:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to leave room" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('Failed to leave room:', error)
|
||||
return NextResponse.json({ error: 'Failed to leave room' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getRoomById, isRoomCreator } from "@/lib/arcade/room-manager";
|
||||
import { isMember, removeMember } from "@/lib/arcade/room-membership";
|
||||
import { getViewerId } from "@/lib/viewer";
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getRoomById, isRoomCreator } from '@/lib/arcade/room-manager'
|
||||
import { isMember, removeMember } from '@/lib/arcade/room-membership'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string; userId: string }>;
|
||||
};
|
||||
params: Promise<{ roomId: string; userId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/arcade/rooms/:roomId/members/:userId
|
||||
@@ -13,50 +13,38 @@ type RouteContext = {
|
||||
*/
|
||||
export async function DELETE(_req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId, userId } = await context.params;
|
||||
const viewerId = await getViewerId();
|
||||
const { roomId, userId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Get room
|
||||
const room = await getRoomById(roomId);
|
||||
const room = await getRoomById(roomId)
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: "Room not found" }, { status: 404 });
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if requester is room creator
|
||||
const isCreator = await isRoomCreator(roomId, viewerId);
|
||||
const isCreator = await isRoomCreator(roomId, viewerId)
|
||||
if (!isCreator) {
|
||||
return NextResponse.json(
|
||||
{ error: "Only room creator can kick members" },
|
||||
{ status: 403 },
|
||||
);
|
||||
return NextResponse.json({ error: 'Only room creator can kick members' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Cannot kick self
|
||||
if (userId === viewerId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cannot kick yourself" },
|
||||
{ status: 400 },
|
||||
);
|
||||
return NextResponse.json({ error: 'Cannot kick yourself' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if target user is a member
|
||||
const isTargetMember = await isMember(roomId, userId);
|
||||
const isTargetMember = await isMember(roomId, userId)
|
||||
if (!isTargetMember) {
|
||||
return NextResponse.json(
|
||||
{ error: "User is not a member of this room" },
|
||||
{ status: 404 },
|
||||
);
|
||||
return NextResponse.json({ error: 'User is not a member of this room' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Remove member
|
||||
await removeMember(roomId, userId);
|
||||
await removeMember(roomId, userId)
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Failed to kick member:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to kick member" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('Failed to kick member:', error)
|
||||
return NextResponse.json({ error: 'Failed to kick member' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getRoomById } from "@/lib/arcade/room-manager";
|
||||
import {
|
||||
getOnlineMemberCount,
|
||||
getRoomMembers,
|
||||
} from "@/lib/arcade/room-membership";
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getRoomById } from '@/lib/arcade/room-manager'
|
||||
import { getOnlineMemberCount, getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>;
|
||||
};
|
||||
params: Promise<{ roomId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/arcade/rooms/:roomId/members
|
||||
@@ -15,27 +12,24 @@ type RouteContext = {
|
||||
*/
|
||||
export async function GET(_req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params;
|
||||
const { roomId } = await context.params
|
||||
|
||||
// Get room
|
||||
const room = await getRoomById(roomId);
|
||||
const room = await getRoomById(roomId)
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: "Room not found" }, { status: 404 });
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get members
|
||||
const members = await getRoomMembers(roomId);
|
||||
const onlineCount = await getOnlineMemberCount(roomId);
|
||||
const members = await getRoomMembers(roomId)
|
||||
const onlineCount = await getOnlineMemberCount(roomId)
|
||||
|
||||
return NextResponse.json({
|
||||
members,
|
||||
onlineCount,
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch members:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch members" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('Failed to fetch members:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch members' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
97
apps/web/src/app/api/arcade/rooms/[roomId]/report/route.ts
Normal file
97
apps/web/src/app/api/arcade/rooms/[roomId]/report/route.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createReport } from '@/lib/arcade/room-moderation'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/arcade/rooms/:roomId/report
|
||||
* Submit a report about another player
|
||||
* Body:
|
||||
* - reportedUserId: string
|
||||
* - reason: string (enum)
|
||||
* - details?: string (optional)
|
||||
*/
|
||||
export async function POST(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.reportedUserId || !body.reason) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: reportedUserId, reason' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate reason
|
||||
const validReasons = ['harassment', 'cheating', 'inappropriate-name', 'spam', 'afk', 'other']
|
||||
if (!validReasons.includes(body.reason)) {
|
||||
return NextResponse.json({ error: 'Invalid reason' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Can't report yourself
|
||||
if (body.reportedUserId === viewerId) {
|
||||
return NextResponse.json({ error: 'Cannot report yourself' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get room members to verify both users are in the room and get names
|
||||
const members = await getRoomMembers(roomId)
|
||||
const reporter = members.find((m) => m.userId === viewerId)
|
||||
const reported = members.find((m) => m.userId === body.reportedUserId)
|
||||
|
||||
if (!reporter) {
|
||||
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!reported) {
|
||||
return NextResponse.json({ error: 'Reported user is not in this room' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Create report
|
||||
const report = await createReport({
|
||||
roomId,
|
||||
reporterId: viewerId,
|
||||
reporterName: reporter.displayName,
|
||||
reportedUserId: body.reportedUserId,
|
||||
reportedUserName: reported.displayName,
|
||||
reason: body.reason,
|
||||
details: body.details,
|
||||
})
|
||||
|
||||
// Notify host via socket (find the host)
|
||||
const host = members.find((m) => m.isCreator)
|
||||
if (host) {
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
// Send notification only to the host
|
||||
io.to(`user:${host.userId}`).emit('report-submitted', {
|
||||
roomId,
|
||||
report: {
|
||||
id: report.id,
|
||||
reporterName: report.reporterName,
|
||||
reportedUserName: report.reportedUserName,
|
||||
reportedUserId: report.reportedUserId,
|
||||
reason: report.reason,
|
||||
createdAt: report.createdAt,
|
||||
},
|
||||
})
|
||||
} catch (socketError) {
|
||||
console.error('[Report API] Failed to notify host:', socketError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, report }, { status: 201 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to submit report:', error)
|
||||
return NextResponse.json({ error: 'Failed to submit report' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
39
apps/web/src/app/api/arcade/rooms/[roomId]/reports/route.ts
Normal file
39
apps/web/src/app/api/arcade/rooms/[roomId]/reports/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getAllReports } from '@/lib/arcade/room-moderation'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/arcade/rooms/:roomId/reports
|
||||
* Get all reports for a room (host only)
|
||||
*/
|
||||
export async function GET(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Check if user is the host
|
||||
const members = await getRoomMembers(roomId)
|
||||
const currentMember = members.find((m) => m.userId === viewerId)
|
||||
|
||||
if (!currentMember) {
|
||||
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!currentMember.isCreator) {
|
||||
return NextResponse.json({ error: 'Only the host can view reports' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get all reports
|
||||
const reports = await getAllReports(roomId)
|
||||
|
||||
return NextResponse.json({ reports }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to get reports:', error)
|
||||
return NextResponse.json({ error: 'Failed to get reports' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
deleteRoom,
|
||||
getRoomById,
|
||||
isRoomCreator,
|
||||
touchRoom,
|
||||
updateRoom,
|
||||
} from "@/lib/arcade/room-manager";
|
||||
import { getRoomMembers } from "@/lib/arcade/room-membership";
|
||||
import { getActivePlayers } from "@/lib/arcade/player-manager";
|
||||
import { getViewerId } from "@/lib/viewer";
|
||||
} from '@/lib/arcade/room-manager'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>;
|
||||
};
|
||||
params: Promise<{ roomId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/arcade/rooms/:roomId
|
||||
@@ -20,40 +20,42 @@ type RouteContext = {
|
||||
*/
|
||||
export async function GET(_req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params;
|
||||
const viewerId = await getViewerId();
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
const room = await getRoomById(roomId);
|
||||
const room = await getRoomById(roomId)
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: "Room not found" }, { status: 404 });
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const members = await getRoomMembers(roomId);
|
||||
const canModerate = await isRoomCreator(roomId, viewerId);
|
||||
const members = await getRoomMembers(roomId)
|
||||
const canModerate = await isRoomCreator(roomId, viewerId)
|
||||
|
||||
// Fetch active players for each member
|
||||
// This creates a map of userId -> Player[]
|
||||
const memberPlayers: Record<string, any[]> = {};
|
||||
const memberPlayers: Record<string, any[]> = {}
|
||||
for (const member of members) {
|
||||
const activePlayers = await getActivePlayers(member.userId);
|
||||
memberPlayers[member.userId] = activePlayers;
|
||||
const activePlayers = await getActivePlayers(member.userId)
|
||||
memberPlayers[member.userId] = activePlayers
|
||||
}
|
||||
|
||||
// Update room activity when viewing (keeps active rooms fresh)
|
||||
await touchRoom(roomId);
|
||||
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,
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch room:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch room" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('Failed to fetch room:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch room' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,63 +64,50 @@ export async function GET(_req: NextRequest, context: RouteContext) {
|
||||
* Update room (creator only)
|
||||
* Body:
|
||||
* - name?: string
|
||||
* - isLocked?: boolean
|
||||
* - status?: 'lobby' | 'playing' | 'finished'
|
||||
*
|
||||
* Note: For access control (accessMode, password), use PATCH /api/arcade/rooms/:roomId/settings
|
||||
*/
|
||||
export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params;
|
||||
const viewerId = await getViewerId();
|
||||
const body = await req.json();
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Check if user is room creator
|
||||
const isCreator = await isRoomCreator(roomId, viewerId);
|
||||
const isCreator = await isRoomCreator(roomId, viewerId)
|
||||
if (!isCreator) {
|
||||
return NextResponse.json(
|
||||
{ error: "Only room creator can update room" },
|
||||
{ status: 403 },
|
||||
);
|
||||
return NextResponse.json({ error: 'Only room creator can update room' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Validate name length if provided
|
||||
if (body.name && body.name.length > 50) {
|
||||
return NextResponse.json(
|
||||
{ error: "Room name too long (max 50 characters)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
return NextResponse.json({ error: 'Room name too long (max 50 characters)' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate status if provided
|
||||
if (
|
||||
body.status &&
|
||||
!["lobby", "playing", "finished"].includes(body.status)
|
||||
) {
|
||||
return NextResponse.json({ error: "Invalid status" }, { status: 400 });
|
||||
if (body.status && !['lobby', 'playing', 'finished'].includes(body.status)) {
|
||||
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updates: {
|
||||
name?: string;
|
||||
isLocked?: boolean;
|
||||
status?: "lobby" | "playing" | "finished";
|
||||
} = {};
|
||||
name?: string
|
||||
status?: 'lobby' | 'playing' | 'finished'
|
||||
} = {}
|
||||
|
||||
if (body.name !== undefined) updates.name = body.name;
|
||||
if (body.isLocked !== undefined) updates.isLocked = body.isLocked;
|
||||
if (body.status !== undefined) updates.status = body.status;
|
||||
if (body.name !== undefined) updates.name = body.name
|
||||
if (body.status !== undefined) updates.status = body.status
|
||||
|
||||
const room = await updateRoom(roomId, updates);
|
||||
const room = await updateRoom(roomId, updates)
|
||||
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: "Room not found" }, { status: 404 });
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ room });
|
||||
return NextResponse.json({ room })
|
||||
} catch (error) {
|
||||
console.error("Failed to update room:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update room" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('Failed to update room:', error)
|
||||
return NextResponse.json({ error: 'Failed to update room' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,26 +117,20 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
*/
|
||||
export async function DELETE(_req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params;
|
||||
const viewerId = await getViewerId();
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Check if user is room creator
|
||||
const isCreator = await isRoomCreator(roomId, viewerId);
|
||||
const isCreator = await isRoomCreator(roomId, viewerId)
|
||||
if (!isCreator) {
|
||||
return NextResponse.json(
|
||||
{ error: "Only room creator can delete room" },
|
||||
{ status: 403 },
|
||||
);
|
||||
return NextResponse.json({ error: 'Only room creator can delete room' }, { status: 403 })
|
||||
}
|
||||
|
||||
await deleteRoom(roomId);
|
||||
await deleteRoom(roomId)
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Failed to delete room:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete room" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('Failed to delete room:', error)
|
||||
return NextResponse.json({ error: 'Failed to delete room' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
278
apps/web/src/app/api/arcade/rooms/[roomId]/settings/route.ts
Normal file
278
apps/web/src/app/api/arcade/rooms/[roomId]/settings/route.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
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 { getAllGameConfigs, setGameConfig } from '@/lib/arcade/game-config-helpers'
|
||||
import type { GameName } from '@/lib/arcade/validation'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/arcade/rooms/:roomId/settings
|
||||
* Update room settings (host only)
|
||||
* 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 {
|
||||
const { roomId } = await context.params
|
||||
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)
|
||||
|
||||
if (!currentMember) {
|
||||
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!currentMember.isCreator) {
|
||||
return NextResponse.json({ error: 'Only the host can change room settings' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Validate accessMode if provided
|
||||
const validAccessModes = [
|
||||
'open',
|
||||
'locked',
|
||||
'retired',
|
||||
'password',
|
||||
'restricted',
|
||||
'approval-only',
|
||||
]
|
||||
if (body.accessMode && !validAccessModes.includes(body.accessMode)) {
|
||||
return NextResponse.json({ error: 'Invalid access mode' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate password requirements
|
||||
if (body.accessMode === 'password' && !body.password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Password is required for password-protected rooms' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 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> = {}
|
||||
|
||||
if (body.accessMode !== undefined) {
|
||||
updateData.accessMode = body.accessMode
|
||||
}
|
||||
|
||||
// Hash password if provided
|
||||
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
|
||||
}
|
||||
|
||||
// Handle game config updates - write to new room_game_configs table
|
||||
if (body.gameConfig !== undefined && body.gameConfig !== null) {
|
||||
// body.gameConfig is expected to be nested by game name: { matching: {...}, memory-quiz: {...} }
|
||||
// Extract each game's config and write to the new table
|
||||
for (const [gameName, config] of Object.entries(body.gameConfig)) {
|
||||
if (config && typeof config === 'object') {
|
||||
await setGameConfig(roomId, gameName as GameName, config)
|
||||
console.log(`[Settings API] Wrote ${gameName} config to room_game_configs table`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 (only if there's something to update)
|
||||
let updatedRoom = currentRoom
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
;[updatedRoom] = await db
|
||||
.update(schema.arcadeRooms)
|
||||
.set(updateData)
|
||||
.where(eq(schema.arcadeRooms.id, roomId))
|
||||
.returning()
|
||||
}
|
||||
|
||||
// Get aggregated game configs from new table
|
||||
const gameConfig = await getAllGameConfigs(roomId)
|
||||
|
||||
console.log(
|
||||
'[Settings API] Room state in database AFTER update:',
|
||||
JSON.stringify(
|
||||
{
|
||||
gameName: updatedRoom.gameName,
|
||||
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,
|
||||
gameConfig, // Include aggregated configs from new table
|
||||
}
|
||||
|
||||
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,
|
||||
gameConfig, // Include aggregated configs from new table
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update room settings:', error)
|
||||
return NextResponse.json({ error: 'Failed to update room settings' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/arcade/rooms/:roomId/transfer-ownership
|
||||
* Transfer room ownership to another member (host only)
|
||||
* Body:
|
||||
* - newOwnerId: string
|
||||
*/
|
||||
export async function POST(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.newOwnerId) {
|
||||
return NextResponse.json({ error: 'Missing required field: newOwnerId' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if user is the current host
|
||||
const members = await getRoomMembers(roomId)
|
||||
const currentMember = members.find((m) => m.userId === viewerId)
|
||||
|
||||
if (!currentMember) {
|
||||
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!currentMember.isCreator) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only the current host can transfer ownership' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Can't transfer to yourself
|
||||
if (body.newOwnerId === viewerId) {
|
||||
return NextResponse.json({ error: 'You are already the owner' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify new owner is in the room
|
||||
const newOwner = members.find((m) => m.userId === body.newOwnerId)
|
||||
if (!newOwner) {
|
||||
return NextResponse.json({ error: 'New owner must be a member of the room' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Remove isCreator from current owner
|
||||
await db
|
||||
.update(schema.roomMembers)
|
||||
.set({ isCreator: false })
|
||||
.where(eq(schema.roomMembers.id, currentMember.id))
|
||||
|
||||
// Set isCreator on new owner
|
||||
await db
|
||||
.update(schema.roomMembers)
|
||||
.set({ isCreator: true })
|
||||
.where(eq(schema.roomMembers.id, newOwner.id))
|
||||
|
||||
// Update room createdBy field
|
||||
await db
|
||||
.update(schema.arcadeRooms)
|
||||
.set({
|
||||
createdBy: body.newOwnerId,
|
||||
creatorName: newOwner.displayName,
|
||||
})
|
||||
.where(eq(schema.arcadeRooms.id, roomId))
|
||||
|
||||
// Broadcast ownership transfer via socket
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
const updatedMembers = await getRoomMembers(roomId)
|
||||
|
||||
io.to(`room:${roomId}`).emit('ownership-transferred', {
|
||||
roomId,
|
||||
oldOwnerId: viewerId,
|
||||
newOwnerId: body.newOwnerId,
|
||||
newOwnerName: newOwner.displayName,
|
||||
members: updatedMembers,
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[Ownership Transfer] Room ${roomId} ownership transferred from ${viewerId} to ${body.newOwnerId}`
|
||||
)
|
||||
} catch (socketError) {
|
||||
console.error('[Ownership Transfer] Failed to broadcast transfer:', socketError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to transfer ownership:', error)
|
||||
return NextResponse.json({ error: 'Failed to transfer ownership' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getRoomByCode } from "@/lib/arcade/room-manager";
|
||||
import { normalizeRoomCode } from "@/lib/arcade/room-code";
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getRoomByCode } from '@/lib/arcade/room-manager'
|
||||
import { normalizeRoomCode } from '@/lib/arcade/room-code'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ code: string }>;
|
||||
};
|
||||
params: Promise<{ code: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/arcade/rooms/code/:code
|
||||
@@ -12,31 +12,28 @@ type RouteContext = {
|
||||
*/
|
||||
export async function GET(_req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { code } = await context.params;
|
||||
const { code } = await context.params
|
||||
|
||||
// Normalize the code (uppercase, remove spaces/dashes)
|
||||
const normalizedCode = normalizeRoomCode(code);
|
||||
const normalizedCode = normalizeRoomCode(code)
|
||||
|
||||
// Get room
|
||||
const room = await getRoomByCode(normalizedCode);
|
||||
const room = await getRoomByCode(normalizedCode)
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: "Room not found" }, { status: 404 });
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Generate redirect URL
|
||||
const baseUrl = process.env.NEXT_PUBLIC_URL || "http://localhost:3000";
|
||||
const redirectUrl = `${baseUrl}/arcade/rooms/${room.id}`;
|
||||
const baseUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:3000'
|
||||
const redirectUrl = `${baseUrl}/arcade/rooms/${room.id}`
|
||||
|
||||
return NextResponse.json({
|
||||
roomId: room.id,
|
||||
redirectUrl,
|
||||
room,
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to find room by code:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to find room by code" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('Failed to find room by code:', error)
|
||||
return NextResponse.json({ error: 'Failed to find room by code' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getUserRooms } from "@/lib/arcade/room-membership";
|
||||
import { getRoomById } from "@/lib/arcade/room-manager";
|
||||
import { getRoomMembers } from "@/lib/arcade/room-membership";
|
||||
import { getRoomActivePlayers } from "@/lib/arcade/player-manager";
|
||||
import { getViewerId } from "@/lib/viewer";
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getUserRooms } from '@/lib/arcade/room-membership'
|
||||
import { getRoomById } from '@/lib/arcade/room-manager'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getAllGameConfigs } from '@/lib/arcade/game-config-helpers'
|
||||
|
||||
/**
|
||||
* GET /api/arcade/rooms/current
|
||||
@@ -11,45 +12,61 @@ import { getViewerId } from "@/lib/viewer";
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const userId = await getViewerId();
|
||||
const userId = await getViewerId()
|
||||
|
||||
// Get all rooms user is in (should be at most 1 due to modal room enforcement)
|
||||
const roomIds = await getUserRooms(userId);
|
||||
const roomIds = await getUserRooms(userId)
|
||||
|
||||
if (roomIds.length === 0) {
|
||||
return NextResponse.json({ room: null }, { status: 200 });
|
||||
return NextResponse.json({ room: null }, { status: 200 })
|
||||
}
|
||||
|
||||
const roomId = roomIds[0];
|
||||
const roomId = roomIds[0]
|
||||
|
||||
// Get room data
|
||||
const room = await getRoomById(roomId);
|
||||
const room = await getRoomById(roomId)
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: "Room not found" }, { status: 404 });
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get game configs from new room_game_configs table
|
||||
const gameConfig = await getAllGameConfigs(roomId)
|
||||
|
||||
console.log(
|
||||
'[Current Room API] Room data READ from database:',
|
||||
JSON.stringify(
|
||||
{
|
||||
roomId,
|
||||
gameName: room.gameName,
|
||||
gameConfig,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
// Get members
|
||||
const members = await getRoomMembers(roomId);
|
||||
const members = await getRoomMembers(roomId)
|
||||
|
||||
// Get active players for all members
|
||||
const memberPlayers = await getRoomActivePlayers(roomId);
|
||||
const memberPlayers = await getRoomActivePlayers(roomId)
|
||||
|
||||
// Convert Map to object for JSON serialization
|
||||
const memberPlayersObj: Record<string, any[]> = {};
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players;
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
room,
|
||||
room: {
|
||||
...room,
|
||||
gameConfig, // Override with configs from new table
|
||||
},
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[Current Room API] Error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch current room" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('[Current Room API] Error:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch current room' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { createRoom, listActiveRooms } from "@/lib/arcade/room-manager";
|
||||
import {
|
||||
addRoomMember,
|
||||
getRoomMembers,
|
||||
isMember,
|
||||
} from "@/lib/arcade/room-membership";
|
||||
import { getRoomActivePlayers } from "@/lib/arcade/player-manager";
|
||||
import { getViewerId } from "@/lib/viewer";
|
||||
import type { GameName } from "@/lib/arcade/validation";
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createRoom, listActiveRooms } from '@/lib/arcade/room-manager'
|
||||
import { addRoomMember, getRoomMembers, isMember } from '@/lib/arcade/room-membership'
|
||||
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import type { GameName } from '@/lib/arcade/validation'
|
||||
|
||||
/**
|
||||
* GET /api/arcade/rooms
|
||||
@@ -17,22 +13,22 @@ import type { GameName } from "@/lib/arcade/validation";
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const gameName = searchParams.get("gameName") as GameName | null;
|
||||
const { searchParams } = new URL(req.url)
|
||||
const gameName = searchParams.get('gameName') as GameName | null
|
||||
|
||||
const viewerId = await getViewerId();
|
||||
const rooms = await listActiveRooms(gameName || undefined);
|
||||
const viewerId = await getViewerId()
|
||||
const rooms = await listActiveRooms(gameName || undefined)
|
||||
|
||||
// Enrich with member counts, player counts, and membership status
|
||||
const roomsWithCounts = await Promise.all(
|
||||
rooms.map(async (room) => {
|
||||
const members = await getRoomMembers(room.id);
|
||||
const playerMap = await getRoomActivePlayers(room.id);
|
||||
const userIsMember = await isMember(room.id, viewerId);
|
||||
const members = await getRoomMembers(room.id)
|
||||
const playerMap = await getRoomActivePlayers(room.id)
|
||||
const userIsMember = await isMember(room.id, viewerId)
|
||||
|
||||
let totalPlayers = 0;
|
||||
let totalPlayers = 0
|
||||
for (const players of playerMap.values()) {
|
||||
totalPlayers += players.length;
|
||||
totalPlayers += players.length
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -43,21 +39,18 @@ export async function GET(req: NextRequest) {
|
||||
status: room.status,
|
||||
createdAt: room.createdAt,
|
||||
creatorName: room.creatorName,
|
||||
isLocked: room.isLocked,
|
||||
accessMode: room.accessMode,
|
||||
memberCount: members.length,
|
||||
playerCount: totalPlayers,
|
||||
isMember: userIsMember,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({ rooms: roomsWithCounts });
|
||||
return NextResponse.json({ rooms: roomsWithCounts })
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch rooms:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch rooms" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('Failed to fetch rooms:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch rooms' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,50 +62,67 @@ export async function GET(req: NextRequest) {
|
||||
* - gameName: string
|
||||
* - gameConfig?: object
|
||||
* - ttlMinutes?: number
|
||||
* - accessMode?: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
|
||||
* - password?: string
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const viewerId = await getViewerId();
|
||||
const body = await req.json();
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.name || !body.gameName) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields: name, gameName" },
|
||||
{ status: 400 },
|
||||
);
|
||||
// Validate game name 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 game name
|
||||
const validGames: GameName[] = [
|
||||
"matching",
|
||||
"memory-quiz",
|
||||
"complement-race",
|
||||
];
|
||||
if (!validGames.includes(body.gameName)) {
|
||||
return NextResponse.json({ error: "Invalid game name" }, { status: 400 });
|
||||
// Validate name length (if provided)
|
||||
if (body.name && body.name.length > 50) {
|
||||
return NextResponse.json({ error: 'Room name too long (max 50 characters)' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate name length
|
||||
if (body.name.length > 50) {
|
||||
// Normalize empty name to null
|
||||
const roomName = body.name?.trim() || null
|
||||
|
||||
// Validate access mode
|
||||
if (body.accessMode) {
|
||||
const validAccessModes = [
|
||||
'open',
|
||||
'password',
|
||||
'approval-only',
|
||||
'restricted',
|
||||
'locked',
|
||||
'retired',
|
||||
]
|
||||
if (!validAccessModes.includes(body.accessMode)) {
|
||||
return NextResponse.json({ error: 'Invalid access mode' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// Validate password if provided
|
||||
if (body.accessMode === 'password' && !body.password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Room name too long (max 50 characters)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
{ error: 'Password is required for password-protected rooms' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get display name from body or generate from viewerId
|
||||
const displayName = body.creatorName || `Guest ${viewerId.slice(-4)}`;
|
||||
const displayName = body.creatorName || `Guest ${viewerId.slice(-4)}`
|
||||
|
||||
// Create room
|
||||
const room = await createRoom({
|
||||
name: body.name,
|
||||
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,
|
||||
})
|
||||
|
||||
// Add creator as first member
|
||||
await addRoomMember({
|
||||
@@ -120,24 +130,33 @@ export async function POST(req: NextRequest) {
|
||||
userId: viewerId,
|
||||
displayName,
|
||||
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}`;
|
||||
const baseUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:3000'
|
||||
const joinUrl = `${baseUrl}/arcade/rooms/${room.id}`
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
room,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
joinUrl,
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to create room:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create room" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('Failed to create room:', error)
|
||||
return NextResponse.json({ error: 'Failed to create room' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
* - etc.
|
||||
*/
|
||||
|
||||
import { handlers } from "@/auth";
|
||||
import { handlers } from '@/auth'
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
export const { GET, POST } = handlers
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import buildInfo from "@/generated/build-info.json";
|
||||
import { NextResponse } from 'next/server'
|
||||
import buildInfo from '@/generated/build-info.json'
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(buildInfo);
|
||||
return NextResponse.json(buildInfo)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getViewerId } from "@/lib/viewer";
|
||||
import { getActivePlayers } from "@/lib/arcade/player-manager";
|
||||
import { db, schema } from "@/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { db, schema } from '@/db'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
/**
|
||||
* GET /api/debug/active-players
|
||||
@@ -10,27 +10,24 @@ import { eq } from "drizzle-orm";
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const viewerId = await getViewerId();
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Get user record
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
});
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: "User not found", viewerId },
|
||||
{ status: 404 },
|
||||
);
|
||||
return NextResponse.json({ error: 'User not found', viewerId }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get ALL players for this user
|
||||
const allPlayers = await db.query.players.findMany({
|
||||
where: eq(schema.players.userId, user.id),
|
||||
});
|
||||
})
|
||||
|
||||
// Get active players using the helper
|
||||
const activePlayers = await getActivePlayers(viewerId);
|
||||
const activePlayers = await getActivePlayers(viewerId)
|
||||
|
||||
return NextResponse.json({
|
||||
viewerId,
|
||||
@@ -49,12 +46,12 @@ export async function GET() {
|
||||
})),
|
||||
activeCount: activePlayers.length,
|
||||
totalCount: allPlayers.length,
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch active players:", error);
|
||||
console.error('Failed to fetch active players:', error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch active players", details: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
{ error: 'Failed to fetch active players', details: String(error) },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +1,46 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { assetStore } from "@/lib/asset-store";
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { assetStore } from '@/lib/asset-store'
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { id: string } },
|
||||
) {
|
||||
export async function GET(_request: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const { id } = params;
|
||||
const { id } = params
|
||||
|
||||
console.log("🔍 Looking for asset:", id);
|
||||
console.log("📦 Available assets:", await assetStore.keys());
|
||||
console.log('🔍 Looking for asset:', id)
|
||||
console.log('📦 Available assets:', await assetStore.keys())
|
||||
|
||||
// Get asset from store
|
||||
const asset = await assetStore.get(id);
|
||||
const asset = await assetStore.get(id)
|
||||
if (!asset) {
|
||||
console.log("❌ Asset not found in store");
|
||||
console.log('❌ Asset not found in store')
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Asset not found or expired",
|
||||
error: 'Asset not found or expired',
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log("✅ Asset found, serving download");
|
||||
console.log('✅ Asset found, serving download')
|
||||
|
||||
// Return file with appropriate headers
|
||||
return new NextResponse(new Uint8Array(asset.data), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": asset.mimeType,
|
||||
"Content-Disposition": `attachment; filename="${asset.filename}"`,
|
||||
"Content-Length": asset.data.length.toString(),
|
||||
"Cache-Control": "private, no-cache, no-store, must-revalidate",
|
||||
Expires: "0",
|
||||
Pragma: "no-cache",
|
||||
'Content-Type': asset.mimeType,
|
||||
'Content-Disposition': `attachment; filename="${asset.filename}"`,
|
||||
'Content-Length': asset.data.length.toString(),
|
||||
'Cache-Control': 'private, no-cache, no-store, must-revalidate',
|
||||
Expires: '0',
|
||||
Pragma: 'no-cache',
|
||||
},
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("❌ Download failed:", error);
|
||||
console.error('❌ Download failed:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Failed to download file",
|
||||
error: 'Failed to download file',
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,28 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { assetStore } from "@/lib/asset-store";
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { assetStore } from '@/lib/asset-store'
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { id: string } },
|
||||
) {
|
||||
export async function GET(_request: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const { id } = params;
|
||||
const { id } = params
|
||||
|
||||
const asset = await assetStore.get(id);
|
||||
const asset = await assetStore.get(id)
|
||||
if (!asset) {
|
||||
return NextResponse.json({ error: "Asset not found" }, { status: 404 });
|
||||
return NextResponse.json({ error: 'Asset not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Set appropriate headers for download
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", asset.mimeType);
|
||||
headers.set(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${asset.filename}"`,
|
||||
);
|
||||
headers.set("Content-Length", asset.data.length.toString());
|
||||
headers.set("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
const headers = new Headers()
|
||||
headers.set('Content-Type', asset.mimeType)
|
||||
headers.set('Content-Disposition', `attachment; filename="${asset.filename}"`)
|
||||
headers.set('Content-Length', asset.data.length.toString())
|
||||
headers.set('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||
|
||||
return new NextResponse(new Uint8Array(asset.data), {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Asset download error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to download asset" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('Asset download error:', error)
|
||||
return NextResponse.json({ error: 'Failed to download asset' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,146 +1,141 @@
|
||||
import { SorobanGenerator } from "@soroban/core";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import path from "path";
|
||||
import { SorobanGenerator } from '@soroban/core'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import path from 'path'
|
||||
|
||||
// Global generator instance for better performance
|
||||
let generator: SorobanGenerator | null = null;
|
||||
let generator: SorobanGenerator | null = null
|
||||
|
||||
async function getGenerator() {
|
||||
if (!generator) {
|
||||
// Point to the core package in our monorepo
|
||||
const corePackagePath = path.join(process.cwd(), "../../packages/core");
|
||||
generator = new SorobanGenerator(corePackagePath);
|
||||
const corePackagePath = path.join(process.cwd(), '../../packages/core')
|
||||
generator = new SorobanGenerator(corePackagePath)
|
||||
|
||||
// Note: SorobanGenerator from @soroban/core doesn't have initialize method
|
||||
// It uses one-shot mode by default
|
||||
}
|
||||
return generator;
|
||||
return generator
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const config = await request.json();
|
||||
const config = await request.json()
|
||||
|
||||
// Debug: log the received config
|
||||
console.log("📥 Received config:", JSON.stringify(config, null, 2));
|
||||
console.log('📥 Received config:', JSON.stringify(config, null, 2))
|
||||
|
||||
// Ensure range is set with a default
|
||||
if (!config.range) {
|
||||
console.log("⚠️ No range provided, using default: 0-99");
|
||||
config.range = "0-99";
|
||||
console.log('⚠️ No range provided, using default: 0-99')
|
||||
config.range = '0-99'
|
||||
}
|
||||
|
||||
// Get generator instance
|
||||
const gen = await getGenerator();
|
||||
const gen = await getGenerator()
|
||||
|
||||
// Check dependencies before generating
|
||||
const deps = await gen.checkDependencies?.();
|
||||
const deps = await gen.checkDependencies?.()
|
||||
if (deps && (!deps.python || !deps.typst)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Missing system dependencies",
|
||||
error: 'Missing system dependencies',
|
||||
details: {
|
||||
python: deps.python ? "✅ Available" : "❌ Missing Python 3",
|
||||
typst: deps.typst ? "✅ Available" : "❌ Missing Typst",
|
||||
qpdf: deps.qpdf ? "✅ Available" : "⚠️ Missing qpdf (optional)",
|
||||
python: deps.python ? '✅ Available' : '❌ Missing Python 3',
|
||||
typst: deps.typst ? '✅ Available' : '❌ Missing Typst',
|
||||
qpdf: deps.qpdf ? '✅ Available' : '⚠️ Missing qpdf (optional)',
|
||||
},
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate flashcards using Python via TypeScript bindings
|
||||
console.log(
|
||||
"🚀 Generating flashcards with config:",
|
||||
JSON.stringify(config, null, 2),
|
||||
);
|
||||
const result = await gen.generate(config);
|
||||
console.log('🚀 Generating flashcards with config:', JSON.stringify(config, null, 2))
|
||||
const result = await gen.generate(config)
|
||||
|
||||
// SorobanGenerator.generate() returns PDF data directly as Buffer
|
||||
if (!Buffer.isBuffer(result)) {
|
||||
throw new Error(
|
||||
`Expected PDF Buffer from generator, got: ${typeof result}`,
|
||||
);
|
||||
throw new Error(`Expected PDF Buffer from generator, got: ${typeof result}`)
|
||||
}
|
||||
const pdfBuffer = result;
|
||||
const pdfBuffer = result
|
||||
// Create filename for download
|
||||
const filename = `soroban-flashcards-${config.range || "cards"}.pdf`;
|
||||
const filename = `soroban-flashcards-${config.range || 'cards'}.pdf`
|
||||
|
||||
// Return PDF directly as download
|
||||
return new NextResponse(new Uint8Array(pdfBuffer), {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Content-Length": pdfBuffer.length.toString(),
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Content-Length': pdfBuffer.length.toString(),
|
||||
},
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("❌ Generation failed:", error);
|
||||
console.error('❌ Generation failed:', error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Failed to generate flashcards",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
error: 'Failed to generate flashcards',
|
||||
details: error instanceof Error ? error.message : 'Unknown error',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions to calculate metadata
|
||||
function _calculateCardCount(range: string, step: number): number {
|
||||
if (range.includes("-")) {
|
||||
const [start, end] = range.split("-").map((n) => parseInt(n, 10) || 0);
|
||||
return Math.floor((end - start + 1) / step);
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map((n) => parseInt(n, 10) || 0)
|
||||
return Math.floor((end - start + 1) / step)
|
||||
}
|
||||
|
||||
if (range.includes(",")) {
|
||||
return range.split(",").length;
|
||||
if (range.includes(',')) {
|
||||
return range.split(',').length
|
||||
}
|
||||
|
||||
return 1;
|
||||
return 1
|
||||
}
|
||||
|
||||
function _generateNumbersFromRange(range: string, step: number): number[] {
|
||||
if (range.includes("-")) {
|
||||
const [start, end] = range.split("-").map((n) => parseInt(n, 10) || 0);
|
||||
const numbers: number[] = [];
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map((n) => parseInt(n, 10) || 0)
|
||||
const numbers: number[] = []
|
||||
for (let i = start; i <= end; i += step) {
|
||||
numbers.push(i);
|
||||
if (numbers.length >= 100) break; // Limit to prevent huge arrays
|
||||
numbers.push(i)
|
||||
if (numbers.length >= 100) break // Limit to prevent huge arrays
|
||||
}
|
||||
return numbers;
|
||||
return numbers
|
||||
}
|
||||
|
||||
if (range.includes(",")) {
|
||||
return range.split(",").map((n) => parseInt(n.trim(), 10) || 0);
|
||||
if (range.includes(',')) {
|
||||
return range.split(',').map((n) => parseInt(n.trim(), 10) || 0)
|
||||
}
|
||||
|
||||
return [parseInt(range, 10) || 0];
|
||||
return [parseInt(range, 10) || 0]
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
export async function GET() {
|
||||
try {
|
||||
const gen = await getGenerator();
|
||||
const gen = await getGenerator()
|
||||
const deps = (await gen.checkDependencies?.()) || {
|
||||
python: true,
|
||||
typst: true,
|
||||
qpdf: true,
|
||||
};
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: "healthy",
|
||||
status: 'healthy',
|
||||
dependencies: deps,
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "unhealthy",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
status: 'unhealthy',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { db, schema } from "@/db";
|
||||
import { getViewerId } from "@/lib/viewer";
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* PATCH /api/players/[id]
|
||||
* Update a player (only if it belongs to the current viewer)
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } },
|
||||
) {
|
||||
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const viewerId = await getViewerId();
|
||||
const body = await req.json();
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Get user record (must exist if player exists)
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
});
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if user has an active arcade session
|
||||
@@ -29,17 +26,17 @@ export async function PATCH(
|
||||
if (body.isActive !== undefined) {
|
||||
const activeSession = await db.query.arcadeSessions.findFirst({
|
||||
where: eq(schema.arcadeSessions.userId, viewerId),
|
||||
});
|
||||
})
|
||||
|
||||
if (activeSession) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Cannot modify active players during an active game session",
|
||||
error: 'Cannot modify active players during an active game session',
|
||||
activeGame: activeSession.currentGame,
|
||||
gameUrl: activeSession.gameUrl,
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,28 +51,17 @@ export async function PATCH(
|
||||
...(body.isActive !== undefined && { isActive: body.isActive }),
|
||||
// userId is explicitly NOT included - it comes from session
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.players.id, params.id),
|
||||
eq(schema.players.userId, user.id),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
.where(and(eq(schema.players.id, params.id), eq(schema.players.userId, user.id)))
|
||||
.returning()
|
||||
|
||||
if (!updatedPlayer) {
|
||||
return NextResponse.json(
|
||||
{ error: "Player not found or unauthorized" },
|
||||
{ status: 404 },
|
||||
);
|
||||
return NextResponse.json({ error: 'Player not found or unauthorized' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ player: updatedPlayer });
|
||||
return NextResponse.json({ player: updatedPlayer })
|
||||
} catch (error) {
|
||||
console.error("Failed to update player:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update player" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('Failed to update player:', error)
|
||||
return NextResponse.json({ error: 'Failed to update player' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,46 +69,32 @@ export async function PATCH(
|
||||
* DELETE /api/players/[id]
|
||||
* Delete a player (only if it belongs to the current viewer)
|
||||
*/
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: { id: string } },
|
||||
) {
|
||||
export async function DELETE(_req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const viewerId = await getViewerId();
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Get user record (must exist if player exists)
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
});
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Delete player (only if it belongs to this user)
|
||||
const [deletedPlayer] = await db
|
||||
.delete(schema.players)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.players.id, params.id),
|
||||
eq(schema.players.userId, user.id),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
.where(and(eq(schema.players.id, params.id), eq(schema.players.userId, user.id)))
|
||||
.returning()
|
||||
|
||||
if (!deletedPlayer) {
|
||||
return NextResponse.json(
|
||||
{ error: "Player not found or unauthorized" },
|
||||
{ status: 404 },
|
||||
);
|
||||
return NextResponse.json({ error: 'Player not found or unauthorized' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, player: deletedPlayer });
|
||||
return NextResponse.json({ success: true, player: deletedPlayer })
|
||||
} catch (error) {
|
||||
console.error("Failed to delete player:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete player" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('Failed to delete player:', error)
|
||||
return NextResponse.json({ error: 'Failed to delete player' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { db, schema } from "../../../../db";
|
||||
import { PATCH } from "../[id]/route";
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { db, schema } from '../../../../db'
|
||||
import { PATCH } from '../[id]/route'
|
||||
|
||||
/**
|
||||
* Arcade Session Validation E2E Tests
|
||||
@@ -15,268 +15,309 @@ import { PATCH } from "../[id]/route";
|
||||
* correctly prevents isActive changes when user has an active arcade session.
|
||||
*/
|
||||
|
||||
describe("PATCH /api/players/[id] - Arcade Session Validation", () => {
|
||||
let testUserId: string;
|
||||
let testGuestId: string;
|
||||
let testPlayerId: string;
|
||||
describe('PATCH /api/players/[id] - Arcade Session Validation', () => {
|
||||
let testUserId: string
|
||||
let testGuestId: string
|
||||
let testPlayerId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a test user with unique guest ID
|
||||
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const [user] = await db
|
||||
.insert(schema.users)
|
||||
.values({ guestId: testGuestId })
|
||||
.returning();
|
||||
testUserId = user.id;
|
||||
testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning()
|
||||
testUserId = user.id
|
||||
|
||||
// Create a test player
|
||||
const [player] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: "Test Player",
|
||||
emoji: "😀",
|
||||
color: "#3b82f6",
|
||||
name: 'Test Player',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
isActive: false,
|
||||
})
|
||||
.returning();
|
||||
testPlayerId = player.id;
|
||||
});
|
||||
.returning()
|
||||
testPlayerId = player.id
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up: delete test arcade session (if exists)
|
||||
await db
|
||||
.delete(schema.arcadeSessions)
|
||||
.where(eq(schema.arcadeSessions.userId, testGuestId));
|
||||
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testGuestId))
|
||||
// Clean up: delete test user (cascade deletes players)
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId));
|
||||
});
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId))
|
||||
})
|
||||
|
||||
it('should return 403 when trying to change isActive with active arcade session', async () => {
|
||||
// Create an 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()
|
||||
|
||||
it("should return 403 when trying to change isActive with active arcade session", async () => {
|
||||
// Create an active arcade session
|
||||
const now = new Date();
|
||||
const now = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
roomId: room.id,
|
||||
userId: testGuestId,
|
||||
currentGame: "matching",
|
||||
gameUrl: "/arcade/matching",
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
gameState: JSON.stringify({}),
|
||||
activePlayers: JSON.stringify([testPlayerId]),
|
||||
startedAt: now,
|
||||
lastActivityAt: now,
|
||||
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
|
||||
version: 1,
|
||||
});
|
||||
})
|
||||
|
||||
// Mock request to change isActive
|
||||
const mockRequest = new NextRequest(
|
||||
`http://localhost:3000/api/players/${testPlayerId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: `guest_id=${testGuestId}`,
|
||||
},
|
||||
body: JSON.stringify({ isActive: true }),
|
||||
const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: `guest_id=${testGuestId}`,
|
||||
},
|
||||
);
|
||||
body: JSON.stringify({ isActive: true }),
|
||||
})
|
||||
|
||||
// Mock getViewerId by setting cookie
|
||||
const response = await PATCH(mockRequest, { params: { id: testPlayerId } });
|
||||
const data = await response.json();
|
||||
const response = await PATCH(mockRequest, { params: { id: testPlayerId } })
|
||||
const data = await response.json()
|
||||
|
||||
// Should be rejected with 403
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toContain(
|
||||
"Cannot modify active players during an active game session",
|
||||
);
|
||||
expect(data.activeGame).toBe("matching");
|
||||
expect(data.gameUrl).toBe("/arcade/matching");
|
||||
expect(response.status).toBe(403)
|
||||
expect(data.error).toContain('Cannot modify active players during an active game session')
|
||||
expect(data.activeGame).toBe('matching')
|
||||
expect(data.gameUrl).toBe('/arcade/matching')
|
||||
|
||||
// Verify player isActive was NOT changed
|
||||
const player = await db.query.players.findFirst({
|
||||
where: eq(schema.players.id, testPlayerId),
|
||||
});
|
||||
expect(player?.isActive).toBe(false); // Still false
|
||||
});
|
||||
})
|
||||
expect(player?.isActive).toBe(false) // Still false
|
||||
})
|
||||
|
||||
it("should allow isActive change when no active arcade session", async () => {
|
||||
it('should allow isActive change when no active arcade session', async () => {
|
||||
// No arcade session created
|
||||
|
||||
// Mock request to change isActive
|
||||
const mockRequest = new NextRequest(
|
||||
`http://localhost:3000/api/players/${testPlayerId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: `guest_id=${testGuestId}`,
|
||||
},
|
||||
body: JSON.stringify({ isActive: true }),
|
||||
const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: `guest_id=${testGuestId}`,
|
||||
},
|
||||
);
|
||||
body: JSON.stringify({ isActive: true }),
|
||||
})
|
||||
|
||||
const response = await PATCH(mockRequest, { params: { id: testPlayerId } });
|
||||
const data = await response.json();
|
||||
const response = await PATCH(mockRequest, { params: { id: testPlayerId } })
|
||||
const data = await response.json()
|
||||
|
||||
// Should succeed
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.player.isActive).toBe(true);
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.player.isActive).toBe(true)
|
||||
|
||||
// Verify player isActive was changed
|
||||
const player = await db.query.players.findFirst({
|
||||
where: eq(schema.players.id, testPlayerId),
|
||||
});
|
||||
expect(player?.isActive).toBe(true);
|
||||
});
|
||||
})
|
||||
expect(player?.isActive).toBe(true)
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
it("should allow non-isActive changes even with active arcade session", async () => {
|
||||
// Create an active arcade session
|
||||
const now = new Date();
|
||||
const now = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
roomId: room.id,
|
||||
userId: testGuestId,
|
||||
currentGame: "matching",
|
||||
gameUrl: "/arcade/matching",
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
gameState: JSON.stringify({}),
|
||||
activePlayers: JSON.stringify([testPlayerId]),
|
||||
startedAt: now,
|
||||
lastActivityAt: now,
|
||||
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
|
||||
version: 1,
|
||||
});
|
||||
})
|
||||
|
||||
// Mock request to change name/emoji/color (NOT isActive)
|
||||
const mockRequest = new NextRequest(
|
||||
`http://localhost:3000/api/players/${testPlayerId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: `guest_id=${testGuestId}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: "Updated Name",
|
||||
emoji: "🎉",
|
||||
color: "#ff0000",
|
||||
}),
|
||||
const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: `guest_id=${testGuestId}`,
|
||||
},
|
||||
);
|
||||
body: JSON.stringify({
|
||||
name: 'Updated Name',
|
||||
emoji: '🎉',
|
||||
color: '#ff0000',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await PATCH(mockRequest, { params: { id: testPlayerId } });
|
||||
const data = await response.json();
|
||||
const response = await PATCH(mockRequest, { params: { id: testPlayerId } })
|
||||
const data = await response.json()
|
||||
|
||||
// Should succeed
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.player.name).toBe("Updated Name");
|
||||
expect(data.player.emoji).toBe("🎉");
|
||||
expect(data.player.color).toBe("#ff0000");
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.player.name).toBe('Updated Name')
|
||||
expect(data.player.emoji).toBe('🎉')
|
||||
expect(data.player.color).toBe('#ff0000')
|
||||
|
||||
// Verify changes were applied
|
||||
const player = await db.query.players.findFirst({
|
||||
where: eq(schema.players.id, testPlayerId),
|
||||
});
|
||||
expect(player?.name).toBe("Updated Name");
|
||||
expect(player?.emoji).toBe("🎉");
|
||||
expect(player?.color).toBe("#ff0000");
|
||||
});
|
||||
})
|
||||
expect(player?.name).toBe('Updated Name')
|
||||
expect(player?.emoji).toBe('🎉')
|
||||
expect(player?.color).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('should allow isActive change after arcade session ends', async () => {
|
||||
// Create an 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()
|
||||
|
||||
it("should allow isActive change after arcade session ends", async () => {
|
||||
// Create an active arcade session
|
||||
const now = new Date();
|
||||
const now = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
roomId: room.id,
|
||||
userId: testGuestId,
|
||||
currentGame: "matching",
|
||||
gameUrl: "/arcade/matching",
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
gameState: JSON.stringify({}),
|
||||
activePlayers: JSON.stringify([testPlayerId]),
|
||||
startedAt: now,
|
||||
lastActivityAt: now,
|
||||
expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now
|
||||
version: 1,
|
||||
});
|
||||
})
|
||||
|
||||
// End the session
|
||||
await db
|
||||
.delete(schema.arcadeSessions)
|
||||
.where(eq(schema.arcadeSessions.userId, testGuestId));
|
||||
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}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: `guest_id=${testGuestId}`,
|
||||
},
|
||||
body: JSON.stringify({ isActive: true }),
|
||||
const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: `guest_id=${testGuestId}`,
|
||||
},
|
||||
);
|
||||
body: JSON.stringify({ isActive: true }),
|
||||
})
|
||||
|
||||
const response = await PATCH(mockRequest, { params: { id: testPlayerId } });
|
||||
const data = await response.json();
|
||||
const response = await PATCH(mockRequest, { params: { id: testPlayerId } })
|
||||
const data = await response.json()
|
||||
|
||||
// Should succeed
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.player.isActive).toBe(true);
|
||||
});
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.player.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle multiple players with different isActive states", async () => {
|
||||
it('should handle multiple players with different isActive states', async () => {
|
||||
// Create additional players
|
||||
const [player2] = await db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
name: "Player 2",
|
||||
emoji: "😎",
|
||||
color: "#8b5cf6",
|
||||
name: 'Player 2',
|
||||
emoji: '😎',
|
||||
color: '#8b5cf6',
|
||||
isActive: true,
|
||||
})
|
||||
.returning();
|
||||
.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();
|
||||
const now2 = new Date()
|
||||
await db.insert(schema.arcadeSessions).values({
|
||||
roomId: room.id,
|
||||
userId: testGuestId,
|
||||
currentGame: "matching",
|
||||
gameUrl: "/arcade/matching",
|
||||
currentGame: 'matching',
|
||||
gameUrl: '/arcade/matching',
|
||||
gameState: JSON.stringify({}),
|
||||
activePlayers: JSON.stringify([testPlayerId, player2.id]),
|
||||
startedAt: now2,
|
||||
lastActivityAt: now2,
|
||||
expiresAt: new Date(now2.getTime() + 3600000), // 1 hour from now
|
||||
version: 1,
|
||||
});
|
||||
})
|
||||
|
||||
// Try to toggle player1 (inactive -> active) - should fail
|
||||
const request1 = new NextRequest(
|
||||
`http://localhost:3000/api/players/${testPlayerId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: `guest_id=${testGuestId}`,
|
||||
},
|
||||
body: JSON.stringify({ isActive: true }),
|
||||
const request1 = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: `guest_id=${testGuestId}`,
|
||||
},
|
||||
);
|
||||
body: JSON.stringify({ isActive: true }),
|
||||
})
|
||||
|
||||
const response1 = await PATCH(request1, { params: { id: testPlayerId } });
|
||||
expect(response1.status).toBe(403);
|
||||
const response1 = await PATCH(request1, { params: { id: testPlayerId } })
|
||||
expect(response1.status).toBe(403)
|
||||
|
||||
// Try to toggle player2 (active -> inactive) - should also fail
|
||||
const request2 = new NextRequest(
|
||||
`http://localhost:3000/api/players/${player2.id}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: `guest_id=${testGuestId}`,
|
||||
},
|
||||
body: JSON.stringify({ isActive: false }),
|
||||
const request2 = new NextRequest(`http://localhost:3000/api/players/${player2.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: `guest_id=${testGuestId}`,
|
||||
},
|
||||
);
|
||||
body: JSON.stringify({ isActive: false }),
|
||||
})
|
||||
|
||||
const response2 = await PATCH(request2, { params: { id: player2.id } });
|
||||
expect(response2.status).toBe(403);
|
||||
});
|
||||
});
|
||||
const response2 = await PATCH(request2, { params: { id: player2.id } })
|
||||
expect(response2.status).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { db, schema } from "@/db";
|
||||
import { getViewerId } from "@/lib/viewer";
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* GET /api/players
|
||||
@@ -9,24 +9,21 @@ import { getViewerId } from "@/lib/viewer";
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const viewerId = await getViewerId();
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Get or create user record
|
||||
const user = await getOrCreateUser(viewerId);
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
|
||||
// Get all players for this user
|
||||
const players = await db.query.players.findMany({
|
||||
where: eq(schema.players.userId, user.id),
|
||||
orderBy: (players, { desc }) => [desc(players.createdAt)],
|
||||
});
|
||||
})
|
||||
|
||||
return NextResponse.json({ players });
|
||||
return NextResponse.json({ players })
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch players:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch players" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('Failed to fetch players:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch players' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,19 +33,19 @@ export async function GET() {
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const viewerId = await getViewerId();
|
||||
const body = await req.json();
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.name || !body.emoji || !body.color) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields: name, emoji, color" },
|
||||
{ status: 400 },
|
||||
);
|
||||
{ error: 'Missing required fields: name, emoji, color' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get or create user record
|
||||
const user = await getOrCreateUser(viewerId);
|
||||
const user = await getOrCreateUser(viewerId)
|
||||
|
||||
// Create player
|
||||
const [player] = await db
|
||||
@@ -60,15 +57,12 @@ export async function POST(req: NextRequest) {
|
||||
color: body.color,
|
||||
isActive: body.isActive ?? false,
|
||||
})
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
return NextResponse.json({ player }, { status: 201 });
|
||||
return NextResponse.json({ player }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Failed to create player:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create player" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('Failed to create player:', error)
|
||||
return NextResponse.json({ error: 'Failed to create player' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +73,7 @@ async function getOrCreateUser(viewerId: string) {
|
||||
// Try to find existing user by guest ID
|
||||
let user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
});
|
||||
})
|
||||
|
||||
// If no user exists, create one
|
||||
if (!user) {
|
||||
@@ -88,10 +82,10 @@ async function getOrCreateUser(viewerId: string) {
|
||||
.values({
|
||||
guestId: viewerId,
|
||||
})
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
user = newUser;
|
||||
user = newUser
|
||||
}
|
||||
|
||||
return user;
|
||||
return user
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { db, schema } from "@/db";
|
||||
import { getViewerId } from "@/lib/viewer";
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* GET /api/user-stats
|
||||
@@ -9,12 +9,12 @@ import { getViewerId } from "@/lib/viewer";
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const viewerId = await getViewerId();
|
||||
const viewerId = await getViewerId()
|
||||
|
||||
// Get user record
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
});
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
// No user yet, return default stats
|
||||
@@ -26,13 +26,13 @@ export async function GET() {
|
||||
bestTime: null,
|
||||
highestAccuracy: 0,
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// Get stats record
|
||||
let stats = await db.query.userStats.findFirst({
|
||||
where: eq(schema.userStats.userId, user.id),
|
||||
});
|
||||
})
|
||||
|
||||
// If no stats record exists, create one with defaults
|
||||
if (!stats) {
|
||||
@@ -41,18 +41,15 @@ export async function GET() {
|
||||
.values({
|
||||
userId: user.id,
|
||||
})
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
stats = newStats;
|
||||
stats = newStats
|
||||
}
|
||||
|
||||
return NextResponse.json({ stats });
|
||||
return NextResponse.json({ stats })
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user stats:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch user stats" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('Failed to fetch user stats:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch user stats' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,13 +59,13 @@ export async function GET() {
|
||||
*/
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const viewerId = await getViewerId();
|
||||
const body = await req.json();
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Get or create user record
|
||||
let user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.guestId, viewerId),
|
||||
});
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
// Create user if it doesn't exist
|
||||
@@ -77,25 +74,23 @@ export async function PATCH(req: NextRequest) {
|
||||
.values({
|
||||
guestId: viewerId,
|
||||
})
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
user = newUser;
|
||||
user = newUser
|
||||
}
|
||||
|
||||
// Get existing stats
|
||||
const stats = await db.query.userStats.findFirst({
|
||||
where: eq(schema.userStats.userId, user.id),
|
||||
});
|
||||
})
|
||||
|
||||
// Prepare update values
|
||||
const updates: any = {};
|
||||
if (body.gamesPlayed !== undefined) updates.gamesPlayed = body.gamesPlayed;
|
||||
if (body.totalWins !== undefined) updates.totalWins = body.totalWins;
|
||||
if (body.favoriteGameType !== undefined)
|
||||
updates.favoriteGameType = body.favoriteGameType;
|
||||
if (body.bestTime !== undefined) updates.bestTime = body.bestTime;
|
||||
if (body.highestAccuracy !== undefined)
|
||||
updates.highestAccuracy = body.highestAccuracy;
|
||||
const updates: any = {}
|
||||
if (body.gamesPlayed !== undefined) updates.gamesPlayed = body.gamesPlayed
|
||||
if (body.totalWins !== undefined) updates.totalWins = body.totalWins
|
||||
if (body.favoriteGameType !== undefined) updates.favoriteGameType = body.favoriteGameType
|
||||
if (body.bestTime !== undefined) updates.bestTime = body.bestTime
|
||||
if (body.highestAccuracy !== undefined) updates.highestAccuracy = body.highestAccuracy
|
||||
|
||||
if (stats) {
|
||||
// Update existing stats
|
||||
@@ -103,9 +98,9 @@ export async function PATCH(req: NextRequest) {
|
||||
.update(schema.userStats)
|
||||
.set(updates)
|
||||
.where(eq(schema.userStats.userId, user.id))
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
return NextResponse.json({ stats: updatedStats });
|
||||
return NextResponse.json({ stats: updatedStats })
|
||||
} else {
|
||||
// Create new stats record
|
||||
const [newStats] = await db
|
||||
@@ -114,15 +109,12 @@ export async function PATCH(req: NextRequest) {
|
||||
userId: user.id,
|
||||
...updates,
|
||||
})
|
||||
.returning();
|
||||
.returning()
|
||||
|
||||
return NextResponse.json({ stats: newStats }, { status: 201 });
|
||||
return NextResponse.json({ stats: newStats }, { status: 201 })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update user stats:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update user stats" },
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('Failed to update user stats:', error)
|
||||
return NextResponse.json({ error: 'Failed to update user stats' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getViewerId } from "@/lib/viewer";
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* GET /api/viewer
|
||||
@@ -8,12 +8,9 @@ import { getViewerId } from "@/lib/viewer";
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const viewerId = await getViewerId();
|
||||
return NextResponse.json({ viewerId });
|
||||
const viewerId = await getViewerId()
|
||||
return NextResponse.json({ viewerId })
|
||||
} catch (_error) {
|
||||
return NextResponse.json(
|
||||
{ error: "No valid viewer session found" },
|
||||
{ status: 401 },
|
||||
);
|
||||
return NextResponse.json({ error: 'No valid viewer session found' }, { status: 401 })
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,316 +0,0 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import * as nextNavigation from "next/navigation";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as arcadeGuard from "@/hooks/useArcadeGuard";
|
||||
import * as roomData from "@/hooks/useRoomData";
|
||||
import * as viewerId from "@/hooks/useViewerId";
|
||||
|
||||
// Mock Next.js navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(),
|
||||
usePathname: vi.fn(),
|
||||
useParams: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock hooks
|
||||
vi.mock("@/hooks/useArcadeGuard");
|
||||
vi.mock("@/hooks/useRoomData");
|
||||
vi.mock("@/hooks/useViewerId");
|
||||
vi.mock("@/hooks/useUserPlayers", () => ({
|
||||
useUserPlayers: () => ({ data: [], isLoading: false }),
|
||||
useCreatePlayer: () => ({ mutate: vi.fn() }),
|
||||
useUpdatePlayer: () => ({ mutate: vi.fn() }),
|
||||
useDeletePlayer: () => ({ mutate: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/hooks/useArcadeSocket", () => ({
|
||||
useArcadeSocket: () => ({
|
||||
connected: false,
|
||||
joinSession: vi.fn(),
|
||||
socket: null,
|
||||
sendMove: vi.fn(),
|
||||
exitSession: vi.fn(),
|
||||
pingSession: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock styled-system
|
||||
vi.mock("../../../../styled-system/css", () => ({
|
||||
css: () => "",
|
||||
}));
|
||||
|
||||
// Mock components
|
||||
vi.mock("@/components/PageWithNav", () => ({
|
||||
PageWithNav: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Import pages after mocks
|
||||
import RoomBrowserPage from "../page";
|
||||
|
||||
describe("Room Navigation with Active Sessions", () => {
|
||||
const mockRouter = {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(nextNavigation, "useRouter").mockReturnValue(mockRouter as any);
|
||||
vi.spyOn(nextNavigation, "usePathname").mockReturnValue("/arcade-rooms");
|
||||
vi.spyOn(viewerId, "useViewerId").mockReturnValue({
|
||||
data: "test-user",
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
error: null,
|
||||
} as any);
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
describe("RoomBrowserPage", () => {
|
||||
it("should render room browser without redirecting when user has active game session", async () => {
|
||||
// User has an active game session
|
||||
vi.spyOn(arcadeGuard, "useArcadeGuard").mockReturnValue({
|
||||
hasActiveSession: true,
|
||||
loading: false,
|
||||
activeSession: {
|
||||
gameUrl: "/arcade/room",
|
||||
currentGame: "matching",
|
||||
},
|
||||
});
|
||||
|
||||
// User is in a room
|
||||
vi.spyOn(roomData, "useRoomData").mockReturnValue({
|
||||
roomData: {
|
||||
id: "room-1",
|
||||
name: "Test Room",
|
||||
code: "ABC123",
|
||||
gameName: "matching",
|
||||
members: [],
|
||||
memberPlayers: {},
|
||||
},
|
||||
isLoading: false,
|
||||
isInRoom: true,
|
||||
notifyRoomOfPlayerUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
// Mock rooms API
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rooms: [
|
||||
{
|
||||
id: "room-1",
|
||||
code: "ABC123",
|
||||
name: "Test Room",
|
||||
gameName: "matching",
|
||||
status: "lobby",
|
||||
createdAt: new Date(),
|
||||
creatorName: "Test User",
|
||||
isLocked: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
render(<RoomBrowserPage />);
|
||||
|
||||
// Should render the page
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("🎮 Multiplayer Rooms")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should NOT redirect to /arcade/room
|
||||
expect(mockRouter.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT redirect when PageWithNav uses arcade guard with enabled=false", async () => {
|
||||
// Simulate PageWithNav calling useArcadeGuard with enabled=false
|
||||
const arcadeGuardSpy = vi.spyOn(arcadeGuard, "useArcadeGuard");
|
||||
|
||||
// User has an active game session
|
||||
arcadeGuardSpy.mockReturnValue({
|
||||
hasActiveSession: true,
|
||||
loading: false,
|
||||
activeSession: {
|
||||
gameUrl: "/arcade/room",
|
||||
currentGame: "matching",
|
||||
},
|
||||
});
|
||||
|
||||
vi.spyOn(roomData, "useRoomData").mockReturnValue({
|
||||
roomData: null,
|
||||
isLoading: false,
|
||||
isInRoom: false,
|
||||
notifyRoomOfPlayerUpdate: vi.fn(),
|
||||
});
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ rooms: [] }),
|
||||
});
|
||||
|
||||
render(<RoomBrowserPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("🎮 Multiplayer Rooms")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// PageWithNav should have called useArcadeGuard with enabled=false
|
||||
// This is tested in PageWithNav's own tests, but we verify no redirect happened
|
||||
expect(mockRouter.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should allow navigation to room detail even with active session", async () => {
|
||||
vi.spyOn(arcadeGuard, "useArcadeGuard").mockReturnValue({
|
||||
hasActiveSession: true,
|
||||
loading: false,
|
||||
activeSession: {
|
||||
gameUrl: "/arcade/room",
|
||||
currentGame: "matching",
|
||||
},
|
||||
});
|
||||
|
||||
vi.spyOn(roomData, "useRoomData").mockReturnValue({
|
||||
roomData: null,
|
||||
isLoading: false,
|
||||
isInRoom: false,
|
||||
notifyRoomOfPlayerUpdate: vi.fn(),
|
||||
});
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rooms: [
|
||||
{
|
||||
id: "room-1",
|
||||
code: "ABC123",
|
||||
name: "Test Room",
|
||||
gameName: "matching",
|
||||
status: "lobby",
|
||||
createdAt: new Date(),
|
||||
creatorName: "Test User",
|
||||
isLocked: false,
|
||||
isMember: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
render(<RoomBrowserPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Room")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on the room card
|
||||
const roomCard = screen.getByText("Test Room").parentElement;
|
||||
roomCard?.click();
|
||||
|
||||
// Should navigate to room detail, not to /arcade/room
|
||||
await waitFor(() => {
|
||||
expect(mockRouter.push).toHaveBeenCalledWith("/arcade-rooms/room-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Room navigation edge cases", () => {
|
||||
it("should handle rapid navigation between room pages without redirect loops", async () => {
|
||||
vi.spyOn(arcadeGuard, "useArcadeGuard").mockReturnValue({
|
||||
hasActiveSession: true,
|
||||
loading: false,
|
||||
activeSession: {
|
||||
gameUrl: "/arcade/room",
|
||||
currentGame: "matching",
|
||||
},
|
||||
});
|
||||
|
||||
vi.spyOn(roomData, "useRoomData").mockReturnValue({
|
||||
roomData: null,
|
||||
isLoading: false,
|
||||
isInRoom: false,
|
||||
notifyRoomOfPlayerUpdate: vi.fn(),
|
||||
});
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ rooms: [] }),
|
||||
});
|
||||
|
||||
const { rerender } = render(<RoomBrowserPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("🎮 Multiplayer Rooms")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Simulate pathname changes (navigating between room pages)
|
||||
vi.spyOn(nextNavigation, "usePathname").mockReturnValue(
|
||||
"/arcade-rooms/room-1",
|
||||
);
|
||||
rerender(<RoomBrowserPage />);
|
||||
|
||||
vi.spyOn(nextNavigation, "usePathname").mockReturnValue("/arcade-rooms");
|
||||
rerender(<RoomBrowserPage />);
|
||||
|
||||
// Should never redirect to game page
|
||||
expect(mockRouter.push).not.toHaveBeenCalledWith("/arcade/room");
|
||||
});
|
||||
|
||||
it("should allow user to leave room and browse other rooms during active game", async () => {
|
||||
// User is in a room with an active game
|
||||
vi.spyOn(arcadeGuard, "useArcadeGuard").mockReturnValue({
|
||||
hasActiveSession: true,
|
||||
loading: false,
|
||||
activeSession: {
|
||||
gameUrl: "/arcade/room",
|
||||
currentGame: "matching",
|
||||
},
|
||||
});
|
||||
|
||||
vi.spyOn(roomData, "useRoomData").mockReturnValue({
|
||||
roomData: {
|
||||
id: "room-1",
|
||||
name: "Current Room",
|
||||
code: "ABC123",
|
||||
gameName: "matching",
|
||||
members: [],
|
||||
memberPlayers: {},
|
||||
},
|
||||
isLoading: false,
|
||||
isInRoom: true,
|
||||
notifyRoomOfPlayerUpdate: vi.fn(),
|
||||
});
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
rooms: [
|
||||
{
|
||||
id: "room-1",
|
||||
name: "Current Room",
|
||||
code: "ABC123",
|
||||
gameName: "matching",
|
||||
status: "playing",
|
||||
isMember: true,
|
||||
},
|
||||
{
|
||||
id: "room-2",
|
||||
name: "Other Room",
|
||||
code: "DEF456",
|
||||
gameName: "memory-quiz",
|
||||
status: "lobby",
|
||||
isMember: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
render(<RoomBrowserPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Current Room")).toBeInTheDocument();
|
||||
expect(screen.getByText("Other Room")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should be able to view both rooms without redirect
|
||||
expect(mockRouter.push).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,153 +1,195 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { css } from "../../../styled-system/css";
|
||||
import { PageWithNav } from "@/components/PageWithNav";
|
||||
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'
|
||||
|
||||
interface Room {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
gameName: string;
|
||||
status: "lobby" | "playing" | "finished";
|
||||
createdAt: Date;
|
||||
creatorName: string;
|
||||
isLocked: boolean;
|
||||
memberCount?: number;
|
||||
playerCount?: number;
|
||||
isMember?: boolean;
|
||||
id: string
|
||||
code: string
|
||||
name: string | null
|
||||
gameName: string
|
||||
status: 'lobby' | 'playing' | 'finished'
|
||||
createdAt: Date
|
||||
creatorName: string
|
||||
isLocked: boolean
|
||||
accessMode: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
|
||||
memberCount?: number
|
||||
playerCount?: number
|
||||
isMember?: boolean
|
||||
}
|
||||
|
||||
export default function RoomBrowserPage() {
|
||||
const router = useRouter();
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const router = useRouter()
|
||||
const { showError, showInfo } = useToast()
|
||||
const [rooms, setRooms] = useState<Room[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchRooms();
|
||||
}, []);
|
||||
fetchRooms()
|
||||
}, [])
|
||||
|
||||
const fetchRooms = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch("/api/arcade/rooms");
|
||||
setLoading(true)
|
||||
const response = await fetch('/api/arcade/rooms')
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const data = await response.json();
|
||||
setRooms(data.rooms);
|
||||
setError(null);
|
||||
const data = await response.json()
|
||||
setRooms(data.rooms)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch rooms:", err);
|
||||
setError("Failed to load rooms");
|
||||
console.error('Failed to fetch rooms:', err)
|
||||
setError('Failed to load rooms')
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const createRoom = async (name: string, gameName: string) => {
|
||||
const createRoom = async (name: string | null, gameName: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/arcade/rooms", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
const response = await fetch('/api/arcade/rooms', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
gameName,
|
||||
creatorName: "Player",
|
||||
creatorName: 'Player',
|
||||
gameConfig: { difficulty: 6 },
|
||||
}),
|
||||
});
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
router.push(`/arcade-rooms/${data.room.id}`);
|
||||
const data = await response.json()
|
||||
router.push(`/join/${data.room.code}`)
|
||||
} catch (err) {
|
||||
console.error("Failed to create room:", err);
|
||||
alert("Failed to create room");
|
||||
console.error('Failed to create room:', err)
|
||||
showError('Failed to create room', err instanceof Error ? err.message : undefined)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const joinRoom = async (roomId: string) => {
|
||||
const joinRoom = async (room: Room) => {
|
||||
try {
|
||||
const response = await fetch(`/api/arcade/rooms/${roomId}/join`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ displayName: "Player" }),
|
||||
});
|
||||
// Check access mode
|
||||
if (room.accessMode === 'password') {
|
||||
const password = prompt(`Enter password for ${room.name || `Room ${room.code}`}:`)
|
||||
if (!password) return // User cancelled
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const response = await fetch(`/api/arcade/rooms/${room.id}/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ displayName: 'Player', password }),
|
||||
})
|
||||
|
||||
// Handle specific room membership conflict
|
||||
if (errorData.code === "ROOM_MEMBERSHIP_CONFLICT") {
|
||||
alert(errorData.userMessage || errorData.message);
|
||||
// Refresh the page to update room list state
|
||||
await fetchRooms();
|
||||
return;
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
showError('Failed to join room', errorData.error)
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||
router.push(`/arcade-rooms/${room.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (room.accessMode === 'approval-only') {
|
||||
showInfo(
|
||||
'Approval Required',
|
||||
'This room requires host approval. Please use the Join Room modal to request access.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (room.accessMode === 'restricted') {
|
||||
showInfo(
|
||||
'Invitation Only',
|
||||
'This room is invitation-only. Please ask the host for an invitation.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// For open rooms
|
||||
const response = await fetch(`/api/arcade/rooms/${room.id}/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ displayName: 'Player' }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
|
||||
// Handle specific room membership conflict
|
||||
if (errorData.code === 'ROOM_MEMBERSHIP_CONFLICT') {
|
||||
showError('Already in Another Room', errorData.userMessage || errorData.message)
|
||||
// Refresh the page to update room list state
|
||||
await fetchRooms()
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(errorData.error || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Show notification if user was auto-removed from other rooms
|
||||
if (data.autoLeave) {
|
||||
console.log(`[Room Join] ${data.autoLeave.message}`);
|
||||
console.log(`[Room Join] ${data.autoLeave.message}`)
|
||||
// Could show a toast notification here in the future
|
||||
}
|
||||
|
||||
router.push(`/arcade-rooms/${roomId}`);
|
||||
router.push(`/arcade-rooms/${room.id}`)
|
||||
} catch (err) {
|
||||
console.error("Failed to join room:", err);
|
||||
alert("Failed to join room");
|
||||
console.error('Failed to join room:', err)
|
||||
showError('Failed to join room', err instanceof Error ? err.message : undefined)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav>
|
||||
<div
|
||||
className={css({
|
||||
minH: "calc(100vh - 80px)",
|
||||
bg: "linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)",
|
||||
p: "8",
|
||||
minH: 'calc(100vh - 80px)',
|
||||
bg: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
|
||||
p: '8',
|
||||
})}
|
||||
>
|
||||
<div className={css({ maxW: "1200px", mx: "auto" })}>
|
||||
<div className={css({ maxW: '1200px', mx: 'auto' })}>
|
||||
{/* Header */}
|
||||
<div className={css({ mb: "8", textAlign: "center" })}>
|
||||
<div className={css({ mb: '8', textAlign: 'center' })}>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: "4xl",
|
||||
fontWeight: "bold",
|
||||
color: "white",
|
||||
mb: "4",
|
||||
fontSize: '4xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '4',
|
||||
})}
|
||||
>
|
||||
🎮 Multiplayer Rooms
|
||||
</h1>
|
||||
<p className={css({ color: "#a0a0ff", fontSize: "lg", mb: "6" })}>
|
||||
<p className={css({ color: '#a0a0ff', fontSize: 'lg', mb: '6' })}>
|
||||
Join a room or create your own to play with friends
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className={css({
|
||||
px: "6",
|
||||
py: "3",
|
||||
bg: "#10b981",
|
||||
color: "white",
|
||||
rounded: "lg",
|
||||
fontSize: "lg",
|
||||
fontWeight: "600",
|
||||
cursor: "pointer",
|
||||
_hover: { bg: "#059669" },
|
||||
transition: "all 0.2s",
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: '#10b981',
|
||||
color: 'white',
|
||||
rounded: 'lg',
|
||||
fontSize: 'lg',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: '#059669' },
|
||||
transition: 'all 0.2s',
|
||||
})}
|
||||
>
|
||||
+ Create New Room
|
||||
@@ -156,9 +198,7 @@ export default function RoomBrowserPage() {
|
||||
|
||||
{/* Room List */}
|
||||
{loading && (
|
||||
<div
|
||||
className={css({ textAlign: "center", color: "white", py: "12" })}
|
||||
>
|
||||
<div className={css({ textAlign: 'center', color: 'white', py: '12' })}>
|
||||
Loading rooms...
|
||||
</div>
|
||||
)}
|
||||
@@ -166,12 +206,12 @@ export default function RoomBrowserPage() {
|
||||
{error && (
|
||||
<div
|
||||
className={css({
|
||||
bg: "#fef2f2",
|
||||
border: "1px solid #fecaca",
|
||||
color: "#991b1b",
|
||||
p: "4",
|
||||
rounded: "lg",
|
||||
textAlign: "center",
|
||||
bg: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
color: '#991b1b',
|
||||
p: '4',
|
||||
rounded: 'lg',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{error}
|
||||
@@ -181,80 +221,80 @@ export default function RoomBrowserPage() {
|
||||
{!loading && !error && rooms.length === 0 && (
|
||||
<div
|
||||
className={css({
|
||||
bg: "rgba(255, 255, 255, 0.05)",
|
||||
backdropFilter: "blur(10px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
rounded: "lg",
|
||||
p: "12",
|
||||
textAlign: "center",
|
||||
color: "white",
|
||||
bg: 'rgba(255, 255, 255, 0.05)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
rounded: 'lg',
|
||||
p: '12',
|
||||
textAlign: 'center',
|
||||
color: 'white',
|
||||
})}
|
||||
>
|
||||
<p className={css({ fontSize: "xl", mb: "2" })}>
|
||||
No rooms available
|
||||
</p>
|
||||
<p className={css({ color: "#a0a0ff" })}>
|
||||
Be the first to create one!
|
||||
</p>
|
||||
<p className={css({ fontSize: 'xl', mb: '2' })}>No rooms available</p>
|
||||
<p className={css({ color: '#a0a0ff' })}>Be the first to create one!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && rooms.length > 0 && (
|
||||
<div className={css({ display: "grid", gap: "4" })}>
|
||||
<div className={css({ display: 'grid', gap: '4' })}>
|
||||
{rooms.map((room) => (
|
||||
<div
|
||||
key={room.id}
|
||||
className={css({
|
||||
bg: "rgba(255, 255, 255, 0.05)",
|
||||
backdropFilter: "blur(10px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
rounded: "lg",
|
||||
p: "6",
|
||||
transition: "all 0.2s",
|
||||
bg: 'rgba(255, 255, 255, 0.05)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
rounded: 'lg',
|
||||
p: '6',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
bg: "rgba(255, 255, 255, 0.08)",
|
||||
borderColor: "rgba(255, 255, 255, 0.2)",
|
||||
bg: 'rgba(255, 255, 255, 0.08)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
onClick={() => router.push(`/arcade-rooms/${room.id}`)}
|
||||
className={css({ flex: 1, cursor: "pointer" })}
|
||||
className={css({ flex: 1, cursor: 'pointer' })}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "3",
|
||||
mb: "2",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '3',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: "2xl",
|
||||
fontWeight: "bold",
|
||||
color: "white",
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
})}
|
||||
>
|
||||
{room.name}
|
||||
{getRoomDisplayWithEmoji({
|
||||
name: room.name,
|
||||
code: room.code,
|
||||
gameName: room.gameName,
|
||||
})}
|
||||
</h3>
|
||||
<span
|
||||
className={css({
|
||||
px: "3",
|
||||
py: "1",
|
||||
bg: "rgba(255, 255, 255, 0.1)",
|
||||
color: "#fbbf24",
|
||||
rounded: "full",
|
||||
fontSize: "sm",
|
||||
fontWeight: "600",
|
||||
fontFamily: "monospace",
|
||||
px: '3',
|
||||
py: '1',
|
||||
bg: 'rgba(255, 255, 255, 0.1)',
|
||||
color: '#fbbf24',
|
||||
rounded: 'full',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'monospace',
|
||||
})}
|
||||
>
|
||||
{room.code}
|
||||
@@ -262,8 +302,8 @@ export default function RoomBrowserPage() {
|
||||
{room.isLocked && (
|
||||
<span
|
||||
className={css({
|
||||
color: "#f87171",
|
||||
fontSize: "sm",
|
||||
color: '#f87171',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
🔒 Locked
|
||||
@@ -272,11 +312,11 @@ export default function RoomBrowserPage() {
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
gap: "4",
|
||||
color: "#a0a0ff",
|
||||
fontSize: "sm",
|
||||
flexWrap: "wrap",
|
||||
display: 'flex',
|
||||
gap: '4',
|
||||
color: '#a0a0ff',
|
||||
fontSize: 'sm',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
<span>👤 Host: {room.creatorName}</span>
|
||||
@@ -284,46 +324,45 @@ export default function RoomBrowserPage() {
|
||||
{room.memberCount !== undefined && (
|
||||
<span>
|
||||
👥 {room.memberCount} member
|
||||
{room.memberCount !== 1 ? "s" : ""}
|
||||
{room.memberCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{room.playerCount !== undefined && room.playerCount > 0 && (
|
||||
<span>
|
||||
🎯 {room.playerCount} player
|
||||
{room.playerCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{room.playerCount !== undefined &&
|
||||
room.playerCount > 0 && (
|
||||
<span>
|
||||
🎯 {room.playerCount} player
|
||||
{room.playerCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={css({
|
||||
color:
|
||||
room.status === "lobby"
|
||||
? "#10b981"
|
||||
: room.status === "playing"
|
||||
? "#fbbf24"
|
||||
: "#6b7280",
|
||||
room.status === 'lobby'
|
||||
? '#10b981'
|
||||
: room.status === 'playing'
|
||||
? '#fbbf24'
|
||||
: '#6b7280',
|
||||
})}
|
||||
>
|
||||
{room.status === "lobby"
|
||||
? "⏳ Waiting"
|
||||
: room.status === "playing"
|
||||
? "🎮 Playing"
|
||||
: "✓ Finished"}
|
||||
{room.status === 'lobby'
|
||||
? '⏳ Waiting'
|
||||
: room.status === 'playing'
|
||||
? '🎮 Playing'
|
||||
: '✓ Finished'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{room.isMember ? (
|
||||
<div
|
||||
className={css({
|
||||
px: "6",
|
||||
py: "3",
|
||||
bg: "#10b981",
|
||||
color: "white",
|
||||
rounded: "lg",
|
||||
fontWeight: "600",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "2",
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: '#10b981',
|
||||
color: 'white',
|
||||
rounded: 'lg',
|
||||
fontWeight: '600',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
})}
|
||||
>
|
||||
✓ Joined
|
||||
@@ -331,24 +370,52 @@ export default function RoomBrowserPage() {
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
joinRoom(room.id);
|
||||
e.stopPropagation()
|
||||
joinRoom(room)
|
||||
}}
|
||||
disabled={room.isLocked}
|
||||
disabled={
|
||||
room.isLocked ||
|
||||
room.accessMode === 'locked' ||
|
||||
room.accessMode === 'retired'
|
||||
}
|
||||
className={css({
|
||||
px: "6",
|
||||
py: "3",
|
||||
bg: room.isLocked ? "#6b7280" : "#3b82f6",
|
||||
color: "white",
|
||||
rounded: "lg",
|
||||
fontWeight: "600",
|
||||
cursor: room.isLocked ? "not-allowed" : "pointer",
|
||||
opacity: room.isLocked ? 0.5 : 1,
|
||||
_hover: room.isLocked ? {} : { bg: "#2563eb" },
|
||||
transition: "all 0.2s",
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg:
|
||||
room.isLocked ||
|
||||
room.accessMode === 'locked' ||
|
||||
room.accessMode === 'retired'
|
||||
? '#6b7280'
|
||||
: room.accessMode === 'password'
|
||||
? '#f59e0b'
|
||||
: '#3b82f6',
|
||||
color: 'white',
|
||||
rounded: 'lg',
|
||||
fontWeight: '600',
|
||||
cursor:
|
||||
room.isLocked ||
|
||||
room.accessMode === 'locked' ||
|
||||
room.accessMode === 'retired'
|
||||
? 'not-allowed'
|
||||
: 'pointer',
|
||||
opacity:
|
||||
room.isLocked ||
|
||||
room.accessMode === 'locked' ||
|
||||
room.accessMode === 'retired'
|
||||
? 0.5
|
||||
: 1,
|
||||
_hover:
|
||||
room.isLocked ||
|
||||
room.accessMode === 'locked' ||
|
||||
room.accessMode === 'retired'
|
||||
? {}
|
||||
: room.accessMode === 'password'
|
||||
? { bg: '#d97706' }
|
||||
: { bg: '#2563eb' },
|
||||
transition: 'all 0.2s',
|
||||
})}
|
||||
>
|
||||
Join Room
|
||||
{room.accessMode === 'password' ? '🔑 Join with Password' : 'Join Room'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -362,82 +429,84 @@ export default function RoomBrowserPage() {
|
||||
{showCreateModal && (
|
||||
<div
|
||||
className={css({
|
||||
position: "fixed",
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
bg: "rgba(0, 0, 0, 0.7)",
|
||||
backdropFilter: "blur(4px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
bg: 'rgba(0, 0, 0, 0.7)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 50,
|
||||
})}
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
bg: "white",
|
||||
rounded: "xl",
|
||||
p: "8",
|
||||
maxW: "500px",
|
||||
w: "full",
|
||||
mx: "4",
|
||||
bg: 'white',
|
||||
rounded: 'xl',
|
||||
p: '8',
|
||||
maxW: '500px',
|
||||
w: 'full',
|
||||
mx: '4',
|
||||
})}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: "2xl",
|
||||
fontWeight: "bold",
|
||||
mb: "6",
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
mb: '6',
|
||||
})}
|
||||
>
|
||||
Create New Room
|
||||
</h2>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const name = formData.get("name") as string;
|
||||
const gameName = formData.get("gameName") as string;
|
||||
if (name && gameName) {
|
||||
createRoom(name, gameName);
|
||||
e.preventDefault()
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const nameValue = formData.get('name') as string
|
||||
const gameName = formData.get('gameName') as string
|
||||
// Treat empty name as null
|
||||
const name = nameValue?.trim() || null
|
||||
if (gameName) {
|
||||
createRoom(name, gameName)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={css({ mb: "4" })}>
|
||||
<div className={css({ mb: '4' })}>
|
||||
<label
|
||||
className={css({
|
||||
display: "block",
|
||||
mb: "2",
|
||||
fontWeight: "600",
|
||||
display: 'block',
|
||||
mb: '2',
|
||||
fontWeight: '600',
|
||||
})}
|
||||
>
|
||||
Room Name
|
||||
Room Name{' '}
|
||||
<span className={css({ fontWeight: '400', color: '#9ca3af' })}>(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="My Awesome Room"
|
||||
placeholder="e.g., Friday Night Games (defaults to: 🎮 CODE)"
|
||||
className={css({
|
||||
w: "full",
|
||||
px: "4",
|
||||
py: "3",
|
||||
border: "1px solid #d1d5db",
|
||||
rounded: "lg",
|
||||
_focus: { outline: "none", borderColor: "#3b82f6" },
|
||||
w: 'full',
|
||||
px: '4',
|
||||
py: '3',
|
||||
border: '1px solid #d1d5db',
|
||||
rounded: 'lg',
|
||||
_focus: { outline: 'none', borderColor: '#3b82f6' },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className={css({ mb: "6" })}>
|
||||
<div className={css({ mb: '6' })}>
|
||||
<label
|
||||
className={css({
|
||||
display: "block",
|
||||
mb: "2",
|
||||
fontWeight: "600",
|
||||
display: 'block',
|
||||
mb: '2',
|
||||
fontWeight: '600',
|
||||
})}
|
||||
>
|
||||
Game
|
||||
@@ -446,12 +515,12 @@ export default function RoomBrowserPage() {
|
||||
name="gameName"
|
||||
required
|
||||
className={css({
|
||||
w: "full",
|
||||
px: "4",
|
||||
py: "3",
|
||||
border: "1px solid #d1d5db",
|
||||
rounded: "lg",
|
||||
_focus: { outline: "none", borderColor: "#3b82f6" },
|
||||
w: 'full',
|
||||
px: '4',
|
||||
py: '3',
|
||||
border: '1px solid #d1d5db',
|
||||
rounded: 'lg',
|
||||
_focus: { outline: 'none', borderColor: '#3b82f6' },
|
||||
})}
|
||||
>
|
||||
<option value="matching">Memory Matching</option>
|
||||
@@ -459,20 +528,20 @@ export default function RoomBrowserPage() {
|
||||
<option value="complement-race">Complement Race</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className={css({ display: "flex", gap: "3" })}>
|
||||
<div className={css({ display: 'flex', gap: '3' })}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className={css({
|
||||
flex: 1,
|
||||
px: "6",
|
||||
py: "3",
|
||||
bg: "#e5e7eb",
|
||||
color: "#374151",
|
||||
rounded: "lg",
|
||||
fontWeight: "600",
|
||||
cursor: "pointer",
|
||||
_hover: { bg: "#d1d5db" },
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: '#e5e7eb',
|
||||
color: '#374151',
|
||||
rounded: 'lg',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: '#d1d5db' },
|
||||
})}
|
||||
>
|
||||
Cancel
|
||||
@@ -481,14 +550,14 @@ export default function RoomBrowserPage() {
|
||||
type="submit"
|
||||
className={css({
|
||||
flex: 1,
|
||||
px: "6",
|
||||
py: "3",
|
||||
bg: "#10b981",
|
||||
color: "white",
|
||||
rounded: "lg",
|
||||
fontWeight: "600",
|
||||
cursor: "pointer",
|
||||
_hover: { bg: "#059669" },
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: '#10b981',
|
||||
color: 'white',
|
||||
rounded: 'lg',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: '#059669' },
|
||||
})}
|
||||
>
|
||||
Create Room
|
||||
@@ -500,5 +569,5 @@ export default function RoomBrowserPage() {
|
||||
)}
|
||||
</div>
|
||||
</PageWithNav>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface SpeechBubbleProps {
|
||||
message: string;
|
||||
onHide: () => void;
|
||||
message: string
|
||||
onHide: () => void
|
||||
}
|
||||
|
||||
export function SpeechBubble({ message, onHide }: SpeechBubbleProps) {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-hide after 3.5s (line 11749-11752)
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(onHide, 300); // Wait for fade-out animation
|
||||
}, 3500);
|
||||
setIsVisible(false)
|
||||
setTimeout(onHide, 300) // Wait for fade-out animation
|
||||
}, 3500)
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [onHide]);
|
||||
return () => clearTimeout(timer)
|
||||
}, [onHide])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "calc(100% + 10px)",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
background: "white",
|
||||
borderRadius: "15px",
|
||||
padding: "10px 15px",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.2)",
|
||||
fontSize: "14px",
|
||||
whiteSpace: "nowrap",
|
||||
position: 'absolute',
|
||||
bottom: 'calc(100% + 10px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'white',
|
||||
borderRadius: '15px',
|
||||
padding: '10px 15px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
|
||||
fontSize: '14px',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transition: "opacity 0.3s ease",
|
||||
transition: 'opacity 0.3s ease',
|
||||
zIndex: 10,
|
||||
pointerEvents: "none",
|
||||
maxWidth: "250px",
|
||||
textAlign: "center",
|
||||
pointerEvents: 'none',
|
||||
maxWidth: '250px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
{/* Tail pointing down */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "-8px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
position: 'absolute',
|
||||
bottom: '-8px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: "8px solid transparent",
|
||||
borderRight: "8px solid transparent",
|
||||
borderTop: "8px solid white",
|
||||
filter: "drop-shadow(0 2px 2px rgba(0,0,0,0.1))",
|
||||
borderLeft: '8px solid transparent',
|
||||
borderRight: '8px solid transparent',
|
||||
borderTop: '8px solid white',
|
||||
filter: 'drop-shadow(0 2px 2px rgba(0,0,0,0.1))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,156 +1,154 @@
|
||||
import type { AIRacer } from "../../lib/gameTypes";
|
||||
import type { AIRacer } from '../../lib/gameTypes'
|
||||
|
||||
export type CommentaryContext =
|
||||
| "ahead"
|
||||
| "behind"
|
||||
| "adaptive_struggle"
|
||||
| "adaptive_mastery"
|
||||
| "player_passed"
|
||||
| "ai_passed"
|
||||
| "lapped"
|
||||
| "desperate_catchup";
|
||||
| 'ahead'
|
||||
| 'behind'
|
||||
| 'adaptive_struggle'
|
||||
| 'adaptive_mastery'
|
||||
| 'player_passed'
|
||||
| 'ai_passed'
|
||||
| 'lapped'
|
||||
| 'desperate_catchup'
|
||||
|
||||
// Swift AI - Competitive personality (lines 11768-11834)
|
||||
export const swiftAICommentary: Record<CommentaryContext, string[]> = {
|
||||
ahead: [
|
||||
"💨 Eat my dust!",
|
||||
"🔥 Too slow for me!",
|
||||
'💨 Eat my dust!',
|
||||
'🔥 Too slow for me!',
|
||||
"⚡ You can't catch me!",
|
||||
"🚀 I'm built for speed!",
|
||||
"🏃♂️ This is way too easy!",
|
||||
'🏃♂️ This is way too easy!',
|
||||
],
|
||||
behind: [
|
||||
"😤 Not over yet!",
|
||||
'😤 Not over yet!',
|
||||
"💪 I'm just getting started!",
|
||||
"🔥 Watch me catch up to you!",
|
||||
'🔥 Watch me catch up to you!',
|
||||
"⚡ I'm coming for you!",
|
||||
"🏃♂️ This is my comeback!",
|
||||
'🏃♂️ This is my comeback!',
|
||||
],
|
||||
adaptive_struggle: [
|
||||
"😏 You struggling much?",
|
||||
"🤖 Math is easy for me!",
|
||||
"⚡ You need to think faster!",
|
||||
"🔥 Need me to slow down?",
|
||||
'😏 You struggling much?',
|
||||
'🤖 Math is easy for me!',
|
||||
'⚡ You need to think faster!',
|
||||
'🔥 Need me to slow down?',
|
||||
],
|
||||
adaptive_mastery: [
|
||||
"😮 You're actually impressive!",
|
||||
"🤔 You're getting faster...",
|
||||
"😤 Time for me to step it up!",
|
||||
"⚡ Not bad for a human!",
|
||||
'😤 Time for me to step it up!',
|
||||
'⚡ Not bad for a human!',
|
||||
],
|
||||
player_passed: [
|
||||
"😠 No way you just passed me!",
|
||||
'😠 No way you just passed me!',
|
||||
"🔥 This isn't over!",
|
||||
"💨 I'm just getting warmed up!",
|
||||
"😤 Your lucky streak won't last!",
|
||||
"⚡ I'll be back in front of you soon!",
|
||||
],
|
||||
ai_passed: [
|
||||
"💨 See ya later, slowpoke!",
|
||||
"😎 Thanks for the warm-up!",
|
||||
'💨 See ya later, slowpoke!',
|
||||
'😎 Thanks for the warm-up!',
|
||||
"🔥 This is how it's done!",
|
||||
"⚡ I'll see you at the finish line!",
|
||||
"💪 Try to keep up with me!",
|
||||
'💪 Try to keep up with me!',
|
||||
],
|
||||
lapped: [
|
||||
"😡 You just lapped me?! No way!",
|
||||
"🤬 This is embarrassing for me!",
|
||||
'😡 You just lapped me?! No way!',
|
||||
'🤬 This is embarrassing for me!',
|
||||
"😤 I'm not going down without a fight!",
|
||||
"💢 How did you get so far ahead?!",
|
||||
"🔥 Time to show you my real speed!",
|
||||
'💢 How did you get so far ahead?!',
|
||||
'🔥 Time to show you my real speed!',
|
||||
"😠 You won't stay ahead for long!",
|
||||
],
|
||||
desperate_catchup: [
|
||||
"🚨 TURBO MODE ACTIVATED! I'm coming for you!",
|
||||
"💥 You forced me to unleash my true power!",
|
||||
"🔥 NO MORE MR. NICE AI! Time to go all out!",
|
||||
'💥 You forced me to unleash my true power!',
|
||||
'🔥 NO MORE MR. NICE AI! Time to go all out!',
|
||||
"⚡ I'm switching to MAXIMUM OVERDRIVE!",
|
||||
"😤 You made me angry - now you'll see what I can do!",
|
||||
"🚀 AFTERBURNERS ENGAGED! This isn't over!",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Math Bot - Analytical personality (lines 11835-11901)
|
||||
export const mathBotCommentary: Record<CommentaryContext, string[]> = {
|
||||
ahead: [
|
||||
"📊 My performance is optimal!",
|
||||
"🤖 My logic beats your speed!",
|
||||
"📈 I have 87% win probability!",
|
||||
'📊 My performance is optimal!',
|
||||
'🤖 My logic beats your speed!',
|
||||
'📈 I have 87% win probability!',
|
||||
"⚙️ I'm perfectly calibrated!",
|
||||
"🔬 Science prevails over you!",
|
||||
'🔬 Science prevails over you!',
|
||||
],
|
||||
behind: [
|
||||
"🤔 Recalculating my strategy...",
|
||||
'🤔 Recalculating my strategy...',
|
||||
"📊 You're exceeding my projections!",
|
||||
"⚙️ Adjusting my parameters!",
|
||||
'⚙️ Adjusting my parameters!',
|
||||
"🔬 I'm analyzing your technique!",
|
||||
"📈 You're a statistical anomaly!",
|
||||
],
|
||||
adaptive_struggle: [
|
||||
"📊 I detect inefficiencies in you!",
|
||||
"🔬 You should focus on patterns!",
|
||||
"⚙️ Use that extra time wisely!",
|
||||
"📈 You have room for improvement!",
|
||||
'📊 I detect inefficiencies in you!',
|
||||
'🔬 You should focus on patterns!',
|
||||
'⚙️ Use that extra time wisely!',
|
||||
'📈 You have room for improvement!',
|
||||
],
|
||||
adaptive_mastery: [
|
||||
"🤖 Your optimization is excellent!",
|
||||
"📊 Your metrics are impressive!",
|
||||
'🤖 Your optimization is excellent!',
|
||||
'📊 Your metrics are impressive!',
|
||||
"⚙️ I'm updating my models because of you!",
|
||||
"🔬 You have near-AI efficiency!",
|
||||
'🔬 You have near-AI efficiency!',
|
||||
],
|
||||
player_passed: [
|
||||
"🤖 Your strategy is fascinating!",
|
||||
'🤖 Your strategy is fascinating!',
|
||||
"📊 You're an unexpected variable!",
|
||||
"⚙️ I'm adjusting my algorithms...",
|
||||
"🔬 Your execution is impressive!",
|
||||
'🔬 Your execution is impressive!',
|
||||
"📈 I'm recalculating the odds!",
|
||||
],
|
||||
ai_passed: [
|
||||
"🤖 My efficiency is optimized!",
|
||||
"📊 Just as I calculated!",
|
||||
"⚙️ All my systems nominal!",
|
||||
"🔬 My logic prevails over you!",
|
||||
'🤖 My efficiency is optimized!',
|
||||
'📊 Just as I calculated!',
|
||||
'⚙️ All my systems nominal!',
|
||||
'🔬 My logic prevails over you!',
|
||||
"📈 I'm at 96% confidence level!",
|
||||
],
|
||||
lapped: [
|
||||
"🤖 Error: You have exceeded my projections!",
|
||||
"📊 This outcome has 0.3% probability!",
|
||||
"⚙️ I need to recalibrate my systems!",
|
||||
"🔬 Your performance is... statistically improbable!",
|
||||
"📈 My confidence level just dropped to 12%!",
|
||||
"🤔 I must analyze your methodology!",
|
||||
'🤖 Error: You have exceeded my projections!',
|
||||
'📊 This outcome has 0.3% probability!',
|
||||
'⚙️ I need to recalibrate my systems!',
|
||||
'🔬 Your performance is... statistically improbable!',
|
||||
'📈 My confidence level just dropped to 12%!',
|
||||
'🤔 I must analyze your methodology!',
|
||||
],
|
||||
desperate_catchup: [
|
||||
"🤖 EMERGENCY PROTOCOL ACTIVATED! Initiating maximum speed!",
|
||||
"🚨 CRITICAL GAP DETECTED! Engaging catchup algorithms!",
|
||||
"⚙️ OVERCLOCKING MY PROCESSORS! Prepare for rapid acceleration!",
|
||||
"📊 PROBABILITY OF FAILURE: UNACCEPTABLE! Switching to turbo mode!",
|
||||
'🤖 EMERGENCY PROTOCOL ACTIVATED! Initiating maximum speed!',
|
||||
'🚨 CRITICAL GAP DETECTED! Engaging catchup algorithms!',
|
||||
'⚙️ OVERCLOCKING MY PROCESSORS! Prepare for rapid acceleration!',
|
||||
'📊 PROBABILITY OF FAILURE: UNACCEPTABLE! Switching to turbo mode!',
|
||||
"🔬 HYPOTHESIS: You're about to see my true potential!",
|
||||
"📈 CONFIDENCE LEVEL: RISING! My comeback protocol is online!",
|
||||
'📈 CONFIDENCE LEVEL: RISING! My comeback protocol is online!',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Get AI commentary message (lines 11636-11657)
|
||||
export function getAICommentary(
|
||||
racer: AIRacer,
|
||||
context: CommentaryContext,
|
||||
_playerProgress: number,
|
||||
_aiProgress: number,
|
||||
_aiProgress: number
|
||||
): string | null {
|
||||
// Check cooldown (line 11759-11761)
|
||||
const now = Date.now();
|
||||
const now = Date.now()
|
||||
if (now - racer.lastComment < racer.commentCooldown) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
// Select message set based on personality and context
|
||||
const messages =
|
||||
racer.personality === "competitive"
|
||||
? swiftAICommentary[context]
|
||||
: mathBotCommentary[context];
|
||||
racer.personality === 'competitive' ? swiftAICommentary[context] : mathBotCommentary[context]
|
||||
|
||||
if (!messages || messages.length === 0) return null;
|
||||
if (!messages || messages.length === 0) return null
|
||||
|
||||
// Return random message
|
||||
return messages[Math.floor(Math.random() * messages.length)];
|
||||
return messages[Math.floor(Math.random() * messages.length)]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { AbacusReact } from "@soroban/abacus-react";
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
|
||||
interface AbacusTargetProps {
|
||||
number: number; // The complement number to display
|
||||
number: number // The complement number to display
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -14,9 +14,9 @@ export function AbacusTarget({ number }: AbacusTargetProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 0,
|
||||
}}
|
||||
>
|
||||
@@ -32,5 +32,5 @@ export function AbacusTarget({ number }: AbacusTargetProps) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useComplementRace } from "../context/ComplementRaceContext";
|
||||
import { GameControls } from "./GameControls";
|
||||
import { GameCountdown } from "./GameCountdown";
|
||||
import { GameDisplay } from "./GameDisplay";
|
||||
import { GameIntro } from "./GameIntro";
|
||||
import { GameResults } from "./GameResults";
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { GameControls } from './GameControls'
|
||||
import { GameCountdown } from './GameCountdown'
|
||||
import { GameDisplay } from './GameDisplay'
|
||||
import { GameIntro } from './GameIntro'
|
||||
import { GameResults } from './GameResults'
|
||||
|
||||
export function ComplementRaceGame() {
|
||||
const { state } = useComplementRace();
|
||||
const { state } = useComplementRace()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="game-page-root"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
padding: "20px 8px",
|
||||
minHeight: "100vh",
|
||||
maxHeight: "100vh",
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
padding: '20px 8px',
|
||||
minHeight: '100vh',
|
||||
maxHeight: '100vh',
|
||||
background:
|
||||
state.style === "sprint"
|
||||
? "linear-gradient(to bottom, #2563eb 0%, #60a5fa 100%)"
|
||||
: "radial-gradient(ellipse at center, #8db978 0%, #7ba565 40%, #6a9354 100%)",
|
||||
position: "relative",
|
||||
state.style === 'sprint'
|
||||
? 'linear-gradient(to bottom, #2563eb 0%, #60a5fa 100%)'
|
||||
: 'radial-gradient(ellipse at center, #8db978 0%, #7ba565 40%, #6a9354 100%)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Background pattern - subtle grass texture */}
|
||||
{state.style !== "sprint" && (
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: "hidden",
|
||||
pointerEvents: "none",
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
opacity: 0.15,
|
||||
}}
|
||||
@@ -51,15 +51,7 @@ export function ComplementRaceGame() {
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<rect width="40" height="40" fill="transparent" />
|
||||
<line
|
||||
x1="2"
|
||||
y1="5"
|
||||
x2="8"
|
||||
y2="5"
|
||||
stroke="#2d5016"
|
||||
strokeWidth="1"
|
||||
opacity="0.3"
|
||||
/>
|
||||
<line x1="2" y1="5" x2="8" y2="5" stroke="#2d5016" strokeWidth="1" opacity="0.3" />
|
||||
<line
|
||||
x1="15"
|
||||
y1="8"
|
||||
@@ -122,214 +114,206 @@ export function ComplementRaceGame() {
|
||||
)}
|
||||
|
||||
{/* Subtle tree clusters around edges - top-down view with gentle sway */}
|
||||
{state.style !== "sprint" && (
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: "hidden",
|
||||
pointerEvents: "none",
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
{/* Top-left tree cluster */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "5%",
|
||||
left: "3%",
|
||||
width: "80px",
|
||||
height: "80px",
|
||||
borderRadius: "50%",
|
||||
background:
|
||||
"radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)",
|
||||
position: 'absolute',
|
||||
top: '5%',
|
||||
left: '3%',
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.2,
|
||||
filter: "blur(4px)",
|
||||
animation: "treeSway1 8s ease-in-out infinite",
|
||||
filter: 'blur(4px)',
|
||||
animation: 'treeSway1 8s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Top-right tree cluster */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "8%",
|
||||
right: "5%",
|
||||
width: "100px",
|
||||
height: "100px",
|
||||
borderRadius: "50%",
|
||||
background:
|
||||
"radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)",
|
||||
position: 'absolute',
|
||||
top: '8%',
|
||||
right: '5%',
|
||||
width: '100px',
|
||||
height: '100px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.18,
|
||||
filter: "blur(5px)",
|
||||
animation: "treeSway2 10s ease-in-out infinite",
|
||||
filter: 'blur(5px)',
|
||||
animation: 'treeSway2 10s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Bottom-left tree cluster */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "10%",
|
||||
left: "8%",
|
||||
width: "90px",
|
||||
height: "90px",
|
||||
borderRadius: "50%",
|
||||
background:
|
||||
"radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)",
|
||||
position: 'absolute',
|
||||
bottom: '10%',
|
||||
left: '8%',
|
||||
width: '90px',
|
||||
height: '90px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.15,
|
||||
filter: "blur(4px)",
|
||||
animation: "treeSway1 9s ease-in-out infinite reverse",
|
||||
filter: 'blur(4px)',
|
||||
animation: 'treeSway1 9s ease-in-out infinite reverse',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Bottom-right tree cluster */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "5%",
|
||||
right: "4%",
|
||||
width: "110px",
|
||||
height: "110px",
|
||||
borderRadius: "50%",
|
||||
background:
|
||||
"radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)",
|
||||
position: 'absolute',
|
||||
bottom: '5%',
|
||||
right: '4%',
|
||||
width: '110px',
|
||||
height: '110px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.2,
|
||||
filter: "blur(6px)",
|
||||
animation: "treeSway2 11s ease-in-out infinite",
|
||||
filter: 'blur(6px)',
|
||||
animation: 'treeSway2 11s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Additional smaller clusters for depth */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "40%",
|
||||
left: "2%",
|
||||
width: "60px",
|
||||
height: "60px",
|
||||
borderRadius: "50%",
|
||||
background:
|
||||
"radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)",
|
||||
position: 'absolute',
|
||||
top: '40%',
|
||||
left: '2%',
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.12,
|
||||
filter: "blur(3px)",
|
||||
animation: "treeSway1 7s ease-in-out infinite",
|
||||
filter: 'blur(3px)',
|
||||
animation: 'treeSway1 7s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "55%",
|
||||
right: "3%",
|
||||
width: "70px",
|
||||
height: "70px",
|
||||
borderRadius: "50%",
|
||||
background:
|
||||
"radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)",
|
||||
position: 'absolute',
|
||||
top: '55%',
|
||||
right: '3%',
|
||||
width: '70px',
|
||||
height: '70px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, #4a7c3a 0%, #3d6630 60%, transparent 70%)',
|
||||
opacity: 0.14,
|
||||
filter: "blur(4px)",
|
||||
animation: "treeSway2 8.5s ease-in-out infinite reverse",
|
||||
filter: 'blur(4px)',
|
||||
animation: 'treeSway2 8.5s ease-in-out infinite reverse',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flying bird shadows - very subtle from aerial view */}
|
||||
{state.style !== "sprint" && (
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: "hidden",
|
||||
pointerEvents: "none",
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "30%",
|
||||
left: "-5%",
|
||||
width: "15px",
|
||||
height: "8px",
|
||||
background: "rgba(0, 0, 0, 0.08)",
|
||||
borderRadius: "50%",
|
||||
filter: "blur(2px)",
|
||||
animation: "birdFly1 20s linear infinite",
|
||||
position: 'absolute',
|
||||
top: '30%',
|
||||
left: '-5%',
|
||||
width: '15px',
|
||||
height: '8px',
|
||||
background: 'rgba(0, 0, 0, 0.08)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(2px)',
|
||||
animation: 'birdFly1 20s linear infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "60%",
|
||||
left: "-5%",
|
||||
width: "12px",
|
||||
height: "6px",
|
||||
background: "rgba(0, 0, 0, 0.06)",
|
||||
borderRadius: "50%",
|
||||
filter: "blur(2px)",
|
||||
animation: "birdFly2 28s linear infinite",
|
||||
position: 'absolute',
|
||||
top: '60%',
|
||||
left: '-5%',
|
||||
width: '12px',
|
||||
height: '6px',
|
||||
background: 'rgba(0, 0, 0, 0.06)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(2px)',
|
||||
animation: 'birdFly2 28s linear infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "45%",
|
||||
left: "-5%",
|
||||
width: "10px",
|
||||
height: "5px",
|
||||
background: "rgba(0, 0, 0, 0.05)",
|
||||
borderRadius: "50%",
|
||||
filter: "blur(1px)",
|
||||
animation: "birdFly1 35s linear infinite",
|
||||
animationDelay: "-12s",
|
||||
position: 'absolute',
|
||||
top: '45%',
|
||||
left: '-5%',
|
||||
width: '10px',
|
||||
height: '5px',
|
||||
background: 'rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(1px)',
|
||||
animation: 'birdFly1 35s linear infinite',
|
||||
animationDelay: '-12s',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtle cloud shadows moving across field */}
|
||||
{state.style !== "sprint" && (
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: "hidden",
|
||||
pointerEvents: "none",
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-10%",
|
||||
left: "-20%",
|
||||
width: "300px",
|
||||
height: "200px",
|
||||
background:
|
||||
"radial-gradient(ellipse, rgba(0, 0, 0, 0.03) 0%, transparent 60%)",
|
||||
borderRadius: "50%",
|
||||
filter: "blur(20px)",
|
||||
animation: "cloudShadow1 45s linear infinite",
|
||||
position: 'absolute',
|
||||
top: '-10%',
|
||||
left: '-20%',
|
||||
width: '300px',
|
||||
height: '200px',
|
||||
background: 'radial-gradient(ellipse, rgba(0, 0, 0, 0.03) 0%, transparent 60%)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(20px)',
|
||||
animation: 'cloudShadow1 45s linear infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-10%",
|
||||
left: "-20%",
|
||||
width: "250px",
|
||||
height: "180px",
|
||||
background:
|
||||
"radial-gradient(ellipse, rgba(0, 0, 0, 0.025) 0%, transparent 60%)",
|
||||
borderRadius: "50%",
|
||||
filter: "blur(25px)",
|
||||
animation: "cloudShadow2 60s linear infinite",
|
||||
animationDelay: "-20s",
|
||||
position: 'absolute',
|
||||
top: '-10%',
|
||||
left: '-20%',
|
||||
width: '250px',
|
||||
height: '180px',
|
||||
background: 'radial-gradient(ellipse, rgba(0, 0, 0, 0.025) 0%, transparent 60%)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(25px)',
|
||||
animation: 'cloudShadow2 60s linear infinite',
|
||||
animationDelay: '-20s',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -369,21 +353,21 @@ export function ComplementRaceGame() {
|
||||
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
margin: "0 auto",
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{state.gamePhase === "intro" && <GameIntro />}
|
||||
{state.gamePhase === "controls" && <GameControls />}
|
||||
{state.gamePhase === "countdown" && <GameCountdown />}
|
||||
{state.gamePhase === "playing" && <GameDisplay />}
|
||||
{state.gamePhase === "results" && <GameResults />}
|
||||
{state.gamePhase === 'intro' && <GameIntro />}
|
||||
{state.gamePhase === 'controls' && <GameControls />}
|
||||
{state.gamePhase === 'countdown' && <GameCountdown />}
|
||||
{state.gamePhase === 'playing' && <GameDisplay />}
|
||||
{state.gamePhase === 'results' && <GameResults />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,80 +1,74 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useComplementRace } from "../context/ComplementRaceContext";
|
||||
import type {
|
||||
ComplementDisplay,
|
||||
GameMode,
|
||||
GameStyle,
|
||||
TimeoutSetting,
|
||||
} from "../lib/gameTypes";
|
||||
import { AbacusTarget } from "./AbacusTarget";
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import type { ComplementDisplay, GameMode, GameStyle, TimeoutSetting } from '../lib/gameTypes'
|
||||
import { AbacusTarget } from './AbacusTarget'
|
||||
|
||||
export function GameControls() {
|
||||
const { state, dispatch } = useComplementRace();
|
||||
const { state, dispatch } = useComplementRace()
|
||||
|
||||
const handleModeSelect = (mode: GameMode) => {
|
||||
dispatch({ type: "SET_MODE", mode });
|
||||
};
|
||||
dispatch({ type: 'SET_MODE', mode })
|
||||
}
|
||||
|
||||
const handleStyleSelect = (style: GameStyle) => {
|
||||
dispatch({ type: "SET_STYLE", style });
|
||||
dispatch({ type: 'SET_STYLE', style })
|
||||
// Start the game immediately - no navigation needed
|
||||
if (style === "sprint") {
|
||||
dispatch({ type: "BEGIN_GAME" });
|
||||
if (style === 'sprint') {
|
||||
dispatch({ type: 'BEGIN_GAME' })
|
||||
} else {
|
||||
dispatch({ type: "START_COUNTDOWN" });
|
||||
dispatch({ type: 'START_COUNTDOWN' })
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleTimeoutSelect = (timeout: TimeoutSetting) => {
|
||||
dispatch({ type: "SET_TIMEOUT", timeout });
|
||||
};
|
||||
dispatch({ type: 'SET_TIMEOUT', timeout })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background:
|
||||
"linear-gradient(to bottom, #0f172a 0%, #1e293b 50%, #334155 100%)",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'linear-gradient(to bottom, #0f172a 0%, #1e293b 50%, #334155 100%)',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Animated background pattern */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 20% 50%, rgba(59, 130, 246, 0.1) 0%, transparent 50%), radial-gradient(circle at 80% 80%, rgba(139, 92, 246, 0.1) 0%, transparent 50%)",
|
||||
pointerEvents: "none",
|
||||
'radial-gradient(circle at 20% 50%, rgba(59, 130, 246, 0.1) 0%, transparent 50%), radial-gradient(circle at 80% 80%, rgba(139, 92, 246, 0.1) 0%, transparent 50%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "20px",
|
||||
position: "relative",
|
||||
textAlign: 'center',
|
||||
padding: '20px',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "32px",
|
||||
fontWeight: "bold",
|
||||
background: "linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
backgroundClip: "text",
|
||||
fontSize: '32px',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
margin: 0,
|
||||
letterSpacing: "-0.5px",
|
||||
letterSpacing: '-0.5px',
|
||||
}}
|
||||
>
|
||||
Complement Race
|
||||
@@ -84,73 +78,73 @@ export function GameControls() {
|
||||
{/* Settings Bar */}
|
||||
<div
|
||||
style={{
|
||||
padding: "0 20px 16px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
position: "relative",
|
||||
padding: '0 20px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Number Mode & Display */}
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(30, 41, 59, 0.8)",
|
||||
backdropFilter: "blur(20px)",
|
||||
borderRadius: "16px",
|
||||
padding: "16px",
|
||||
border: "1px solid rgba(148, 163, 184, 0.2)",
|
||||
background: 'rgba(30, 41, 59, 0.8)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderRadius: '16px',
|
||||
padding: '16px',
|
||||
border: '1px solid rgba(148, 163, 184, 0.2)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "20px",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
display: 'flex',
|
||||
gap: '20px',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Number Mode Pills */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
alignItems: "center",
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: "200px",
|
||||
minWidth: '200px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
color: "#94a3b8",
|
||||
fontWeight: "600",
|
||||
marginRight: "4px",
|
||||
fontSize: '13px',
|
||||
color: '#94a3b8',
|
||||
fontWeight: '600',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
>
|
||||
Mode:
|
||||
</span>
|
||||
{[
|
||||
{ mode: "friends5" as GameMode, label: "5" },
|
||||
{ mode: "friends10" as GameMode, label: "10" },
|
||||
{ mode: "mixed" as GameMode, label: "Mix" },
|
||||
{ mode: 'friends5' as GameMode, label: '5' },
|
||||
{ mode: 'friends10' as GameMode, label: '10' },
|
||||
{ mode: 'mixed' as GameMode, label: 'Mix' },
|
||||
].map(({ mode, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => handleModeSelect(mode)}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
borderRadius: "20px",
|
||||
border: "none",
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
border: 'none',
|
||||
background:
|
||||
state.mode === mode
|
||||
? "linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)"
|
||||
: "rgba(148, 163, 184, 0.2)",
|
||||
color: state.mode === mode ? "white" : "#94a3b8",
|
||||
fontWeight: "bold",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s ease",
|
||||
fontSize: "13px",
|
||||
? 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)'
|
||||
: 'rgba(148, 163, 184, 0.2)',
|
||||
color: state.mode === mode ? 'white' : '#94a3b8',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
@@ -161,117 +155,106 @@ export function GameControls() {
|
||||
{/* Complement Display Pills */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
alignItems: "center",
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: "200px",
|
||||
minWidth: '200px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
color: "#94a3b8",
|
||||
fontWeight: "600",
|
||||
marginRight: "4px",
|
||||
fontSize: '13px',
|
||||
color: '#94a3b8',
|
||||
fontWeight: '600',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
>
|
||||
Show:
|
||||
</span>
|
||||
{(["number", "abacus", "random"] as ComplementDisplay[]).map(
|
||||
(displayMode) => (
|
||||
<button
|
||||
key={displayMode}
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "SET_COMPLEMENT_DISPLAY",
|
||||
display: displayMode,
|
||||
})
|
||||
}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
borderRadius: "20px",
|
||||
border: "none",
|
||||
background:
|
||||
state.complementDisplay === displayMode
|
||||
? "linear-gradient(135deg, #10b981 0%, #059669 100%)"
|
||||
: "rgba(148, 163, 184, 0.2)",
|
||||
color:
|
||||
state.complementDisplay === displayMode
|
||||
? "white"
|
||||
: "#94a3b8",
|
||||
fontWeight: "bold",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s ease",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
>
|
||||
{displayMode === "number"
|
||||
? "123"
|
||||
: displayMode === "abacus"
|
||||
? "🧮"
|
||||
: "🎲"}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
{(['number', 'abacus', 'random'] as ComplementDisplay[]).map((displayMode) => (
|
||||
<button
|
||||
key={displayMode}
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: 'SET_COMPLEMENT_DISPLAY',
|
||||
display: displayMode,
|
||||
})
|
||||
}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
border: 'none',
|
||||
background:
|
||||
state.complementDisplay === displayMode
|
||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
: 'rgba(148, 163, 184, 0.2)',
|
||||
color: state.complementDisplay === displayMode ? 'white' : '#94a3b8',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{displayMode === 'number' ? '123' : displayMode === 'abacus' ? '🧮' : '🎲'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Speed Pills */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "6px",
|
||||
alignItems: "center",
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: "200px",
|
||||
flexWrap: "wrap",
|
||||
minWidth: '200px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
color: "#94a3b8",
|
||||
fontWeight: "600",
|
||||
marginRight: "4px",
|
||||
fontSize: '13px',
|
||||
color: '#94a3b8',
|
||||
fontWeight: '600',
|
||||
marginRight: '4px',
|
||||
}}
|
||||
>
|
||||
Speed:
|
||||
</span>
|
||||
{(
|
||||
[
|
||||
"preschool",
|
||||
"kindergarten",
|
||||
"relaxed",
|
||||
"slow",
|
||||
"normal",
|
||||
"fast",
|
||||
"expert",
|
||||
'preschool',
|
||||
'kindergarten',
|
||||
'relaxed',
|
||||
'slow',
|
||||
'normal',
|
||||
'fast',
|
||||
'expert',
|
||||
] as TimeoutSetting[]
|
||||
).map((timeout) => (
|
||||
<button
|
||||
key={timeout}
|
||||
onClick={() => handleTimeoutSelect(timeout)}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
borderRadius: "16px",
|
||||
border: "none",
|
||||
padding: '6px 12px',
|
||||
borderRadius: '16px',
|
||||
border: 'none',
|
||||
background:
|
||||
state.timeoutSetting === timeout
|
||||
? "linear-gradient(135deg, #ec4899 0%, #be185d 100%)"
|
||||
: "rgba(148, 163, 184, 0.2)",
|
||||
color:
|
||||
state.timeoutSetting === timeout ? "white" : "#94a3b8",
|
||||
fontWeight:
|
||||
state.timeoutSetting === timeout ? "bold" : "normal",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s ease",
|
||||
fontSize: "11px",
|
||||
? 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)'
|
||||
: 'rgba(148, 163, 184, 0.2)',
|
||||
color: state.timeoutSetting === timeout ? 'white' : '#94a3b8',
|
||||
fontWeight: state.timeoutSetting === timeout ? 'bold' : 'normal',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '11px',
|
||||
}}
|
||||
>
|
||||
{timeout === "preschool"
|
||||
? "Pre"
|
||||
: timeout === "kindergarten"
|
||||
? "K"
|
||||
{timeout === 'preschool'
|
||||
? 'Pre'
|
||||
: timeout === 'kindergarten'
|
||||
? 'K'
|
||||
: timeout.charAt(0).toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
@@ -281,59 +264,51 @@ export function GameControls() {
|
||||
{/* Preview - compact */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: "12px",
|
||||
padding: "12px",
|
||||
borderRadius: "12px",
|
||||
background: "rgba(15, 23, 42, 0.6)",
|
||||
border: "1px solid rgba(148, 163, 184, 0.2)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "12px",
|
||||
marginTop: '12px',
|
||||
padding: '12px',
|
||||
borderRadius: '12px',
|
||||
background: 'rgba(15, 23, 42, 0.6)',
|
||||
border: '1px solid rgba(148, 163, 184, 0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{ fontSize: "11px", color: "#94a3b8", fontWeight: "600" }}
|
||||
>
|
||||
Preview:
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: '#94a3b8', fontWeight: '600' }}>Preview:</span>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
fontSize: "20px",
|
||||
fontWeight: "bold",
|
||||
color: "white",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #3b82f6, #8b5cf6)",
|
||||
color: "white",
|
||||
padding: "2px 10px",
|
||||
borderRadius: "6px",
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
color: 'white',
|
||||
padding: '2px 10px',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
>
|
||||
?
|
||||
</div>
|
||||
<span style={{ fontSize: "16px", color: "#64748b" }}>+</span>
|
||||
{state.complementDisplay === "number" ? (
|
||||
<span style={{ fontSize: '16px', color: '#64748b' }}>+</span>
|
||||
{state.complementDisplay === 'number' ? (
|
||||
<span>3</span>
|
||||
) : state.complementDisplay === "abacus" ? (
|
||||
<div style={{ transform: "scale(0.8)" }}>
|
||||
) : state.complementDisplay === 'abacus' ? (
|
||||
<div style={{ transform: 'scale(0.8)' }}>
|
||||
<AbacusTarget number={3} />
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: "14px" }}>🎲</span>
|
||||
<span style={{ fontSize: '14px' }}>🎲</span>
|
||||
)}
|
||||
<span style={{ fontSize: "16px", color: "#64748b" }}>=</span>
|
||||
<span style={{ color: "#10b981" }}>
|
||||
{state.mode === "friends5"
|
||||
? "5"
|
||||
: state.mode === "friends10"
|
||||
? "10"
|
||||
: "?"}
|
||||
<span style={{ fontSize: '16px', color: '#64748b' }}>=</span>
|
||||
<span style={{ color: '#10b981' }}>
|
||||
{state.mode === 'friends5' ? '5' : state.mode === 'friends10' ? '10' : '?'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -345,173 +320,161 @@ export function GameControls() {
|
||||
data-component="race-cards-container"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "0 20px 20px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "16px",
|
||||
position: "relative",
|
||||
padding: '0 20px 20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
overflow: "auto",
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{
|
||||
style: "practice" as GameStyle,
|
||||
emoji: "🏁",
|
||||
title: "Practice Race",
|
||||
desc: "Race against AI to 20 correct answers",
|
||||
gradient: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
|
||||
shadowColor: "rgba(16, 185, 129, 0.5)",
|
||||
accentColor: "#34d399",
|
||||
style: 'practice' as GameStyle,
|
||||
emoji: '🏁',
|
||||
title: 'Practice Race',
|
||||
desc: 'Race against AI to 20 correct answers',
|
||||
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
shadowColor: 'rgba(16, 185, 129, 0.5)',
|
||||
accentColor: '#34d399',
|
||||
},
|
||||
{
|
||||
style: "sprint" as GameStyle,
|
||||
emoji: "🚂",
|
||||
title: "Steam Sprint",
|
||||
desc: "High-speed 60-second train journey",
|
||||
gradient: "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)",
|
||||
shadowColor: "rgba(245, 158, 11, 0.5)",
|
||||
accentColor: "#fbbf24",
|
||||
style: 'sprint' as GameStyle,
|
||||
emoji: '🚂',
|
||||
title: 'Steam Sprint',
|
||||
desc: 'High-speed 60-second train journey',
|
||||
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
shadowColor: 'rgba(245, 158, 11, 0.5)',
|
||||
accentColor: '#fbbf24',
|
||||
},
|
||||
{
|
||||
style: "survival" as GameStyle,
|
||||
emoji: "🔄",
|
||||
title: "Survival Circuit",
|
||||
desc: "Endless laps - beat your best time",
|
||||
gradient: "linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)",
|
||||
shadowColor: "rgba(139, 92, 246, 0.5)",
|
||||
accentColor: "#a78bfa",
|
||||
style: 'survival' as GameStyle,
|
||||
emoji: '🔄',
|
||||
title: 'Survival Circuit',
|
||||
desc: 'Endless laps - beat your best time',
|
||||
gradient: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)',
|
||||
shadowColor: 'rgba(139, 92, 246, 0.5)',
|
||||
accentColor: '#a78bfa',
|
||||
},
|
||||
].map(
|
||||
({
|
||||
style,
|
||||
emoji,
|
||||
title,
|
||||
desc,
|
||||
gradient,
|
||||
shadowColor,
|
||||
accentColor,
|
||||
}) => (
|
||||
<button
|
||||
key={style}
|
||||
onClick={() => handleStyleSelect(style)}
|
||||
].map(({ style, emoji, title, desc, gradient, shadowColor, accentColor }) => (
|
||||
<button
|
||||
key={style}
|
||||
onClick={() => handleStyleSelect(style)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: '0',
|
||||
border: 'none',
|
||||
borderRadius: '24px',
|
||||
background: gradient,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: `0 10px 40px ${shadowColor}, 0 0 0 1px rgba(255, 255, 255, 0.1)`,
|
||||
transform: 'translateY(0)',
|
||||
flex: 1,
|
||||
minHeight: '140px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-8px) scale(1.02)'
|
||||
e.currentTarget.style.boxShadow = `0 20px 60px ${shadowColor}, 0 0 0 2px ${accentColor}`
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0) scale(1)'
|
||||
e.currentTarget.style.boxShadow = `0 10px 40px ${shadowColor}, 0 0 0 1px rgba(255, 255, 255, 0.1)`
|
||||
}}
|
||||
>
|
||||
{/* Shine effect overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
padding: "0",
|
||||
border: "none",
|
||||
borderRadius: "24px",
|
||||
background: gradient,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
boxShadow: `0 10px 40px ${shadowColor}, 0 0 0 1px rgba(255, 255, 255, 0.1)`,
|
||||
transform: "translateY(0)",
|
||||
flex: 1,
|
||||
minHeight: "140px",
|
||||
overflow: "hidden",
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.2) 0%, transparent 50%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform =
|
||||
"translateY(-8px) scale(1.02)";
|
||||
e.currentTarget.style.boxShadow = `0 20px 60px ${shadowColor}, 0 0 0 2px ${accentColor}`;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "translateY(0) scale(1)";
|
||||
e.currentTarget.style.boxShadow = `0 10px 40px ${shadowColor}, 0 0 0 1px rgba(255, 255, 255, 0.1)`;
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '28px 32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Shine effect overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(255,255,255,0.2) 0%, transparent 50%)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: "28px 32px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '20px',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "20px",
|
||||
flex: 1,
|
||||
fontSize: '64px',
|
||||
lineHeight: 1,
|
||||
filter: 'drop-shadow(0 4px 8px rgba(0,0,0,0.3))',
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
</div>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "64px",
|
||||
lineHeight: 1,
|
||||
filter: "drop-shadow(0 4px 8px rgba(0,0,0,0.3))",
|
||||
fontSize: '28px',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
marginBottom: '6px',
|
||||
textShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
{title}
|
||||
</div>
|
||||
<div style={{ textAlign: "left", flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "28px",
|
||||
fontWeight: "bold",
|
||||
color: "white",
|
||||
marginBottom: "6px",
|
||||
textShadow: "0 2px 8px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "15px",
|
||||
color: "rgba(255, 255, 255, 0.9)",
|
||||
textShadow: "0 1px 4px rgba(0,0,0,0.2)",
|
||||
}}
|
||||
>
|
||||
{desc}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
textShadow: '0 1px 4px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
>
|
||||
{desc}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PLAY NOW button */}
|
||||
<div
|
||||
style={{
|
||||
background: "white",
|
||||
color: gradient.includes("10b981")
|
||||
? "#047857"
|
||||
: gradient.includes("f59e0b")
|
||||
? "#d97706"
|
||||
: "#6b21a8",
|
||||
padding: "16px 32px",
|
||||
borderRadius: "16px",
|
||||
fontWeight: "bold",
|
||||
fontSize: "18px",
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.25)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<span>PLAY</span>
|
||||
<span style={{ fontSize: "24px" }}>▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
|
||||
{/* PLAY NOW button */}
|
||||
<div
|
||||
style={{
|
||||
background: 'white',
|
||||
color: gradient.includes('10b981')
|
||||
? '#047857'
|
||||
: gradient.includes('f59e0b')
|
||||
? '#d97706'
|
||||
: '#6b21a8',
|
||||
padding: '16px 32px',
|
||||
borderRadius: '16px',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '18px',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.25)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<span>PLAY</span>
|
||||
<span style={{ fontSize: '24px' }}>▶</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,83 +1,83 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useComplementRace } from "../context/ComplementRaceContext";
|
||||
import { useSoundEffects } from "../hooks/useSoundEffects";
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { useSoundEffects } from '../hooks/useSoundEffects'
|
||||
|
||||
export function GameCountdown() {
|
||||
const { dispatch } = useComplementRace();
|
||||
const { playSound } = useSoundEffects();
|
||||
const [count, setCount] = useState(3);
|
||||
const [showGo, setShowGo] = useState(false);
|
||||
const { dispatch } = useComplementRace()
|
||||
const { playSound } = useSoundEffects()
|
||||
const [count, setCount] = useState(3)
|
||||
const [showGo, setShowGo] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const countdownInterval = setInterval(() => {
|
||||
setCount((prevCount) => {
|
||||
if (prevCount > 1) {
|
||||
// Play countdown beep (volume 0.4)
|
||||
playSound("countdown", 0.4);
|
||||
return prevCount - 1;
|
||||
playSound('countdown', 0.4)
|
||||
return prevCount - 1
|
||||
} else if (prevCount === 1) {
|
||||
// Show GO!
|
||||
setShowGo(true);
|
||||
setShowGo(true)
|
||||
// Play race start fanfare (volume 0.6)
|
||||
playSound("race_start", 0.6);
|
||||
return 0;
|
||||
playSound('race_start', 0.6)
|
||||
return 0
|
||||
}
|
||||
return prevCount;
|
||||
});
|
||||
}, 1000);
|
||||
return prevCount
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(countdownInterval);
|
||||
}, [playSound]);
|
||||
return () => clearInterval(countdownInterval)
|
||||
}, [playSound])
|
||||
|
||||
useEffect(() => {
|
||||
if (showGo) {
|
||||
// Hide countdown and start game after GO animation
|
||||
const timer = setTimeout(() => {
|
||||
dispatch({ type: "BEGIN_GAME" });
|
||||
}, 1000);
|
||||
dispatch({ type: 'BEGIN_GAME' })
|
||||
}, 1000)
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [showGo, dispatch]);
|
||||
}, [showGo, dispatch])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0, 0, 0, 0.9)",
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(0, 0, 0, 0.9)',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: showGo ? "120px" : "160px",
|
||||
fontWeight: "bold",
|
||||
color: showGo ? "#10b981" : "white",
|
||||
textShadow: "0 4px 20px rgba(0, 0, 0, 0.5)",
|
||||
animation: showGo ? "scaleUp 1s ease-out" : "pulse 0.5s ease-in-out",
|
||||
transition: "all 0.3s ease",
|
||||
fontSize: showGo ? '120px' : '160px',
|
||||
fontWeight: 'bold',
|
||||
color: showGo ? '#10b981' : 'white',
|
||||
textShadow: '0 4px 20px rgba(0, 0, 0, 0.5)',
|
||||
animation: showGo ? 'scaleUp 1s ease-out' : 'pulse 0.5s ease-in-out',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{showGo ? "GO!" : count}
|
||||
{showGo ? 'GO!' : count}
|
||||
</div>
|
||||
|
||||
{!showGo && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "32px",
|
||||
fontSize: "24px",
|
||||
color: "rgba(255, 255, 255, 0.8)",
|
||||
fontWeight: "500",
|
||||
marginTop: '32px',
|
||||
fontSize: '24px',
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Get Ready!
|
||||
@@ -100,5 +100,5 @@ export function GameCountdown() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,74 +1,65 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useComplementRace } from "../context/ComplementRaceContext";
|
||||
import { useAdaptiveDifficulty } from "../hooks/useAdaptiveDifficulty";
|
||||
import { useAIRacers } from "../hooks/useAIRacers";
|
||||
import { useSoundEffects } from "../hooks/useSoundEffects";
|
||||
import { useSteamJourney } from "../hooks/useSteamJourney";
|
||||
import { generatePassengers } from "../lib/passengerGenerator";
|
||||
import { AbacusTarget } from "./AbacusTarget";
|
||||
import { CircularTrack } from "./RaceTrack/CircularTrack";
|
||||
import { LinearTrack } from "./RaceTrack/LinearTrack";
|
||||
import { SteamTrainJourney } from "./RaceTrack/SteamTrainJourney";
|
||||
import { RouteCelebration } from "./RouteCelebration";
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
import { useAdaptiveDifficulty } from '../hooks/useAdaptiveDifficulty'
|
||||
import { useAIRacers } from '../hooks/useAIRacers'
|
||||
import { useSoundEffects } from '../hooks/useSoundEffects'
|
||||
import { useSteamJourney } from '../hooks/useSteamJourney'
|
||||
import { generatePassengers } from '../lib/passengerGenerator'
|
||||
import { AbacusTarget } from './AbacusTarget'
|
||||
import { CircularTrack } from './RaceTrack/CircularTrack'
|
||||
import { LinearTrack } from './RaceTrack/LinearTrack'
|
||||
import { SteamTrainJourney } from './RaceTrack/SteamTrainJourney'
|
||||
import { RouteCelebration } from './RouteCelebration'
|
||||
|
||||
type FeedbackAnimation = "correct" | "incorrect" | null;
|
||||
type FeedbackAnimation = 'correct' | 'incorrect' | null
|
||||
|
||||
export function GameDisplay() {
|
||||
const { state, dispatch } = useComplementRace();
|
||||
useAIRacers(); // Activate AI racer updates (not used in sprint mode)
|
||||
const { trackPerformance, getAdaptiveFeedbackMessage } =
|
||||
useAdaptiveDifficulty();
|
||||
const { boostMomentum } = useSteamJourney();
|
||||
const { playSound } = useSoundEffects();
|
||||
const [feedbackAnimation, setFeedbackAnimation] =
|
||||
useState<FeedbackAnimation>(null);
|
||||
const { state, dispatch } = useComplementRace()
|
||||
useAIRacers() // Activate AI racer updates (not used in sprint mode)
|
||||
const { trackPerformance, getAdaptiveFeedbackMessage } = useAdaptiveDifficulty()
|
||||
const { boostMomentum } = useSteamJourney()
|
||||
const { playSound } = useSoundEffects()
|
||||
const [feedbackAnimation, setFeedbackAnimation] = useState<FeedbackAnimation>(null)
|
||||
|
||||
// Clear feedback animation after it plays (line 1996, 2001)
|
||||
useEffect(() => {
|
||||
if (feedbackAnimation) {
|
||||
const timer = setTimeout(() => {
|
||||
setFeedbackAnimation(null);
|
||||
}, 500); // Match animation duration
|
||||
return () => clearTimeout(timer);
|
||||
setFeedbackAnimation(null)
|
||||
}, 500) // Match animation duration
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [feedbackAnimation]);
|
||||
}, [feedbackAnimation])
|
||||
|
||||
// Show adaptive feedback with auto-hide
|
||||
useEffect(() => {
|
||||
if (state.adaptiveFeedback) {
|
||||
const timer = setTimeout(() => {
|
||||
dispatch({ type: "CLEAR_ADAPTIVE_FEEDBACK" });
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
dispatch({ type: 'CLEAR_ADAPTIVE_FEEDBACK' })
|
||||
}, 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [state.adaptiveFeedback, dispatch]);
|
||||
}, [state.adaptiveFeedback, dispatch])
|
||||
|
||||
// Check for finish line (player reaches race goal) - only for practice mode
|
||||
useEffect(() => {
|
||||
if (
|
||||
state.correctAnswers >= state.raceGoal &&
|
||||
state.isGameActive &&
|
||||
state.style === "practice"
|
||||
state.style === 'practice'
|
||||
) {
|
||||
// Play celebration sound (line 14182)
|
||||
playSound("celebration");
|
||||
playSound('celebration')
|
||||
// End the game
|
||||
dispatch({ type: "END_RACE" });
|
||||
dispatch({ type: 'END_RACE' })
|
||||
// Show results after a short delay
|
||||
setTimeout(() => {
|
||||
dispatch({ type: "SHOW_RESULTS" });
|
||||
}, 1500);
|
||||
dispatch({ type: 'SHOW_RESULTS' })
|
||||
}, 1500)
|
||||
}
|
||||
}, [
|
||||
state.correctAnswers,
|
||||
state.raceGoal,
|
||||
state.isGameActive,
|
||||
state.style,
|
||||
dispatch,
|
||||
playSound,
|
||||
]);
|
||||
}, [state.correctAnswers, state.raceGoal, state.isGameActive, state.style, dispatch, playSound])
|
||||
|
||||
// For survival mode (endless circuit), track laps but never end
|
||||
// For sprint mode (steam sprint), end after 60 seconds (will implement later)
|
||||
@@ -78,109 +69,101 @@ export function GameDisplay() {
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
// Only process number keys
|
||||
if (/^[0-9]$/.test(e.key)) {
|
||||
const newInput = state.currentInput + e.key;
|
||||
dispatch({ type: "UPDATE_INPUT", input: newInput });
|
||||
const newInput = state.currentInput + e.key
|
||||
dispatch({ type: 'UPDATE_INPUT', input: newInput })
|
||||
|
||||
// Check if answer is complete
|
||||
if (state.currentQuestion) {
|
||||
const answer = parseInt(newInput, 10);
|
||||
const correctAnswer = state.currentQuestion.correctAnswer;
|
||||
const answer = parseInt(newInput, 10)
|
||||
const correctAnswer = state.currentQuestion.correctAnswer
|
||||
|
||||
// If we have enough digits to match the answer, submit
|
||||
if (newInput.length >= correctAnswer.toString().length) {
|
||||
const responseTime = Date.now() - state.questionStartTime;
|
||||
const isCorrect = answer === correctAnswer;
|
||||
const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}`;
|
||||
const responseTime = Date.now() - state.questionStartTime
|
||||
const isCorrect = answer === correctAnswer
|
||||
const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}`
|
||||
|
||||
if (isCorrect) {
|
||||
// Correct answer
|
||||
dispatch({ type: "SUBMIT_ANSWER", answer });
|
||||
trackPerformance(true, responseTime);
|
||||
dispatch({ type: 'SUBMIT_ANSWER', answer })
|
||||
trackPerformance(true, responseTime)
|
||||
|
||||
// Trigger correct answer animation (line 1996)
|
||||
setFeedbackAnimation("correct");
|
||||
setFeedbackAnimation('correct')
|
||||
|
||||
// Play appropriate sound based on performance (from web_generator.py lines 11530-11542)
|
||||
const newStreak = state.streak + 1;
|
||||
const newStreak = state.streak + 1
|
||||
if (newStreak > 0 && newStreak % 5 === 0) {
|
||||
// Epic streak sound for every 5th correct answer
|
||||
playSound("streak");
|
||||
playSound('streak')
|
||||
} else if (responseTime < 800) {
|
||||
// Whoosh sound for very fast responses (under 800ms)
|
||||
playSound("whoosh");
|
||||
playSound('whoosh')
|
||||
} else if (responseTime < 1200 && state.streak >= 3) {
|
||||
// Combo sound for rapid answers while on a streak
|
||||
playSound("combo");
|
||||
playSound('combo')
|
||||
} else {
|
||||
// Regular correct sound
|
||||
playSound("correct");
|
||||
playSound('correct')
|
||||
}
|
||||
|
||||
// Boost momentum for sprint mode
|
||||
if (state.style === "sprint") {
|
||||
boostMomentum();
|
||||
if (state.style === 'sprint') {
|
||||
boostMomentum()
|
||||
|
||||
// Play train whistle for milestones in sprint mode (line 13222-13235)
|
||||
if (newStreak >= 5 && newStreak % 3 === 0) {
|
||||
// Major milestone - play train whistle
|
||||
setTimeout(() => {
|
||||
playSound("train_whistle", 0.4);
|
||||
}, 200);
|
||||
playSound('train_whistle', 0.4)
|
||||
}, 200)
|
||||
} else if (state.momentum >= 90) {
|
||||
// High momentum celebration - occasional whistle
|
||||
if (Math.random() < 0.3) {
|
||||
setTimeout(() => {
|
||||
playSound("train_whistle", 0.25);
|
||||
}, 150);
|
||||
playSound('train_whistle', 0.25)
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show adaptive feedback
|
||||
const feedback = getAdaptiveFeedbackMessage(
|
||||
pairKey,
|
||||
true,
|
||||
responseTime,
|
||||
);
|
||||
const feedback = getAdaptiveFeedbackMessage(pairKey, true, responseTime)
|
||||
if (feedback) {
|
||||
dispatch({ type: "SHOW_ADAPTIVE_FEEDBACK", feedback });
|
||||
dispatch({ type: 'SHOW_ADAPTIVE_FEEDBACK', feedback })
|
||||
}
|
||||
|
||||
dispatch({ type: "NEXT_QUESTION" });
|
||||
dispatch({ type: 'NEXT_QUESTION' })
|
||||
} else {
|
||||
// Incorrect answer
|
||||
trackPerformance(false, responseTime);
|
||||
trackPerformance(false, responseTime)
|
||||
|
||||
// Trigger incorrect answer animation (line 2001)
|
||||
setFeedbackAnimation("incorrect");
|
||||
setFeedbackAnimation('incorrect')
|
||||
|
||||
// Play incorrect sound (from web_generator.py line 11589)
|
||||
playSound("incorrect");
|
||||
playSound('incorrect')
|
||||
|
||||
// Show adaptive feedback
|
||||
const feedback = getAdaptiveFeedbackMessage(
|
||||
pairKey,
|
||||
false,
|
||||
responseTime,
|
||||
);
|
||||
const feedback = getAdaptiveFeedbackMessage(pairKey, false, responseTime)
|
||||
if (feedback) {
|
||||
dispatch({ type: "SHOW_ADAPTIVE_FEEDBACK", feedback });
|
||||
dispatch({ type: 'SHOW_ADAPTIVE_FEEDBACK', feedback })
|
||||
}
|
||||
|
||||
dispatch({ type: "UPDATE_INPUT", input: "" });
|
||||
dispatch({ type: 'UPDATE_INPUT', input: '' })
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (e.key === "Backspace") {
|
||||
} else if (e.key === 'Backspace') {
|
||||
dispatch({
|
||||
type: "UPDATE_INPUT",
|
||||
type: 'UPDATE_INPUT',
|
||||
input: state.currentInput.slice(0, -1),
|
||||
});
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyPress);
|
||||
return () => window.removeEventListener("keydown", handleKeyPress);
|
||||
window.addEventListener('keydown', handleKeyPress)
|
||||
return () => window.removeEventListener('keydown', handleKeyPress)
|
||||
}, [
|
||||
state.currentInput,
|
||||
state.currentQuestion,
|
||||
@@ -193,34 +176,34 @@ export function GameDisplay() {
|
||||
boostMomentum,
|
||||
playSound,
|
||||
state.momentum,
|
||||
]);
|
||||
])
|
||||
|
||||
// Handle route celebration continue
|
||||
const handleContinueToNextRoute = () => {
|
||||
const nextRoute = state.currentRoute + 1;
|
||||
const nextRoute = state.currentRoute + 1
|
||||
|
||||
// Start new route (this also hides celebration)
|
||||
dispatch({
|
||||
type: "START_NEW_ROUTE",
|
||||
type: 'START_NEW_ROUTE',
|
||||
routeNumber: nextRoute,
|
||||
stations: state.stations, // Keep same stations for now
|
||||
});
|
||||
})
|
||||
|
||||
// Generate new passengers
|
||||
const newPassengers = generatePassengers(state.stations);
|
||||
dispatch({ type: "GENERATE_PASSENGERS", passengers: newPassengers });
|
||||
};
|
||||
const newPassengers = generatePassengers(state.stations)
|
||||
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
|
||||
}
|
||||
|
||||
if (!state.currentQuestion) return null;
|
||||
if (!state.currentQuestion) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="game-display"
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Adaptive Feedback */}
|
||||
@@ -228,21 +211,21 @@ export function GameDisplay() {
|
||||
<div
|
||||
data-component="adaptive-feedback"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: "80px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
color: "white",
|
||||
padding: "12px 24px",
|
||||
borderRadius: "12px",
|
||||
boxShadow: "0 4px 20px rgba(102, 126, 234, 0.4)",
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold",
|
||||
position: 'fixed',
|
||||
top: '80px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
padding: '12px 24px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 20px rgba(102, 126, 234, 0.4)',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
zIndex: 1000,
|
||||
animation: "slideDown 0.3s ease-out",
|
||||
maxWidth: "600px",
|
||||
textAlign: "center",
|
||||
animation: 'slideDown 0.3s ease-out',
|
||||
maxWidth: '600px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{state.adaptiveFeedback.message}
|
||||
@@ -250,84 +233,84 @@ export function GameDisplay() {
|
||||
)}
|
||||
|
||||
{/* Stats Header - constrained width, hidden for sprint mode */}
|
||||
{state.style !== "sprint" && (
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
data-component="stats-container"
|
||||
style={{
|
||||
maxWidth: "1200px",
|
||||
margin: "0 auto",
|
||||
width: "100%",
|
||||
padding: "0 20px",
|
||||
marginTop: "10px",
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
width: '100%',
|
||||
padding: '0 20px',
|
||||
marginTop: '10px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-component="stats-header"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-around",
|
||||
marginBottom: "10px",
|
||||
background: "white",
|
||||
borderRadius: "12px",
|
||||
padding: "10px",
|
||||
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
marginBottom: '10px',
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '10px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<div data-stat="score" style={{ textAlign: "center" }}>
|
||||
<div data-stat="score" style={{ textAlign: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
color: "#6b7280",
|
||||
fontSize: "14px",
|
||||
marginBottom: "4px",
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Score
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
fontSize: "24px",
|
||||
color: "#3b82f6",
|
||||
fontWeight: 'bold',
|
||||
fontSize: '24px',
|
||||
color: '#3b82f6',
|
||||
}}
|
||||
>
|
||||
{state.score}
|
||||
</div>
|
||||
</div>
|
||||
<div data-stat="streak" style={{ textAlign: "center" }}>
|
||||
<div data-stat="streak" style={{ textAlign: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
color: "#6b7280",
|
||||
fontSize: "14px",
|
||||
marginBottom: "4px",
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Streak
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
fontSize: "24px",
|
||||
color: "#10b981",
|
||||
fontWeight: 'bold',
|
||||
fontSize: '24px',
|
||||
color: '#10b981',
|
||||
}}
|
||||
>
|
||||
{state.streak} 🔥
|
||||
</div>
|
||||
</div>
|
||||
<div data-stat="progress" style={{ textAlign: "center" }}>
|
||||
<div data-stat="progress" style={{ textAlign: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
color: "#6b7280",
|
||||
fontSize: "14px",
|
||||
marginBottom: "4px",
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Progress
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
fontSize: "24px",
|
||||
color: "#f59e0b",
|
||||
fontWeight: 'bold',
|
||||
fontSize: '24px',
|
||||
color: '#f59e0b',
|
||||
}}
|
||||
>
|
||||
{state.correctAnswers}/{state.raceGoal}
|
||||
@@ -341,28 +324,28 @@ export function GameDisplay() {
|
||||
<div
|
||||
data-component="track-container"
|
||||
style={{
|
||||
width: "100vw",
|
||||
position: "relative",
|
||||
left: "50%",
|
||||
right: "50%",
|
||||
marginLeft: "-50vw",
|
||||
marginRight: "-50vw",
|
||||
padding: state.style === "sprint" ? "0" : "0 20px",
|
||||
display: "flex",
|
||||
justifyContent: state.style === "sprint" ? "stretch" : "center",
|
||||
background: "transparent",
|
||||
flex: state.style === "sprint" ? 1 : "initial",
|
||||
minHeight: state.style === "sprint" ? 0 : "initial",
|
||||
width: '100vw',
|
||||
position: 'relative',
|
||||
left: '50%',
|
||||
right: '50%',
|
||||
marginLeft: '-50vw',
|
||||
marginRight: '-50vw',
|
||||
padding: state.style === 'sprint' ? '0' : '0 20px',
|
||||
display: 'flex',
|
||||
justifyContent: state.style === 'sprint' ? 'stretch' : 'center',
|
||||
background: 'transparent',
|
||||
flex: state.style === 'sprint' ? 1 : 'initial',
|
||||
minHeight: state.style === 'sprint' ? 0 : 'initial',
|
||||
}}
|
||||
>
|
||||
{state.style === "survival" ? (
|
||||
{state.style === 'survival' ? (
|
||||
<CircularTrack
|
||||
playerProgress={state.correctAnswers}
|
||||
playerLap={state.playerLap}
|
||||
aiRacers={state.aiRacers}
|
||||
aiLaps={state.aiLaps}
|
||||
/>
|
||||
) : state.style === "sprint" ? (
|
||||
) : state.style === 'sprint' ? (
|
||||
<SteamTrainJourney
|
||||
momentum={state.momentum}
|
||||
trainPosition={state.trainPosition}
|
||||
@@ -382,67 +365,66 @@ export function GameDisplay() {
|
||||
</div>
|
||||
|
||||
{/* Question Display - only for non-sprint modes */}
|
||||
{state.style !== "sprint" && (
|
||||
{state.style !== 'sprint' && (
|
||||
<div
|
||||
data-component="question-container"
|
||||
style={{
|
||||
maxWidth: "1200px",
|
||||
margin: "0 auto",
|
||||
width: "100%",
|
||||
padding: "0 20px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
marginTop: "20px",
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
width: '100%',
|
||||
padding: '0 20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginTop: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-component="question-display"
|
||||
style={{
|
||||
background: "rgba(255, 255, 255, 0.98)",
|
||||
borderRadius: "24px",
|
||||
padding: "28px 50px",
|
||||
boxShadow:
|
||||
"0 16px 40px rgba(0, 0, 0, 0.3), 0 0 0 5px rgba(59, 130, 246, 0.4)",
|
||||
backdropFilter: "blur(12px)",
|
||||
border: "4px solid rgba(255, 255, 255, 0.95)",
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
borderRadius: '24px',
|
||||
padding: '28px 50px',
|
||||
boxShadow: '0 16px 40px rgba(0, 0, 0, 0.3), 0 0 0 5px rgba(59, 130, 246, 0.4)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
border: '4px solid rgba(255, 255, 255, 0.95)',
|
||||
}}
|
||||
>
|
||||
{/* Complement equation as main focus */}
|
||||
<div
|
||||
data-element="question-equation"
|
||||
style={{
|
||||
fontSize: "96px",
|
||||
fontWeight: "bold",
|
||||
color: "#1f2937",
|
||||
lineHeight: "1.1",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "20px",
|
||||
justifyContent: "center",
|
||||
fontSize: '96px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
lineHeight: '1.1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '20px',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #3b82f6, #8b5cf6)",
|
||||
color: "white",
|
||||
padding: "12px 32px",
|
||||
borderRadius: "16px",
|
||||
minWidth: "140px",
|
||||
display: "inline-block",
|
||||
textShadow: "0 3px 10px rgba(0, 0, 0, 0.3)",
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
color: 'white',
|
||||
padding: '12px 32px',
|
||||
borderRadius: '16px',
|
||||
minWidth: '140px',
|
||||
display: 'inline-block',
|
||||
textShadow: '0 3px 10px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
{state.currentInput || "?"}
|
||||
{state.currentInput || '?'}
|
||||
</span>
|
||||
<span style={{ color: "#6b7280" }}>+</span>
|
||||
<span style={{ color: '#6b7280' }}>+</span>
|
||||
{state.currentQuestion.showAsAbacus ? (
|
||||
<div
|
||||
style={{
|
||||
transform: "scale(2.4) translateY(8%)",
|
||||
transformOrigin: "center center",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transform: 'scale(2.4) translateY(8%)',
|
||||
transformOrigin: 'center center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AbacusTarget number={state.currentQuestion.number} />
|
||||
@@ -450,17 +432,15 @@ export function GameDisplay() {
|
||||
) : (
|
||||
<span>{state.currentQuestion.number}</span>
|
||||
)}
|
||||
<span style={{ color: "#6b7280" }}>=</span>
|
||||
<span style={{ color: "#10b981" }}>
|
||||
{state.currentQuestion.targetSum}
|
||||
</span>
|
||||
<span style={{ color: '#6b7280' }}>=</span>
|
||||
<span style={{ color: '#10b981' }}>{state.currentQuestion.targetSum}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route Celebration Modal */}
|
||||
{state.showRouteCelebration && state.style === "sprint" && (
|
||||
{state.showRouteCelebration && state.style === 'sprint' && (
|
||||
<RouteCelebration
|
||||
completedRouteNumber={state.currentRoute}
|
||||
nextRouteNumber={state.currentRoute + 1}
|
||||
@@ -468,5 +448,5 @@ export function GameDisplay() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useComplementRace } from "../context/ComplementRaceContext";
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
|
||||
export function GameIntro() {
|
||||
const { dispatch } = useComplementRace();
|
||||
const { dispatch } = useComplementRace()
|
||||
|
||||
const handleStartClick = () => {
|
||||
dispatch({ type: "SHOW_CONTROLS" });
|
||||
};
|
||||
dispatch({ type: 'SHOW_CONTROLS' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "40px 20px",
|
||||
maxWidth: "800px",
|
||||
margin: "20px auto 0",
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px',
|
||||
maxWidth: '800px',
|
||||
margin: '20px auto 0',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "48px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "16px",
|
||||
background: "linear-gradient(135deg, #3b82f6, #8b5cf6)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
backgroundClip: "text",
|
||||
fontSize: '48px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
>
|
||||
Speed Complement Race
|
||||
@@ -34,32 +34,32 @@ export function GameIntro() {
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
color: "#6b7280",
|
||||
marginBottom: "32px",
|
||||
lineHeight: "1.6",
|
||||
fontSize: '18px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '32px',
|
||||
lineHeight: '1.6',
|
||||
}}
|
||||
>
|
||||
Race against AI opponents while solving complement problems! Find the
|
||||
missing number to complete the equation.
|
||||
Race against AI opponents while solving complement problems! Find the missing number to
|
||||
complete the equation.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: "white",
|
||||
borderRadius: "16px",
|
||||
padding: "32px",
|
||||
marginBottom: "32px",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.1)",
|
||||
textAlign: "left",
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
marginBottom: '32px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "16px",
|
||||
color: "#1f2937",
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
color: '#1f2937',
|
||||
}}
|
||||
>
|
||||
How to Play
|
||||
@@ -67,35 +67,35 @@ export function GameIntro() {
|
||||
|
||||
<ul
|
||||
style={{
|
||||
listStyle: "none",
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<li style={{ display: "flex", gap: "12px", alignItems: "start" }}>
|
||||
<span style={{ fontSize: "24px" }}>🎯</span>
|
||||
<span style={{ color: "#4b5563", lineHeight: "1.6" }}>
|
||||
<li style={{ display: 'flex', gap: '12px', alignItems: 'start' }}>
|
||||
<span style={{ fontSize: '24px' }}>🎯</span>
|
||||
<span style={{ color: '#4b5563', lineHeight: '1.6' }}>
|
||||
Find the complement number to reach the target sum
|
||||
</span>
|
||||
</li>
|
||||
<li style={{ display: "flex", gap: "12px", alignItems: "start" }}>
|
||||
<span style={{ fontSize: "24px" }}>⚡</span>
|
||||
<span style={{ color: "#4b5563", lineHeight: "1.6" }}>
|
||||
<li style={{ display: 'flex', gap: '12px', alignItems: 'start' }}>
|
||||
<span style={{ fontSize: '24px' }}>⚡</span>
|
||||
<span style={{ color: '#4b5563', lineHeight: '1.6' }}>
|
||||
Type your answer quickly to move forward in the race
|
||||
</span>
|
||||
</li>
|
||||
<li style={{ display: "flex", gap: "12px", alignItems: "start" }}>
|
||||
<span style={{ fontSize: "24px" }}>🤖</span>
|
||||
<span style={{ color: "#4b5563", lineHeight: "1.6" }}>
|
||||
<li style={{ display: 'flex', gap: '12px', alignItems: 'start' }}>
|
||||
<span style={{ fontSize: '24px' }}>🤖</span>
|
||||
<span style={{ color: '#4b5563', lineHeight: '1.6' }}>
|
||||
Compete against Swift AI and Math Bot with unique personalities
|
||||
</span>
|
||||
</li>
|
||||
<li style={{ display: "flex", gap: "12px", alignItems: "start" }}>
|
||||
<span style={{ fontSize: "24px" }}>🏆</span>
|
||||
<span style={{ color: "#4b5563", lineHeight: "1.6" }}>
|
||||
<li style={{ display: 'flex', gap: '12px', alignItems: 'start' }}>
|
||||
<span style={{ fontSize: '24px' }}>🏆</span>
|
||||
<span style={{ color: '#4b5563', lineHeight: '1.6' }}>
|
||||
Earn points for correct answers and build up your streak
|
||||
</span>
|
||||
</li>
|
||||
@@ -105,30 +105,28 @@ export function GameIntro() {
|
||||
<button
|
||||
onClick={handleStartClick}
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #10b981, #059669)",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "12px",
|
||||
padding: "16px 48px",
|
||||
fontSize: "20px",
|
||||
fontWeight: "bold",
|
||||
cursor: "pointer",
|
||||
boxShadow: "0 4px 12px rgba(16, 185, 129, 0.3)",
|
||||
transition: "all 0.2s ease",
|
||||
background: 'linear-gradient(135deg, #10b981, #059669)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '16px 48px',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.3)',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = "translateY(-2px)";
|
||||
e.currentTarget.style.boxShadow =
|
||||
"0 6px 16px rgba(16, 185, 129, 0.4)";
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(16, 185, 129, 0.4)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "translateY(0)";
|
||||
e.currentTarget.style.boxShadow =
|
||||
"0 4px 12px rgba(16, 185, 129, 0.3)";
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.3)'
|
||||
}}
|
||||
>
|
||||
Start Racing!
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,182 +1,161 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useComplementRace } from "../context/ComplementRaceContext";
|
||||
import { useComplementRace } from '../context/ComplementRaceContext'
|
||||
|
||||
export function GameResults() {
|
||||
const { state, dispatch } = useComplementRace();
|
||||
const { state, dispatch } = useComplementRace()
|
||||
|
||||
// Determine race outcome
|
||||
const playerWon = state.aiRacers.every(
|
||||
(racer) => state.correctAnswers > racer.position,
|
||||
);
|
||||
const playerWon = state.aiRacers.every((racer) => state.correctAnswers > racer.position)
|
||||
const playerPosition =
|
||||
state.aiRacers.filter((racer) => racer.position >= state.correctAnswers)
|
||||
.length + 1;
|
||||
state.aiRacers.filter((racer) => racer.position >= state.correctAnswers).length + 1
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "60px 40px 40px",
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
minHeight: "100vh",
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '60px 40px 40px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "white",
|
||||
borderRadius: "24px",
|
||||
padding: "48px",
|
||||
maxWidth: "600px",
|
||||
width: "100%",
|
||||
boxShadow: "0 20px 60px rgba(0, 0, 0, 0.3)",
|
||||
textAlign: "center",
|
||||
background: 'white',
|
||||
borderRadius: '24px',
|
||||
padding: '48px',
|
||||
maxWidth: '600px',
|
||||
width: '100%',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Result Header */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "64px",
|
||||
marginBottom: "16px",
|
||||
fontSize: '64px',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
{playerWon
|
||||
? "🏆"
|
||||
: playerPosition === 2
|
||||
? "🥈"
|
||||
: playerPosition === 3
|
||||
? "🥉"
|
||||
: "🎯"}
|
||||
{playerWon ? '🏆' : playerPosition === 2 ? '🥈' : playerPosition === 3 ? '🥉' : '🎯'}
|
||||
</div>
|
||||
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "36px",
|
||||
fontWeight: "bold",
|
||||
color: "#1f2937",
|
||||
marginBottom: "8px",
|
||||
fontSize: '36px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{playerWon
|
||||
? "Victory!"
|
||||
: `${playerPosition}${getOrdinalSuffix(playerPosition)} Place`}
|
||||
{playerWon ? 'Victory!' : `${playerPosition}${getOrdinalSuffix(playerPosition)} Place`}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
color: "#6b7280",
|
||||
marginBottom: "32px",
|
||||
fontSize: '18px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '32px',
|
||||
}}
|
||||
>
|
||||
{playerWon ? "You beat all the AI racers!" : `You finished the race!`}
|
||||
{playerWon ? 'You beat all the AI racers!' : `You finished the race!`}
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, 1fr)",
|
||||
gap: "16px",
|
||||
marginBottom: "32px",
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '16px',
|
||||
marginBottom: '32px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#f3f4f6",
|
||||
borderRadius: "12px",
|
||||
padding: "16px",
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: "#6b7280",
|
||||
fontSize: "14px",
|
||||
marginBottom: "4px",
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Final Score
|
||||
</div>
|
||||
<div
|
||||
style={{ fontSize: "28px", fontWeight: "bold", color: "#3b82f6" }}
|
||||
>
|
||||
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#3b82f6' }}>
|
||||
{state.score}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: "#f3f4f6",
|
||||
borderRadius: "12px",
|
||||
padding: "16px",
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: "#6b7280",
|
||||
fontSize: "14px",
|
||||
marginBottom: "4px",
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Best Streak
|
||||
</div>
|
||||
<div
|
||||
style={{ fontSize: "28px", fontWeight: "bold", color: "#10b981" }}
|
||||
>
|
||||
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#10b981' }}>
|
||||
{state.bestStreak} 🔥
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: "#f3f4f6",
|
||||
borderRadius: "12px",
|
||||
padding: "16px",
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: "#6b7280",
|
||||
fontSize: "14px",
|
||||
marginBottom: "4px",
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Total Questions
|
||||
</div>
|
||||
<div
|
||||
style={{ fontSize: "28px", fontWeight: "bold", color: "#f59e0b" }}
|
||||
>
|
||||
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#f59e0b' }}>
|
||||
{state.totalQuestions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: "#f3f4f6",
|
||||
borderRadius: "12px",
|
||||
padding: "16px",
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: "#6b7280",
|
||||
fontSize: "14px",
|
||||
marginBottom: "4px",
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Accuracy
|
||||
</div>
|
||||
<div
|
||||
style={{ fontSize: "28px", fontWeight: "bold", color: "#8b5cf6" }}
|
||||
>
|
||||
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#8b5cf6' }}>
|
||||
{state.totalQuestions > 0
|
||||
? Math.round(
|
||||
(state.correctAnswers / state.totalQuestions) * 100,
|
||||
)
|
||||
? Math.round((state.correctAnswers / state.totalQuestions) * 100)
|
||||
: 0}
|
||||
%
|
||||
</div>
|
||||
@@ -186,23 +165,23 @@ export function GameResults() {
|
||||
{/* Final Standings */}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "32px",
|
||||
textAlign: "left",
|
||||
marginBottom: '32px',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
fontWeight: "bold",
|
||||
color: "#1f2937",
|
||||
marginBottom: "12px",
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
Final Standings
|
||||
</h3>
|
||||
|
||||
{[
|
||||
{ name: "You", position: state.correctAnswers, icon: "👤" },
|
||||
{ name: 'You', position: state.correctAnswers, icon: '👤' },
|
||||
...state.aiRacers.map((racer) => ({
|
||||
name: racer.name,
|
||||
position: racer.position,
|
||||
@@ -214,33 +193,31 @@ export function GameResults() {
|
||||
<div
|
||||
key={racer.name}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px",
|
||||
background: racer.name === "You" ? "#eff6ff" : "#f9fafb",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "8px",
|
||||
border: racer.name === "You" ? "2px solid #3b82f6" : "none",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px',
|
||||
background: racer.name === 'You' ? '#eff6ff' : '#f9fafb',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '8px',
|
||||
border: racer.name === 'You' ? '2px solid #3b82f6' : 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: "12px" }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold",
|
||||
color: "#9ca3af",
|
||||
minWidth: "32px",
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: '#9ca3af',
|
||||
minWidth: '32px',
|
||||
}}
|
||||
>
|
||||
#{index + 1}
|
||||
</div>
|
||||
<div style={{ fontSize: "20px" }}>{racer.icon}</div>
|
||||
<div style={{ fontSize: '20px' }}>{racer.icon}</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: racer.name === "You" ? "bold" : "normal",
|
||||
fontWeight: racer.name === 'You' ? 'bold' : 'normal',
|
||||
}}
|
||||
>
|
||||
{racer.name}
|
||||
@@ -248,9 +225,9 @@ export function GameResults() {
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
fontWeight: "bold",
|
||||
color: "#6b7280",
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: '#6b7280',
|
||||
}}
|
||||
>
|
||||
{Math.floor(racer.position)}
|
||||
@@ -262,30 +239,30 @@ export function GameResults() {
|
||||
{/* Buttons */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "12px",
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => dispatch({ type: "RESET_GAME" })}
|
||||
onClick={() => dispatch({ type: 'RESET_GAME' })}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
color: "white",
|
||||
padding: "16px 32px",
|
||||
borderRadius: "12px",
|
||||
fontSize: "18px",
|
||||
fontWeight: "bold",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
transition: "transform 0.2s",
|
||||
boxShadow: "0 4px 12px rgba(102, 126, 234, 0.4)",
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
padding: '16px 32px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s',
|
||||
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.4)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = "translateY(-2px)";
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "translateY(0)";
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
}}
|
||||
>
|
||||
Race Again
|
||||
@@ -293,12 +270,12 @@ export function GameResults() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function getOrdinalSuffix(num: number): string {
|
||||
if (num === 1) return "st";
|
||||
if (num === 2) return "nd";
|
||||
if (num === 3) return "rd";
|
||||
return "th";
|
||||
if (num === 1) return 'st'
|
||||
if (num === 2) return 'nd'
|
||||
if (num === 3) return 'rd'
|
||||
return 'th'
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { memo } from "react";
|
||||
import type { Passenger, Station } from "../lib/gameTypes";
|
||||
import { memo } from 'react'
|
||||
import type { Passenger, Station } from '../lib/gameTypes'
|
||||
|
||||
interface PassengerCardProps {
|
||||
passenger: Passenger;
|
||||
originStation: Station | undefined;
|
||||
destinationStation: Station | undefined;
|
||||
passenger: Passenger
|
||||
originStation: Station | undefined
|
||||
destinationStation: Station | undefined
|
||||
}
|
||||
|
||||
export const PassengerCard = memo(function PassengerCard({
|
||||
@@ -14,82 +14,80 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
originStation,
|
||||
destinationStation,
|
||||
}: PassengerCardProps) {
|
||||
if (!destinationStation || !originStation) return null;
|
||||
if (!destinationStation || !originStation) return null
|
||||
|
||||
// Vintage train station colors
|
||||
const bgColor = passenger.isDelivered
|
||||
? "#1a3a1a" // Dark green for delivered
|
||||
? '#1a3a1a' // Dark green for delivered
|
||||
: !passenger.isBoarded
|
||||
? "#2a2419" // Dark brown/sepia for waiting
|
||||
? '#2a2419' // Dark brown/sepia for waiting
|
||||
: passenger.isUrgent
|
||||
? "#3a2419" // Dark red-brown for urgent
|
||||
: "#1a2a3a"; // Dark blue for aboard
|
||||
? '#3a2419' // Dark red-brown for urgent
|
||||
: '#1a2a3a' // Dark blue for aboard
|
||||
|
||||
const accentColor = passenger.isDelivered
|
||||
? "#4ade80" // Green
|
||||
? '#4ade80' // Green
|
||||
: !passenger.isBoarded
|
||||
? "#d4af37" // Gold for waiting
|
||||
? '#d4af37' // Gold for waiting
|
||||
: passenger.isUrgent
|
||||
? "#ff6b35" // Orange-red for urgent
|
||||
: "#60a5fa"; // Blue for aboard
|
||||
? '#ff6b35' // Orange-red for urgent
|
||||
: '#60a5fa' // Blue for aboard
|
||||
|
||||
const borderColor =
|
||||
passenger.isUrgent && passenger.isBoarded && !passenger.isDelivered
|
||||
? "#ff6b35"
|
||||
: "#d4af37";
|
||||
passenger.isUrgent && passenger.isBoarded && !passenger.isDelivered ? '#ff6b35' : '#d4af37'
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: bgColor,
|
||||
border: `2px solid ${borderColor}`,
|
||||
borderRadius: "4px",
|
||||
padding: "8px 10px",
|
||||
minWidth: "220px",
|
||||
maxWidth: "280px",
|
||||
borderRadius: '4px',
|
||||
padding: '8px 10px',
|
||||
minWidth: '220px',
|
||||
maxWidth: '280px',
|
||||
boxShadow:
|
||||
passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded
|
||||
? "0 0 16px rgba(255, 107, 53, 0.5)"
|
||||
: "0 4px 12px rgba(0, 0, 0, 0.4)",
|
||||
position: "relative",
|
||||
? '0 0 16px rgba(255, 107, 53, 0.5)'
|
||||
: '0 4px 12px rgba(0, 0, 0, 0.4)',
|
||||
position: 'relative',
|
||||
fontFamily: '"Courier New", Courier, monospace',
|
||||
animation:
|
||||
passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded
|
||||
? "urgentFlicker 1.5s ease-in-out infinite"
|
||||
: "none",
|
||||
transition: "all 0.3s ease",
|
||||
? 'urgentFlicker 1.5s ease-in-out infinite'
|
||||
: 'none',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{/* Top row: Passenger info and status */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "6px",
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '6px',
|
||||
borderBottom: `1px solid ${accentColor}33`,
|
||||
paddingBottom: "4px",
|
||||
paddingRight: "42px", // Make room for points badge
|
||||
paddingBottom: '4px',
|
||||
paddingRight: '42px', // Make room for points badge
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "20px", lineHeight: "1" }}>
|
||||
{passenger.isDelivered ? "✅" : passenger.avatar}
|
||||
<div style={{ fontSize: '20px', lineHeight: '1' }}>
|
||||
{passenger.isDelivered ? '✅' : passenger.avatar}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
fontWeight: "bold",
|
||||
fontSize: '11px',
|
||||
fontWeight: 'bold',
|
||||
color: accentColor,
|
||||
letterSpacing: "0.5px",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{passenger.name}
|
||||
@@ -99,63 +97,57 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
{/* Status indicator */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "9px",
|
||||
fontSize: '9px',
|
||||
color: accentColor,
|
||||
fontWeight: "bold",
|
||||
letterSpacing: "0.5px",
|
||||
fontWeight: 'bold',
|
||||
letterSpacing: '0.5px',
|
||||
background: `${accentColor}22`,
|
||||
padding: "2px 6px",
|
||||
borderRadius: "2px",
|
||||
padding: '2px 6px',
|
||||
borderRadius: '2px',
|
||||
border: `1px solid ${accentColor}66`,
|
||||
whiteSpace: "nowrap",
|
||||
marginTop: "0",
|
||||
whiteSpace: 'nowrap',
|
||||
marginTop: '0',
|
||||
}}
|
||||
>
|
||||
{passenger.isDelivered
|
||||
? "DLVRD"
|
||||
: passenger.isBoarded
|
||||
? "BOARD"
|
||||
: "WAIT"}
|
||||
{passenger.isDelivered ? 'DLVRD' : passenger.isBoarded ? 'BOARD' : 'WAIT'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Route information */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "3px",
|
||||
fontSize: "10px",
|
||||
color: "#e8d4a0",
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '3px',
|
||||
fontSize: '10px',
|
||||
color: '#e8d4a0',
|
||||
}}
|
||||
>
|
||||
{/* From station */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: accentColor,
|
||||
fontSize: "8px",
|
||||
fontWeight: "bold",
|
||||
width: "28px",
|
||||
letterSpacing: "0.3px",
|
||||
fontSize: '8px',
|
||||
fontWeight: 'bold',
|
||||
width: '28px',
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
FROM:
|
||||
</span>
|
||||
<span style={{ fontSize: "14px", lineHeight: "1" }}>
|
||||
{originStation.icon}
|
||||
</span>
|
||||
<span style={{ fontSize: '14px', lineHeight: '1' }}>{originStation.icon}</span>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: "600",
|
||||
fontSize: "10px",
|
||||
letterSpacing: "0.3px",
|
||||
fontWeight: '600',
|
||||
fontSize: '10px',
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
{originStation.name}
|
||||
@@ -165,30 +157,28 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
{/* To station */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: accentColor,
|
||||
fontSize: "8px",
|
||||
fontWeight: "bold",
|
||||
width: "28px",
|
||||
letterSpacing: "0.3px",
|
||||
fontSize: '8px',
|
||||
fontWeight: 'bold',
|
||||
width: '28px',
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
TO:
|
||||
</span>
|
||||
<span style={{ fontSize: "14px", lineHeight: "1" }}>
|
||||
{destinationStation.icon}
|
||||
</span>
|
||||
<span style={{ fontSize: '14px', lineHeight: '1' }}>{destinationStation.icon}</span>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: "600",
|
||||
fontSize: "10px",
|
||||
letterSpacing: "0.3px",
|
||||
fontWeight: '600',
|
||||
fontSize: '10px',
|
||||
letterSpacing: '0.3px',
|
||||
}}
|
||||
>
|
||||
{destinationStation.name}
|
||||
@@ -200,20 +190,20 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
{!passenger.isDelivered && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "6px",
|
||||
right: "6px",
|
||||
position: 'absolute',
|
||||
top: '6px',
|
||||
right: '6px',
|
||||
background: `${accentColor}33`,
|
||||
border: `1px solid ${accentColor}`,
|
||||
borderRadius: "2px",
|
||||
padding: "2px 6px",
|
||||
fontSize: "10px",
|
||||
fontWeight: "bold",
|
||||
borderRadius: '2px',
|
||||
padding: '2px 6px',
|
||||
fontSize: '10px',
|
||||
fontWeight: 'bold',
|
||||
color: accentColor,
|
||||
letterSpacing: "0.5px",
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
{passenger.isUrgent ? "+20" : "+10"}
|
||||
{passenger.isUrgent ? '+20' : '+10'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -221,12 +211,12 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
{passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "8px",
|
||||
bottom: "6px",
|
||||
fontSize: "10px",
|
||||
animation: "urgentBlink 0.8s ease-in-out infinite",
|
||||
filter: "drop-shadow(0 0 4px rgba(255, 107, 53, 0.8))",
|
||||
position: 'absolute',
|
||||
left: '8px',
|
||||
bottom: '6px',
|
||||
fontSize: '10px',
|
||||
animation: 'urgentBlink 0.8s ease-in-out infinite',
|
||||
filter: 'drop-shadow(0 0 4px rgba(255, 107, 53, 0.8))',
|
||||
}}
|
||||
>
|
||||
⚠️
|
||||
@@ -255,5 +245,5 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { animated, useSpring } from "@react-spring/web";
|
||||
import { AbacusReact } from "@soroban/abacus-react";
|
||||
import { animated, useSpring } from '@react-spring/web'
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
|
||||
interface PressureGaugeProps {
|
||||
pressure: number; // 0-150 PSI
|
||||
pressure: number // 0-150 PSI
|
||||
}
|
||||
|
||||
export function PressureGauge({ pressure }: PressureGaugeProps) {
|
||||
const maxPressure = 150;
|
||||
const maxPressure = 150
|
||||
|
||||
// Animate pressure value smoothly with spring physics
|
||||
const spring = useSpring({
|
||||
@@ -18,38 +18,38 @@ export function PressureGauge({ pressure }: PressureGaugeProps) {
|
||||
friction: 14,
|
||||
clamp: false,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// Calculate needle angle - sweeps 180° from left to right
|
||||
// 0 PSI = 180° (pointing left), 150 PSI = 0° (pointing right)
|
||||
const angle = spring.pressure.to((p) => 180 - (p / maxPressure) * 180);
|
||||
const angle = spring.pressure.to((p) => 180 - (p / maxPressure) * 180)
|
||||
|
||||
// Get pressure color (animated)
|
||||
const color = spring.pressure.to((p) => {
|
||||
if (p < 50) return "#ef4444"; // Red (low)
|
||||
if (p < 100) return "#f59e0b"; // Orange (medium)
|
||||
return "#10b981"; // Green (high)
|
||||
});
|
||||
if (p < 50) return '#ef4444' // Red (low)
|
||||
if (p < 100) return '#f59e0b' // Orange (medium)
|
||||
return '#10b981' // Green (high)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
background: "rgba(255, 255, 255, 0.95)",
|
||||
padding: "16px",
|
||||
borderRadius: "12px",
|
||||
minWidth: "220px",
|
||||
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.2)",
|
||||
position: 'relative',
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
padding: '16px',
|
||||
borderRadius: '12px',
|
||||
minWidth: '220px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)',
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#6b7280",
|
||||
marginBottom: "8px",
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '8px',
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
PRESSURE
|
||||
@@ -59,9 +59,9 @@ export function PressureGauge({ pressure }: PressureGaugeProps) {
|
||||
<svg
|
||||
viewBox="-40 -20 280 170"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
marginBottom: "8px",
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{/* Background arc - semicircle from left to right (bottom half) */}
|
||||
@@ -76,16 +76,16 @@ export function PressureGauge({ pressure }: PressureGaugeProps) {
|
||||
{/* Tick marks */}
|
||||
{[0, 50, 100, 150].map((psi, index) => {
|
||||
// Angle from 180° (left) to 0° (right)
|
||||
const tickAngle = 180 - (psi / maxPressure) * 180;
|
||||
const tickRad = (tickAngle * Math.PI) / 180;
|
||||
const x1 = 100 + Math.cos(tickRad) * 70;
|
||||
const y1 = 100 - Math.sin(tickRad) * 70; // Subtract for SVG coords
|
||||
const x2 = 100 + Math.cos(tickRad) * 80;
|
||||
const y2 = 100 - Math.sin(tickRad) * 80; // Subtract for SVG coords
|
||||
const tickAngle = 180 - (psi / maxPressure) * 180
|
||||
const tickRad = (tickAngle * Math.PI) / 180
|
||||
const x1 = 100 + Math.cos(tickRad) * 70
|
||||
const y1 = 100 - Math.sin(tickRad) * 70 // Subtract for SVG coords
|
||||
const x2 = 100 + Math.cos(tickRad) * 80
|
||||
const y2 = 100 - Math.sin(tickRad) * 80 // Subtract for SVG coords
|
||||
|
||||
// Position for abacus label
|
||||
const labelX = 100 + Math.cos(tickRad) * 112;
|
||||
const labelY = 100 - Math.sin(tickRad) * 112;
|
||||
const labelX = 100 + Math.cos(tickRad) * 112
|
||||
const labelY = 100 - Math.sin(tickRad) * 112
|
||||
|
||||
return (
|
||||
<g key={`tick-${index}`}>
|
||||
@@ -98,17 +98,12 @@ export function PressureGauge({ pressure }: PressureGaugeProps) {
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<foreignObject
|
||||
x={labelX - 30}
|
||||
y={labelY - 25}
|
||||
width="60"
|
||||
height="100"
|
||||
>
|
||||
<foreignObject x={labelX - 30} y={labelY - 25} width="60" height="100">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 0,
|
||||
}}
|
||||
>
|
||||
@@ -126,7 +121,7 @@ export function PressureGauge({ pressure }: PressureGaugeProps) {
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
);
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Center pivot */}
|
||||
@@ -150,19 +145,19 @@ export function PressureGauge({ pressure }: PressureGaugeProps) {
|
||||
{/* Abacus readout */}
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "8px",
|
||||
minHeight: "32px",
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
minHeight: '32px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 0,
|
||||
}}
|
||||
>
|
||||
@@ -178,12 +173,8 @@ export function PressureGauge({ pressure }: PressureGaugeProps) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{ fontSize: "12px", color: "#6b7280", fontWeight: "bold" }}
|
||||
>
|
||||
PSI
|
||||
</span>
|
||||
<span style={{ fontSize: '12px', color: '#6b7280', fontWeight: 'bold' }}>PSI</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,203 +1,191 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useGameMode } from "@/contexts/GameModeContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { useComplementRace } from "../../context/ComplementRaceContext";
|
||||
import { useSoundEffects } from "../../hooks/useSoundEffects";
|
||||
import type { AIRacer } from "../../lib/gameTypes";
|
||||
import { SpeechBubble } from "../AISystem/SpeechBubble";
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useUserProfile } from '@/contexts/UserProfileContext'
|
||||
import { useComplementRace } from '../../context/ComplementRaceContext'
|
||||
import { useSoundEffects } from '../../hooks/useSoundEffects'
|
||||
import type { AIRacer } from '../../lib/gameTypes'
|
||||
import { SpeechBubble } from '../AISystem/SpeechBubble'
|
||||
|
||||
interface CircularTrackProps {
|
||||
playerProgress: number;
|
||||
playerLap: number;
|
||||
aiRacers: AIRacer[];
|
||||
aiLaps: Map<string, number>;
|
||||
playerProgress: number
|
||||
playerLap: number
|
||||
aiRacers: AIRacer[]
|
||||
aiLaps: Map<string, number>
|
||||
}
|
||||
|
||||
export function CircularTrack({
|
||||
playerProgress,
|
||||
playerLap,
|
||||
aiRacers,
|
||||
aiLaps,
|
||||
}: CircularTrackProps) {
|
||||
const { state, dispatch } = useComplementRace();
|
||||
const { players } = useGameMode();
|
||||
const { profile: _profile } = useUserProfile();
|
||||
const { playSound } = useSoundEffects();
|
||||
const [celebrationCooldown, setCelebrationCooldown] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
export function CircularTrack({ playerProgress, playerLap, aiRacers, aiLaps }: CircularTrackProps) {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const { players } = useGameMode()
|
||||
const { profile: _profile } = useUserProfile()
|
||||
const { playSound } = useSoundEffects()
|
||||
const [celebrationCooldown, setCelebrationCooldown] = useState<Set<string>>(new Set())
|
||||
|
||||
// Get the first active player's emoji
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id);
|
||||
const firstActivePlayer = activePlayers[0];
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? "👤";
|
||||
const [dimensions, setDimensions] = useState({ width: 600, height: 400 });
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id)
|
||||
const firstActivePlayer = activePlayers[0]
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
|
||||
const [dimensions, setDimensions] = useState({ width: 600, height: 400 })
|
||||
|
||||
// Update dimensions on mount and resize
|
||||
useEffect(() => {
|
||||
const updateDimensions = () => {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const isLandscape = vw > vh;
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
const isLandscape = vw > vh
|
||||
|
||||
if (isLandscape) {
|
||||
// Landscape: wider track (emphasize horizontal straights)
|
||||
const width = Math.min(vw * 0.75, 800);
|
||||
const height = Math.min(vh * 0.5, 350);
|
||||
setDimensions({ width, height });
|
||||
const width = Math.min(vw * 0.75, 800)
|
||||
const height = Math.min(vh * 0.5, 350)
|
||||
setDimensions({ width, height })
|
||||
} else {
|
||||
// Portrait: taller track (emphasize vertical straights)
|
||||
const width = Math.min(vw * 0.85, 350);
|
||||
const height = Math.min(vh * 0.5, 550);
|
||||
setDimensions({ width, height });
|
||||
}
|
||||
};
|
||||
|
||||
updateDimensions();
|
||||
window.addEventListener("resize", updateDimensions);
|
||||
return () => window.removeEventListener("resize", updateDimensions);
|
||||
}, []);
|
||||
|
||||
const padding = 40;
|
||||
const trackWidth = dimensions.width - padding * 2;
|
||||
const trackHeight = dimensions.height - padding * 2;
|
||||
|
||||
// For a rounded rectangle track, we have straight sections and curved ends
|
||||
const straightLength =
|
||||
Math.max(trackWidth, trackHeight) - Math.min(trackWidth, trackHeight);
|
||||
const radius = Math.min(trackWidth, trackHeight) / 2;
|
||||
const isHorizontal = trackWidth > trackHeight;
|
||||
|
||||
// Calculate position on rounded rectangle track
|
||||
const getCircularPosition = (progress: number) => {
|
||||
const progressPerLap = 50;
|
||||
const normalizedProgress = (progress % progressPerLap) / progressPerLap;
|
||||
|
||||
// Track perimeter consists of: 2 straights + 2 semicircles
|
||||
const straightPerim = straightLength;
|
||||
const curvePerim = Math.PI * radius;
|
||||
const totalPerim = 2 * straightPerim + 2 * curvePerim;
|
||||
|
||||
const distanceAlongTrack = normalizedProgress * totalPerim;
|
||||
|
||||
const centerX = dimensions.width / 2;
|
||||
const centerY = dimensions.height / 2;
|
||||
|
||||
let x: number, y: number, angle: number;
|
||||
|
||||
if (isHorizontal) {
|
||||
// Horizontal track: straight sections on top/bottom, curves on left/right
|
||||
const topStraightEnd = straightPerim;
|
||||
const rightCurveEnd = topStraightEnd + curvePerim;
|
||||
const bottomStraightEnd = rightCurveEnd + straightPerim;
|
||||
const _leftCurveEnd = bottomStraightEnd + curvePerim;
|
||||
|
||||
if (distanceAlongTrack < topStraightEnd) {
|
||||
// Top straight (moving right)
|
||||
const t = distanceAlongTrack / straightPerim;
|
||||
x = centerX - straightLength / 2 + t * straightLength;
|
||||
y = centerY - radius;
|
||||
angle = 90;
|
||||
} else if (distanceAlongTrack < rightCurveEnd) {
|
||||
// Right curve
|
||||
const curveProgress =
|
||||
(distanceAlongTrack - topStraightEnd) / curvePerim;
|
||||
const curveAngle = curveProgress * Math.PI - Math.PI / 2;
|
||||
x = centerX + straightLength / 2 + radius * Math.cos(curveAngle);
|
||||
y = centerY + radius * Math.sin(curveAngle);
|
||||
angle = curveProgress * 180 + 90;
|
||||
} else if (distanceAlongTrack < bottomStraightEnd) {
|
||||
// Bottom straight (moving left)
|
||||
const t = (distanceAlongTrack - rightCurveEnd) / straightPerim;
|
||||
x = centerX + straightLength / 2 - t * straightLength;
|
||||
y = centerY + radius;
|
||||
angle = 270;
|
||||
} else {
|
||||
// Left curve
|
||||
const curveProgress =
|
||||
(distanceAlongTrack - bottomStraightEnd) / curvePerim;
|
||||
const curveAngle = curveProgress * Math.PI + Math.PI / 2;
|
||||
x = centerX - straightLength / 2 + radius * Math.cos(curveAngle);
|
||||
y = centerY + radius * Math.sin(curveAngle);
|
||||
angle = curveProgress * 180 + 270;
|
||||
}
|
||||
} else {
|
||||
// Vertical track: straight sections on left/right, curves on top/bottom
|
||||
const leftStraightEnd = straightPerim;
|
||||
const bottomCurveEnd = leftStraightEnd + curvePerim;
|
||||
const rightStraightEnd = bottomCurveEnd + straightPerim;
|
||||
const _topCurveEnd = rightStraightEnd + curvePerim;
|
||||
|
||||
if (distanceAlongTrack < leftStraightEnd) {
|
||||
// Left straight (moving down)
|
||||
const t = distanceAlongTrack / straightPerim;
|
||||
x = centerX - radius;
|
||||
y = centerY - straightLength / 2 + t * straightLength;
|
||||
angle = 180;
|
||||
} else if (distanceAlongTrack < bottomCurveEnd) {
|
||||
// Bottom curve
|
||||
const curveProgress =
|
||||
(distanceAlongTrack - leftStraightEnd) / curvePerim;
|
||||
const curveAngle = curveProgress * Math.PI;
|
||||
x = centerX + radius * Math.cos(curveAngle);
|
||||
y = centerY + straightLength / 2 + radius * Math.sin(curveAngle);
|
||||
angle = curveProgress * 180 + 180;
|
||||
} else if (distanceAlongTrack < rightStraightEnd) {
|
||||
// Right straight (moving up)
|
||||
const t = (distanceAlongTrack - bottomCurveEnd) / straightPerim;
|
||||
x = centerX + radius;
|
||||
y = centerY + straightLength / 2 - t * straightLength;
|
||||
angle = 0;
|
||||
} else {
|
||||
// Top curve
|
||||
const curveProgress =
|
||||
(distanceAlongTrack - rightStraightEnd) / curvePerim;
|
||||
const curveAngle = curveProgress * Math.PI + Math.PI;
|
||||
x = centerX + radius * Math.cos(curveAngle);
|
||||
y = centerY - straightLength / 2 + radius * Math.sin(curveAngle);
|
||||
angle = curveProgress * 180;
|
||||
const width = Math.min(vw * 0.85, 350)
|
||||
const height = Math.min(vh * 0.5, 550)
|
||||
setDimensions({ width, height })
|
||||
}
|
||||
}
|
||||
|
||||
return { x, y, angle };
|
||||
};
|
||||
updateDimensions()
|
||||
window.addEventListener('resize', updateDimensions)
|
||||
return () => window.removeEventListener('resize', updateDimensions)
|
||||
}, [])
|
||||
|
||||
const padding = 40
|
||||
const trackWidth = dimensions.width - padding * 2
|
||||
const trackHeight = dimensions.height - padding * 2
|
||||
|
||||
// For a rounded rectangle track, we have straight sections and curved ends
|
||||
const straightLength = Math.max(trackWidth, trackHeight) - Math.min(trackWidth, trackHeight)
|
||||
const radius = Math.min(trackWidth, trackHeight) / 2
|
||||
const isHorizontal = trackWidth > trackHeight
|
||||
|
||||
// Calculate position on rounded rectangle track
|
||||
const getCircularPosition = (progress: number) => {
|
||||
const progressPerLap = 50
|
||||
const normalizedProgress = (progress % progressPerLap) / progressPerLap
|
||||
|
||||
// Track perimeter consists of: 2 straights + 2 semicircles
|
||||
const straightPerim = straightLength
|
||||
const curvePerim = Math.PI * radius
|
||||
const totalPerim = 2 * straightPerim + 2 * curvePerim
|
||||
|
||||
const distanceAlongTrack = normalizedProgress * totalPerim
|
||||
|
||||
const centerX = dimensions.width / 2
|
||||
const centerY = dimensions.height / 2
|
||||
|
||||
let x: number, y: number, angle: number
|
||||
|
||||
if (isHorizontal) {
|
||||
// Horizontal track: straight sections on top/bottom, curves on left/right
|
||||
const topStraightEnd = straightPerim
|
||||
const rightCurveEnd = topStraightEnd + curvePerim
|
||||
const bottomStraightEnd = rightCurveEnd + straightPerim
|
||||
const _leftCurveEnd = bottomStraightEnd + curvePerim
|
||||
|
||||
if (distanceAlongTrack < topStraightEnd) {
|
||||
// Top straight (moving right)
|
||||
const t = distanceAlongTrack / straightPerim
|
||||
x = centerX - straightLength / 2 + t * straightLength
|
||||
y = centerY - radius
|
||||
angle = 90
|
||||
} else if (distanceAlongTrack < rightCurveEnd) {
|
||||
// Right curve
|
||||
const curveProgress = (distanceAlongTrack - topStraightEnd) / curvePerim
|
||||
const curveAngle = curveProgress * Math.PI - Math.PI / 2
|
||||
x = centerX + straightLength / 2 + radius * Math.cos(curveAngle)
|
||||
y = centerY + radius * Math.sin(curveAngle)
|
||||
angle = curveProgress * 180 + 90
|
||||
} else if (distanceAlongTrack < bottomStraightEnd) {
|
||||
// Bottom straight (moving left)
|
||||
const t = (distanceAlongTrack - rightCurveEnd) / straightPerim
|
||||
x = centerX + straightLength / 2 - t * straightLength
|
||||
y = centerY + radius
|
||||
angle = 270
|
||||
} else {
|
||||
// Left curve
|
||||
const curveProgress = (distanceAlongTrack - bottomStraightEnd) / curvePerim
|
||||
const curveAngle = curveProgress * Math.PI + Math.PI / 2
|
||||
x = centerX - straightLength / 2 + radius * Math.cos(curveAngle)
|
||||
y = centerY + radius * Math.sin(curveAngle)
|
||||
angle = curveProgress * 180 + 270
|
||||
}
|
||||
} else {
|
||||
// Vertical track: straight sections on left/right, curves on top/bottom
|
||||
const leftStraightEnd = straightPerim
|
||||
const bottomCurveEnd = leftStraightEnd + curvePerim
|
||||
const rightStraightEnd = bottomCurveEnd + straightPerim
|
||||
const _topCurveEnd = rightStraightEnd + curvePerim
|
||||
|
||||
if (distanceAlongTrack < leftStraightEnd) {
|
||||
// Left straight (moving down)
|
||||
const t = distanceAlongTrack / straightPerim
|
||||
x = centerX - radius
|
||||
y = centerY - straightLength / 2 + t * straightLength
|
||||
angle = 180
|
||||
} else if (distanceAlongTrack < bottomCurveEnd) {
|
||||
// Bottom curve
|
||||
const curveProgress = (distanceAlongTrack - leftStraightEnd) / curvePerim
|
||||
const curveAngle = curveProgress * Math.PI
|
||||
x = centerX + radius * Math.cos(curveAngle)
|
||||
y = centerY + straightLength / 2 + radius * Math.sin(curveAngle)
|
||||
angle = curveProgress * 180 + 180
|
||||
} else if (distanceAlongTrack < rightStraightEnd) {
|
||||
// Right straight (moving up)
|
||||
const t = (distanceAlongTrack - bottomCurveEnd) / straightPerim
|
||||
x = centerX + radius
|
||||
y = centerY + straightLength / 2 - t * straightLength
|
||||
angle = 0
|
||||
} else {
|
||||
// Top curve
|
||||
const curveProgress = (distanceAlongTrack - rightStraightEnd) / curvePerim
|
||||
const curveAngle = curveProgress * Math.PI + Math.PI
|
||||
x = centerX + radius * Math.cos(curveAngle)
|
||||
y = centerY - straightLength / 2 + radius * Math.sin(curveAngle)
|
||||
angle = curveProgress * 180
|
||||
}
|
||||
}
|
||||
|
||||
return { x, y, angle }
|
||||
}
|
||||
|
||||
// Check for lap completions and show celebrations
|
||||
useEffect(() => {
|
||||
// Check player lap
|
||||
const playerCurrentLap = Math.floor(playerProgress / 50);
|
||||
if (playerCurrentLap > playerLap && !celebrationCooldown.has("player")) {
|
||||
dispatch({ type: "COMPLETE_LAP", racerId: "player" });
|
||||
const playerCurrentLap = Math.floor(playerProgress / 50)
|
||||
if (playerCurrentLap > playerLap && !celebrationCooldown.has('player')) {
|
||||
dispatch({ type: 'COMPLETE_LAP', racerId: 'player' })
|
||||
// Play celebration sound (line 12801)
|
||||
playSound("lap_celebration", 0.6);
|
||||
setCelebrationCooldown((prev) => new Set(prev).add("player"));
|
||||
playSound('lap_celebration', 0.6)
|
||||
setCelebrationCooldown((prev) => new Set(prev).add('player'))
|
||||
setTimeout(() => {
|
||||
setCelebrationCooldown((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete("player");
|
||||
return next;
|
||||
});
|
||||
}, 2000);
|
||||
const next = new Set(prev)
|
||||
next.delete('player')
|
||||
return next
|
||||
})
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// Check AI laps
|
||||
aiRacers.forEach((racer) => {
|
||||
const aiCurrentLap = Math.floor(racer.position / 50);
|
||||
const aiPreviousLap = aiLaps.get(racer.id) || 0;
|
||||
const aiCurrentLap = Math.floor(racer.position / 50)
|
||||
const aiPreviousLap = aiLaps.get(racer.id) || 0
|
||||
if (aiCurrentLap > aiPreviousLap && !celebrationCooldown.has(racer.id)) {
|
||||
dispatch({ type: "COMPLETE_LAP", racerId: racer.id });
|
||||
setCelebrationCooldown((prev) => new Set(prev).add(racer.id));
|
||||
dispatch({ type: 'COMPLETE_LAP', racerId: racer.id })
|
||||
setCelebrationCooldown((prev) => new Set(prev).add(racer.id))
|
||||
setTimeout(() => {
|
||||
setCelebrationCooldown((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(racer.id);
|
||||
return next;
|
||||
});
|
||||
}, 2000);
|
||||
const next = new Set(prev)
|
||||
next.delete(racer.id)
|
||||
return next
|
||||
})
|
||||
}, 2000)
|
||||
}
|
||||
});
|
||||
})
|
||||
}, [
|
||||
playerProgress,
|
||||
playerLap,
|
||||
@@ -206,28 +194,25 @@ export function CircularTrack({
|
||||
celebrationCooldown,
|
||||
dispatch, // Play celebration sound (line 12801)
|
||||
playSound,
|
||||
]);
|
||||
])
|
||||
|
||||
const playerPos = getCircularPosition(playerProgress);
|
||||
const playerPos = getCircularPosition(playerProgress)
|
||||
|
||||
// Create rounded rectangle path with wider curves (banking effect)
|
||||
const createRoundedRectPath = (
|
||||
radiusOffset: number,
|
||||
isOuter: boolean = false,
|
||||
) => {
|
||||
const centerX = dimensions.width / 2;
|
||||
const centerY = dimensions.height / 2;
|
||||
const createRoundedRectPath = (radiusOffset: number, isOuter: boolean = false) => {
|
||||
const centerX = dimensions.width / 2
|
||||
const centerY = dimensions.height / 2
|
||||
|
||||
// Make curves wider by increasing radius more on outer edges
|
||||
const curveWidthBonus = isOuter ? radiusOffset * 0.15 : radiusOffset * -0.1;
|
||||
const r = radius + radiusOffset + curveWidthBonus;
|
||||
const curveWidthBonus = isOuter ? radiusOffset * 0.15 : radiusOffset * -0.1
|
||||
const r = radius + radiusOffset + curveWidthBonus
|
||||
|
||||
if (isHorizontal) {
|
||||
// Horizontal track - curved ends on left/right
|
||||
const leftCenterX = centerX - straightLength / 2;
|
||||
const rightCenterX = centerX + straightLength / 2;
|
||||
const curveTopY = centerY - r;
|
||||
const curveBottomY = centerY + r;
|
||||
const leftCenterX = centerX - straightLength / 2
|
||||
const rightCenterX = centerX + straightLength / 2
|
||||
const curveTopY = centerY - r
|
||||
const curveBottomY = centerY + r
|
||||
|
||||
return `
|
||||
M ${leftCenterX} ${curveTopY}
|
||||
@@ -236,13 +221,13 @@ export function CircularTrack({
|
||||
L ${leftCenterX} ${curveBottomY}
|
||||
A ${r} ${r} 0 0 1 ${leftCenterX} ${curveTopY}
|
||||
Z
|
||||
`;
|
||||
`
|
||||
} else {
|
||||
// Vertical track - curved ends on top/bottom
|
||||
const topCenterY = centerY - straightLength / 2;
|
||||
const bottomCenterY = centerY + straightLength / 2;
|
||||
const curveLeftX = centerX - r;
|
||||
const curveRightX = centerX + r;
|
||||
const topCenterY = centerY - straightLength / 2
|
||||
const bottomCenterY = centerY + straightLength / 2
|
||||
const curveLeftX = centerX - r
|
||||
const curveRightX = centerX + r
|
||||
|
||||
return `
|
||||
M ${curveLeftX} ${topCenterY}
|
||||
@@ -251,18 +236,18 @@ export function CircularTrack({
|
||||
L ${curveRightX} ${topCenterY}
|
||||
A ${r} ${r} 0 0 0 ${curveLeftX} ${topCenterY}
|
||||
Z
|
||||
`;
|
||||
`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="circular-track"
|
||||
style={{
|
||||
position: "relative",
|
||||
position: 'relative',
|
||||
width: `${dimensions.width}px`,
|
||||
height: `${dimensions.height}px`,
|
||||
margin: "0 auto",
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
{/* SVG Track */}
|
||||
@@ -271,40 +256,22 @@ export function CircularTrack({
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
}}
|
||||
>
|
||||
{/* Infield grass */}
|
||||
<path
|
||||
d={createRoundedRectPath(15, false)}
|
||||
fill="#7cb342"
|
||||
stroke="none"
|
||||
/>
|
||||
<path d={createRoundedRectPath(15, false)} fill="#7cb342" stroke="none" />
|
||||
|
||||
{/* Track background - reddish clay color */}
|
||||
<path
|
||||
d={createRoundedRectPath(-10, true)}
|
||||
fill="#d97757"
|
||||
stroke="none"
|
||||
/>
|
||||
<path d={createRoundedRectPath(-10, true)} fill="#d97757" stroke="none" />
|
||||
|
||||
{/* Track outer edge - white boundary */}
|
||||
<path
|
||||
d={createRoundedRectPath(-15, true)}
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path d={createRoundedRectPath(-15, true)} fill="none" stroke="white" strokeWidth="3" />
|
||||
|
||||
{/* Track inner edge - white boundary */}
|
||||
<path
|
||||
d={createRoundedRectPath(15, false)}
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path d={createRoundedRectPath(15, false)} fill="none" stroke="white" strokeWidth="3" />
|
||||
|
||||
{/* Lane markers - dashed white lines */}
|
||||
{[-5, 0, 5].map((offset) => (
|
||||
@@ -321,16 +288,16 @@ export function CircularTrack({
|
||||
|
||||
{/* Start/Finish line - checkered flag pattern */}
|
||||
{(() => {
|
||||
const centerX = dimensions.width / 2;
|
||||
const centerY = dimensions.height / 2;
|
||||
const trackThickness = 35; // Track width from inner to outer edge
|
||||
const centerX = dimensions.width / 2
|
||||
const centerY = dimensions.height / 2
|
||||
const trackThickness = 35 // Track width from inner to outer edge
|
||||
|
||||
if (isHorizontal) {
|
||||
// Horizontal track: vertical finish line crossing the top straight
|
||||
const x = centerX;
|
||||
const yStart = centerY - radius - 18; // Outer edge
|
||||
const squareSize = trackThickness / 6;
|
||||
const lineWidth = 12;
|
||||
const x = centerX
|
||||
const yStart = centerY - radius - 18 // Outer edge
|
||||
const squareSize = trackThickness / 6
|
||||
const lineWidth = 12
|
||||
return (
|
||||
<g>
|
||||
{/* Checkered pattern - vertical line */}
|
||||
@@ -341,17 +308,17 @@ export function CircularTrack({
|
||||
y={yStart + squareSize * i}
|
||||
width={lineWidth}
|
||||
height={squareSize}
|
||||
fill={i % 2 === 0 ? "black" : "white"}
|
||||
fill={i % 2 === 0 ? 'black' : 'white'}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
)
|
||||
} else {
|
||||
// Vertical track: horizontal finish line crossing the left straight
|
||||
const xStart = centerX - radius - 18; // Outer edge
|
||||
const y = centerY;
|
||||
const squareSize = trackThickness / 6;
|
||||
const lineWidth = 12;
|
||||
const xStart = centerX - radius - 18 // Outer edge
|
||||
const y = centerY
|
||||
const squareSize = trackThickness / 6
|
||||
const lineWidth = 12
|
||||
return (
|
||||
<g>
|
||||
{/* Checkered pattern - horizontal line */}
|
||||
@@ -362,23 +329,23 @@ export function CircularTrack({
|
||||
y={y - lineWidth / 2}
|
||||
width={squareSize}
|
||||
height={lineWidth}
|
||||
fill={i % 2 === 0 ? "black" : "white"}
|
||||
fill={i % 2 === 0 ? 'black' : 'white'}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
)
|
||||
}
|
||||
})()}
|
||||
|
||||
{/* Distance markers (quarter points) */}
|
||||
{[0.25, 0.5, 0.75].map((fraction) => {
|
||||
const pos = getCircularPosition(fraction * 50);
|
||||
const markerLength = 12;
|
||||
const perpAngle = (pos.angle + 90) * (Math.PI / 180);
|
||||
const x1 = pos.x - markerLength * Math.cos(perpAngle);
|
||||
const y1 = pos.y - markerLength * Math.sin(perpAngle);
|
||||
const x2 = pos.x + markerLength * Math.cos(perpAngle);
|
||||
const y2 = pos.y + markerLength * Math.sin(perpAngle);
|
||||
const pos = getCircularPosition(fraction * 50)
|
||||
const markerLength = 12
|
||||
const perpAngle = (pos.angle + 90) * (Math.PI / 180)
|
||||
const x1 = pos.x - markerLength * Math.cos(perpAngle)
|
||||
const y1 = pos.y - markerLength * Math.sin(perpAngle)
|
||||
const x2 = pos.x + markerLength * Math.cos(perpAngle)
|
||||
const y2 = pos.y + markerLength * Math.sin(perpAngle)
|
||||
return (
|
||||
<line
|
||||
key={fraction}
|
||||
@@ -390,21 +357,21 @@ export function CircularTrack({
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Player racer */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
left: `${playerPos.x}px`,
|
||||
top: `${playerPos.y}px`,
|
||||
transform: `translate(-50%, -50%) rotate(${playerPos.angle}deg)`,
|
||||
fontSize: "32px",
|
||||
filter: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))",
|
||||
fontSize: '32px',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
|
||||
zIndex: 10,
|
||||
transition: "left 0.3s ease-out, top 0.3s ease-out",
|
||||
transition: 'left 0.3s ease-out, top 0.3s ease-out',
|
||||
}}
|
||||
>
|
||||
{playerEmoji}
|
||||
@@ -412,21 +379,21 @@ export function CircularTrack({
|
||||
|
||||
{/* AI racers */}
|
||||
{aiRacers.map((racer, _index) => {
|
||||
const aiPos = getCircularPosition(racer.position);
|
||||
const activeBubble = state.activeSpeechBubbles.get(racer.id);
|
||||
const aiPos = getCircularPosition(racer.position)
|
||||
const activeBubble = state.activeSpeechBubbles.get(racer.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={racer.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
left: `${aiPos.x}px`,
|
||||
top: `${aiPos.y}px`,
|
||||
transform: `translate(-50%, -50%) rotate(${aiPos.angle}deg)`,
|
||||
fontSize: "28px",
|
||||
filter: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))",
|
||||
fontSize: '28px',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
|
||||
zIndex: 5,
|
||||
transition: "left 0.2s linear, top 0.2s linear",
|
||||
transition: 'left 0.2s linear, top 0.2s linear',
|
||||
}}
|
||||
>
|
||||
{racer.icon}
|
||||
@@ -438,59 +405,57 @@ export function CircularTrack({
|
||||
>
|
||||
<SpeechBubble
|
||||
message={activeBubble}
|
||||
onHide={() =>
|
||||
dispatch({ type: "CLEAR_AI_COMMENT", racerId: racer.id })
|
||||
}
|
||||
onHide={() => dispatch({ type: 'CLEAR_AI_COMMENT', racerId: racer.id })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Lap counter */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
background: "rgba(255, 255, 255, 0.95)",
|
||||
borderRadius: "50%",
|
||||
width: "120px",
|
||||
height: "120px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
|
||||
border: "3px solid #3b82f6",
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
borderRadius: '50%',
|
||||
width: '120px',
|
||||
height: '120px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
border: '3px solid #3b82f6',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
color: "#6b7280",
|
||||
marginBottom: "4px",
|
||||
fontWeight: "bold",
|
||||
fontSize: '14px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '4px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
Lap
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "36px",
|
||||
fontWeight: "bold",
|
||||
color: "#3b82f6",
|
||||
fontSize: '36px',
|
||||
fontWeight: 'bold',
|
||||
color: '#3b82f6',
|
||||
}}
|
||||
>
|
||||
{playerLap + 1}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#9ca3af",
|
||||
marginTop: "4px",
|
||||
fontSize: '12px',
|
||||
color: '#9ca3af',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
{Math.floor(((playerProgress % 50) / 50) * 100)}%
|
||||
@@ -498,21 +463,21 @@ export function CircularTrack({
|
||||
</div>
|
||||
|
||||
{/* Lap celebration */}
|
||||
{celebrationCooldown.has("player") && (
|
||||
{celebrationCooldown.has('player') && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "20px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
background: "linear-gradient(135deg, #fbbf24, #f59e0b)",
|
||||
color: "white",
|
||||
padding: "12px 24px",
|
||||
borderRadius: "12px",
|
||||
fontSize: "18px",
|
||||
fontWeight: "bold",
|
||||
boxShadow: "0 4px 20px rgba(251, 191, 36, 0.4)",
|
||||
animation: "bounce 0.5s ease",
|
||||
position: 'absolute',
|
||||
top: '20px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
|
||||
color: 'white',
|
||||
padding: '12px 24px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
boxShadow: '0 4px 20px rgba(251, 191, 36, 0.4)',
|
||||
animation: 'bounce 0.5s ease',
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
@@ -520,5 +485,5 @@ export function CircularTrack({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { memo } from "react";
|
||||
import type {
|
||||
ComplementQuestion,
|
||||
Passenger,
|
||||
Station,
|
||||
} from "../../lib/gameTypes";
|
||||
import { AbacusTarget } from "../AbacusTarget";
|
||||
import { PassengerCard } from "../PassengerCard";
|
||||
import { PressureGauge } from "../PressureGauge";
|
||||
import { memo } from 'react'
|
||||
import type { ComplementQuestion, Passenger, Station } from '../../lib/gameTypes'
|
||||
import { AbacusTarget } from '../AbacusTarget'
|
||||
import { PassengerCard } from '../PassengerCard'
|
||||
import { PressureGauge } from '../PressureGauge'
|
||||
|
||||
interface RouteTheme {
|
||||
emoji: string;
|
||||
name: string;
|
||||
emoji: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface GameHUDProps {
|
||||
routeTheme: RouteTheme;
|
||||
currentRoute: number;
|
||||
periodName: string;
|
||||
timeRemaining: number;
|
||||
pressure: number;
|
||||
nonDeliveredPassengers: Passenger[];
|
||||
stations: Station[];
|
||||
currentQuestion: ComplementQuestion | null;
|
||||
currentInput: string;
|
||||
routeTheme: RouteTheme
|
||||
currentRoute: number
|
||||
periodName: string
|
||||
timeRemaining: number
|
||||
pressure: number
|
||||
nonDeliveredPassengers: Passenger[]
|
||||
stations: Station[]
|
||||
currentQuestion: ComplementQuestion | null
|
||||
currentInput: string
|
||||
}
|
||||
|
||||
export const GameHUD = memo(
|
||||
@@ -45,51 +41,47 @@ export const GameHUD = memo(
|
||||
<div
|
||||
data-component="route-info"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "10px",
|
||||
left: "10px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{/* Current Route */}
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(0, 0, 0, 0.3)",
|
||||
color: "white",
|
||||
padding: "8px 14px",
|
||||
borderRadius: "8px",
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold",
|
||||
backdropFilter: "blur(4px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
color: 'white',
|
||||
padding: '8px 14px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "20px" }}>{routeTheme.emoji}</span>
|
||||
<span style={{ fontSize: '20px' }}>{routeTheme.emoji}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: "14px", opacity: 0.8 }}>
|
||||
Route {currentRoute}
|
||||
</div>
|
||||
<div style={{ fontSize: "12px", opacity: 0.9 }}>
|
||||
{routeTheme.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', opacity: 0.8 }}>Route {currentRoute}</div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.9 }}>{routeTheme.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time of Day */}
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(0, 0, 0, 0.3)",
|
||||
color: "white",
|
||||
padding: "6px 12px",
|
||||
borderRadius: "8px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
backdropFilter: "blur(4px)",
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
color: 'white',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
>
|
||||
{periodName}
|
||||
@@ -100,16 +92,16 @@ export const GameHUD = memo(
|
||||
<div
|
||||
data-component="time-remaining"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "10px",
|
||||
right: "10px",
|
||||
background: "rgba(0, 0, 0, 0.3)",
|
||||
color: "white",
|
||||
padding: "6px 12px",
|
||||
borderRadius: "8px",
|
||||
fontSize: "18px",
|
||||
fontWeight: "bold",
|
||||
backdropFilter: "blur(4px)",
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
right: '10px',
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
color: 'white',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
backdropFilter: 'blur(4px)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
@@ -120,11 +112,11 @@ export const GameHUD = memo(
|
||||
<div
|
||||
data-component="pressure-gauge-container"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: "20px",
|
||||
left: "20px",
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
left: '20px',
|
||||
zIndex: 1000,
|
||||
width: "120px",
|
||||
width: '120px',
|
||||
}}
|
||||
>
|
||||
<PressureGauge pressure={pressure} />
|
||||
@@ -135,27 +127,23 @@ export const GameHUD = memo(
|
||||
<div
|
||||
data-component="passenger-list"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: "20px",
|
||||
right: "20px",
|
||||
display: "flex",
|
||||
flexDirection: "column-reverse",
|
||||
gap: "8px",
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column-reverse',
|
||||
gap: '8px',
|
||||
zIndex: 1000,
|
||||
maxHeight: "calc(100vh - 40px)",
|
||||
overflowY: "auto",
|
||||
maxHeight: 'calc(100vh - 40px)',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{nonDeliveredPassengers.map((passenger) => (
|
||||
<PassengerCard
|
||||
key={passenger.id}
|
||||
passenger={passenger}
|
||||
originStation={stations.find(
|
||||
(s) => s.id === passenger.originStationId,
|
||||
)}
|
||||
destinationStation={stations.find(
|
||||
(s) => s.id === passenger.destinationStationId,
|
||||
)}
|
||||
originStation={stations.find((s) => s.id === passenger.originStationId)}
|
||||
destinationStation={stations.find((s) => s.id === passenger.destinationStationId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -166,17 +154,16 @@ export const GameHUD = memo(
|
||||
<div
|
||||
data-component="sprint-question-display"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: "20px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
background: "rgba(255, 255, 255, 0.98)",
|
||||
borderRadius: "24px",
|
||||
padding: "28px 50px",
|
||||
boxShadow:
|
||||
"0 16px 40px rgba(0, 0, 0, 0.5), 0 0 0 5px rgba(59, 130, 246, 0.4)",
|
||||
backdropFilter: "blur(12px)",
|
||||
border: "4px solid rgba(255, 255, 255, 0.95)",
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
borderRadius: '24px',
|
||||
padding: '28px 50px',
|
||||
boxShadow: '0 16px 40px rgba(0, 0, 0, 0.5), 0 0 0 5px rgba(59, 130, 246, 0.4)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
border: '4px solid rgba(255, 255, 255, 0.95)',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
@@ -184,38 +171,38 @@ export const GameHUD = memo(
|
||||
<div
|
||||
data-element="sprint-question-equation"
|
||||
style={{
|
||||
fontSize: "96px",
|
||||
fontWeight: "bold",
|
||||
color: "#1f2937",
|
||||
lineHeight: "1.1",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "20px",
|
||||
justifyContent: "center",
|
||||
fontSize: '96px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
lineHeight: '1.1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '20px',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #3b82f6, #8b5cf6)",
|
||||
color: "white",
|
||||
padding: "12px 32px",
|
||||
borderRadius: "16px",
|
||||
minWidth: "140px",
|
||||
display: "inline-block",
|
||||
textShadow: "0 3px 10px rgba(0, 0, 0, 0.3)",
|
||||
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
|
||||
color: 'white',
|
||||
padding: '12px 32px',
|
||||
borderRadius: '16px',
|
||||
minWidth: '140px',
|
||||
display: 'inline-block',
|
||||
textShadow: '0 3px 10px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
{currentInput || "?"}
|
||||
{currentInput || '?'}
|
||||
</span>
|
||||
<span style={{ color: "#6b7280" }}>+</span>
|
||||
<span style={{ color: '#6b7280' }}>+</span>
|
||||
{currentQuestion.showAsAbacus ? (
|
||||
<div
|
||||
style={{
|
||||
transform: "scale(2.4) translateY(8%)",
|
||||
transformOrigin: "center center",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transform: 'scale(2.4) translateY(8%)',
|
||||
transformOrigin: 'center center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AbacusTarget number={currentQuestion.number} />
|
||||
@@ -223,16 +210,14 @@ export const GameHUD = memo(
|
||||
) : (
|
||||
<span>{currentQuestion.number}</span>
|
||||
)}
|
||||
<span style={{ color: "#6b7280" }}>=</span>
|
||||
<span style={{ color: "#10b981" }}>
|
||||
{currentQuestion.targetSum}
|
||||
</span>
|
||||
<span style={{ color: '#6b7280' }}>=</span>
|
||||
<span style={{ color: '#10b981' }}>{currentQuestion.targetSum}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
GameHUD.displayName = "GameHUD";
|
||||
GameHUD.displayName = 'GameHUD'
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useGameMode } from "@/contexts/GameModeContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { useComplementRace } from "../../context/ComplementRaceContext";
|
||||
import type { AIRacer } from "../../lib/gameTypes";
|
||||
import { SpeechBubble } from "../AISystem/SpeechBubble";
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useUserProfile } from '@/contexts/UserProfileContext'
|
||||
import { useComplementRace } from '../../context/ComplementRaceContext'
|
||||
import type { AIRacer } from '../../lib/gameTypes'
|
||||
import { SpeechBubble } from '../AISystem/SpeechBubble'
|
||||
|
||||
interface LinearTrackProps {
|
||||
playerProgress: number;
|
||||
aiRacers: AIRacer[];
|
||||
raceGoal: number;
|
||||
showFinishLine?: boolean;
|
||||
playerProgress: number
|
||||
aiRacers: AIRacer[]
|
||||
raceGoal: number
|
||||
showFinishLine?: boolean
|
||||
}
|
||||
|
||||
export function LinearTrack({
|
||||
@@ -19,72 +19,72 @@ export function LinearTrack({
|
||||
raceGoal,
|
||||
showFinishLine = true,
|
||||
}: LinearTrackProps) {
|
||||
const { state, dispatch } = useComplementRace();
|
||||
const { players } = useGameMode();
|
||||
const { profile: _profile } = useUserProfile();
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const { players } = useGameMode()
|
||||
const { profile: _profile } = useUserProfile()
|
||||
|
||||
// Get the first active player's emoji
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id);
|
||||
const firstActivePlayer = activePlayers[0];
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? "👤";
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id)
|
||||
const firstActivePlayer = activePlayers[0]
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
|
||||
|
||||
// Position calculation: leftPercent = Math.min(98, (progress / raceGoal) * 96 + 2)
|
||||
// 2% minimum (start), 98% maximum (near finish), 96% range for race
|
||||
const getPosition = (progress: number) => {
|
||||
return Math.min(98, (progress / raceGoal) * 96 + 2);
|
||||
};
|
||||
return Math.min(98, (progress / raceGoal) * 96 + 2)
|
||||
}
|
||||
|
||||
const playerPosition = getPosition(playerProgress);
|
||||
const playerPosition = getPosition(playerProgress)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="linear-track"
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: "200px",
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '200px',
|
||||
background:
|
||||
"linear-gradient(to bottom, #87ceeb 0%, #e0f2fe 50%, #90ee90 50%, #d4f1d4 100%)",
|
||||
borderRadius: "12px",
|
||||
overflow: "hidden",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
|
||||
marginTop: "20px",
|
||||
'linear-gradient(to bottom, #87ceeb 0%, #e0f2fe 50%, #90ee90 50%, #d4f1d4 100%)',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
marginTop: '20px',
|
||||
}}
|
||||
>
|
||||
{/* Track lines */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "2px",
|
||||
background: "rgba(0, 0, 0, 0.1)",
|
||||
transform: "translateY(-50%)",
|
||||
height: '2px',
|
||||
background: 'rgba(0, 0, 0, 0.1)',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "40%",
|
||||
position: 'absolute',
|
||||
top: '40%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "1px",
|
||||
background: "rgba(0, 0, 0, 0.05)",
|
||||
transform: "translateY(-50%)",
|
||||
height: '1px',
|
||||
background: 'rgba(0, 0, 0, 0.05)',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "60%",
|
||||
position: 'absolute',
|
||||
top: '60%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "1px",
|
||||
background: "rgba(0, 0, 0, 0.05)",
|
||||
transform: "translateY(-50%)",
|
||||
height: '1px',
|
||||
background: 'rgba(0, 0, 0, 0.05)',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -92,14 +92,14 @@ export function LinearTrack({
|
||||
{showFinishLine && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: "2%",
|
||||
position: 'absolute',
|
||||
right: '2%',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: "4px",
|
||||
width: '4px',
|
||||
background:
|
||||
"repeating-linear-gradient(0deg, black 0px, black 10px, white 10px, white 20px)",
|
||||
boxShadow: "0 0 10px rgba(0, 0, 0, 0.3)",
|
||||
'repeating-linear-gradient(0deg, black 0px, black 10px, white 10px, white 20px)',
|
||||
boxShadow: '0 0 10px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -107,13 +107,13 @@ export function LinearTrack({
|
||||
{/* Player racer */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
left: `${playerPosition}%`,
|
||||
top: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
fontSize: "32px",
|
||||
transition: "left 0.3s ease-out",
|
||||
filter: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))",
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
fontSize: '32px',
|
||||
transition: 'left 0.3s ease-out',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
@@ -122,20 +122,20 @@ export function LinearTrack({
|
||||
|
||||
{/* AI racers */}
|
||||
{aiRacers.map((racer, index) => {
|
||||
const aiPosition = getPosition(racer.position);
|
||||
const activeBubble = state.activeSpeechBubbles.get(racer.id);
|
||||
const aiPosition = getPosition(racer.position)
|
||||
const activeBubble = state.activeSpeechBubbles.get(racer.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={racer.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
left: `${aiPosition}%`,
|
||||
top: `${35 + index * 15}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
fontSize: "28px",
|
||||
transition: "left 0.2s linear",
|
||||
filter: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))",
|
||||
transform: 'translate(-50%, -50%)',
|
||||
fontSize: '28px',
|
||||
transition: 'left 0.2s linear',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
@@ -143,32 +143,30 @@ export function LinearTrack({
|
||||
{activeBubble && (
|
||||
<SpeechBubble
|
||||
message={activeBubble}
|
||||
onHide={() =>
|
||||
dispatch({ type: "CLEAR_AI_COMMENT", racerId: racer.id })
|
||||
}
|
||||
onHide={() => dispatch({ type: 'CLEAR_AI_COMMENT', racerId: racer.id })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "10px",
|
||||
left: "10px",
|
||||
background: "rgba(255, 255, 255, 0.9)",
|
||||
padding: "6px 12px",
|
||||
borderRadius: "8px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
color: "#1f2937",
|
||||
boxShadow: "0 2px 6px rgba(0, 0, 0, 0.1)",
|
||||
position: 'absolute',
|
||||
bottom: '10px',
|
||||
left: '10px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
{playerProgress} / {raceGoal}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { memo } from "react";
|
||||
import type { Passenger, Station } from "../../lib/gameTypes";
|
||||
import type { Landmark } from "../../lib/landmarks";
|
||||
import { memo } from 'react'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { Landmark } from '../../lib/landmarks'
|
||||
|
||||
interface RailroadTrackPathProps {
|
||||
tiesAndRails: {
|
||||
ties: Array<{ x1: number; y1: number; x2: number; y2: number }>;
|
||||
leftRailPath: string;
|
||||
rightRailPath: string;
|
||||
} | null;
|
||||
referencePath: string;
|
||||
pathRef: React.RefObject<SVGPathElement>;
|
||||
landmarkPositions: Array<{ x: number; y: number }>;
|
||||
landmarks: Landmark[];
|
||||
stationPositions: Array<{ x: number; y: number }>;
|
||||
stations: Station[];
|
||||
passengers: Passenger[];
|
||||
boardingAnimations: Map<string, unknown>;
|
||||
disembarkingAnimations: Map<string, unknown>;
|
||||
ties: Array<{ x1: number; y1: number; x2: number; y2: number }>
|
||||
leftRailPath: string
|
||||
rightRailPath: string
|
||||
} | null
|
||||
referencePath: string
|
||||
pathRef: React.RefObject<SVGPathElement>
|
||||
landmarkPositions: Array<{ x: number; y: number }>
|
||||
landmarks: Landmark[]
|
||||
stationPositions: Array<{ x: number; y: number }>
|
||||
stations: Station[]
|
||||
passengers: Passenger[]
|
||||
boardingAnimations: Map<string, unknown>
|
||||
disembarkingAnimations: Map<string, unknown>
|
||||
}
|
||||
|
||||
export const RailroadTrackPath = memo(
|
||||
@@ -76,13 +76,7 @@ export const RailroadTrackPath = memo(
|
||||
)}
|
||||
|
||||
{/* Reference path (invisible, used for positioning) */}
|
||||
<path
|
||||
ref={pathRef}
|
||||
d={referencePath}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path ref={pathRef} d={referencePath} fill="none" stroke="transparent" strokeWidth="2" />
|
||||
|
||||
{/* Landmarks - background scenery */}
|
||||
{landmarkPositions.map((pos, index) => (
|
||||
@@ -93,9 +87,9 @@ export const RailroadTrackPath = memo(
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: `${(landmarks[index]?.size || 24) * 2.0}px`,
|
||||
pointerEvents: "none",
|
||||
pointerEvents: 'none',
|
||||
opacity: 0.7,
|
||||
filter: "drop-shadow(0 2px 3px rgba(0, 0, 0, 0.2))",
|
||||
filter: 'drop-shadow(0 2px 3px rgba(0, 0, 0, 0.2))',
|
||||
}}
|
||||
>
|
||||
{landmarks[index]?.emoji}
|
||||
@@ -104,22 +98,22 @@ export const RailroadTrackPath = memo(
|
||||
|
||||
{/* Station markers */}
|
||||
{stationPositions.map((pos, index) => {
|
||||
const station = stations[index];
|
||||
const station = stations[index]
|
||||
// Find passengers waiting at this station (exclude currently boarding)
|
||||
const waitingPassengers = passengers.filter(
|
||||
(p) =>
|
||||
p.originStationId === station?.id &&
|
||||
!p.isBoarded &&
|
||||
!p.isDelivered &&
|
||||
!boardingAnimations.has(p.id),
|
||||
);
|
||||
!boardingAnimations.has(p.id)
|
||||
)
|
||||
// Find passengers delivered at this station (exclude currently disembarking)
|
||||
const deliveredPassengers = passengers.filter(
|
||||
(p) =>
|
||||
p.destinationStationId === station?.id &&
|
||||
p.isDelivered &&
|
||||
!disembarkingAnimations.has(p.id),
|
||||
);
|
||||
!disembarkingAnimations.has(p.id)
|
||||
)
|
||||
|
||||
return (
|
||||
<g key={`station-${index}`}>
|
||||
@@ -138,7 +132,7 @@ export const RailroadTrackPath = memo(
|
||||
y={pos.y - 40}
|
||||
textAnchor="middle"
|
||||
fontSize="48"
|
||||
style={{ pointerEvents: "none" }}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{station?.icon}
|
||||
</text>
|
||||
@@ -153,12 +147,11 @@ export const RailroadTrackPath = memo(
|
||||
strokeWidth="0.5"
|
||||
style={{
|
||||
fontWeight: 900,
|
||||
pointerEvents: "none",
|
||||
fontFamily:
|
||||
'"Comic Sans MS", "Chalkboard SE", "Bradley Hand", cursive',
|
||||
textShadow: "0 2px 4px rgba(0, 0, 0, 0.2)",
|
||||
letterSpacing: "0.5px",
|
||||
paintOrder: "stroke fill",
|
||||
pointerEvents: 'none',
|
||||
fontFamily: '"Comic Sans MS", "Chalkboard SE", "Bradley Hand", cursive',
|
||||
textShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
|
||||
letterSpacing: '0.5px',
|
||||
paintOrder: 'stroke fill',
|
||||
}}
|
||||
>
|
||||
{station?.name}
|
||||
@@ -172,11 +165,11 @@ export const RailroadTrackPath = memo(
|
||||
y={pos.y - 30}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: "55px",
|
||||
pointerEvents: "none",
|
||||
fontSize: '55px',
|
||||
pointerEvents: 'none',
|
||||
filter: passenger.isUrgent
|
||||
? "drop-shadow(0 0 8px rgba(245, 158, 11, 0.8))"
|
||||
: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))",
|
||||
? 'drop-shadow(0 0 8px rgba(245, 158, 11, 0.8))'
|
||||
: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
|
||||
}}
|
||||
>
|
||||
{passenger.avatar}
|
||||
@@ -187,27 +180,25 @@ export const RailroadTrackPath = memo(
|
||||
{deliveredPassengers.map((passenger, pIndex) => (
|
||||
<text
|
||||
key={`delivered-${passenger.id}`}
|
||||
x={
|
||||
pos.x + (pIndex - deliveredPassengers.length / 2 + 0.5) * 28
|
||||
}
|
||||
x={pos.x + (pIndex - deliveredPassengers.length / 2 + 0.5) * 28}
|
||||
y={pos.y - 30}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: "55px",
|
||||
pointerEvents: "none",
|
||||
filter: "drop-shadow(0 0 12px rgba(16, 185, 129, 0.8))",
|
||||
animation: "celebrateDelivery 2s ease-out forwards",
|
||||
fontSize: '55px',
|
||||
pointerEvents: 'none',
|
||||
filter: 'drop-shadow(0 0 12px rgba(16, 185, 129, 0.8))',
|
||||
animation: 'celebrateDelivery 2s ease-out forwards',
|
||||
}}
|
||||
>
|
||||
{passenger.avatar}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
RailroadTrackPath.displayName = "RailroadTrackPath";
|
||||
RailroadTrackPath.displayName = 'RailroadTrackPath'
|
||||
|
||||
@@ -1,55 +1,53 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { animated, useSpring } from "@react-spring/web";
|
||||
import { memo, useMemo, useRef, useState } from "react";
|
||||
import { useGameMode } from "@/contexts/GameModeContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { useComplementRace } from "../../context/ComplementRaceContext";
|
||||
import { animated, useSpring } from '@react-spring/web'
|
||||
import { memo, useMemo, useRef, useState } from 'react'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useUserProfile } from '@/contexts/UserProfileContext'
|
||||
import { useComplementRace } from '../../context/ComplementRaceContext'
|
||||
import {
|
||||
type BoardingAnimation,
|
||||
type DisembarkingAnimation,
|
||||
usePassengerAnimations,
|
||||
} from "../../hooks/usePassengerAnimations";
|
||||
import type { ComplementQuestion } from "../../lib/gameTypes";
|
||||
import { useSteamJourney } from "../../hooks/useSteamJourney";
|
||||
import { useTrackManagement } from "../../hooks/useTrackManagement";
|
||||
import { useTrainTransforms } from "../../hooks/useTrainTransforms";
|
||||
import { calculateMaxConcurrentPassengers } from "../../lib/passengerGenerator";
|
||||
import { RailroadTrackGenerator } from "../../lib/RailroadTrackGenerator";
|
||||
import { getRouteTheme } from "../../lib/routeThemes";
|
||||
import { GameHUD } from "./GameHUD";
|
||||
import { RailroadTrackPath } from "./RailroadTrackPath";
|
||||
import { TrainAndCars } from "./TrainAndCars";
|
||||
import { TrainTerrainBackground } from "./TrainTerrainBackground";
|
||||
} from '../../hooks/usePassengerAnimations'
|
||||
import type { ComplementQuestion } from '../../lib/gameTypes'
|
||||
import { useSteamJourney } from '../../hooks/useSteamJourney'
|
||||
import { useTrackManagement } from '../../hooks/useTrackManagement'
|
||||
import { useTrainTransforms } from '../../hooks/useTrainTransforms'
|
||||
import { calculateMaxConcurrentPassengers } from '../../lib/passengerGenerator'
|
||||
import { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { getRouteTheme } from '../../lib/routeThemes'
|
||||
import { GameHUD } from './GameHUD'
|
||||
import { RailroadTrackPath } from './RailroadTrackPath'
|
||||
import { TrainAndCars } from './TrainAndCars'
|
||||
import { TrainTerrainBackground } from './TrainTerrainBackground'
|
||||
|
||||
const BoardingPassengerAnimation = memo(
|
||||
({ animation }: { animation: BoardingAnimation }) => {
|
||||
const spring = useSpring({
|
||||
from: { x: animation.fromX, y: animation.fromY, opacity: 1 },
|
||||
to: { x: animation.toX, y: animation.toY, opacity: 1 },
|
||||
config: { tension: 120, friction: 14 },
|
||||
});
|
||||
const BoardingPassengerAnimation = memo(({ animation }: { animation: BoardingAnimation }) => {
|
||||
const spring = useSpring({
|
||||
from: { x: animation.fromX, y: animation.fromY, opacity: 1 },
|
||||
to: { x: animation.toX, y: animation.toY, opacity: 1 },
|
||||
config: { tension: 120, friction: 14 },
|
||||
})
|
||||
|
||||
return (
|
||||
<animated.text
|
||||
x={spring.x}
|
||||
y={spring.y}
|
||||
textAnchor="middle"
|
||||
opacity={spring.opacity}
|
||||
style={{
|
||||
fontSize: "55px",
|
||||
pointerEvents: "none",
|
||||
filter: animation.passenger.isUrgent
|
||||
? "drop-shadow(0 0 8px rgba(245, 158, 11, 0.8))"
|
||||
: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))",
|
||||
}}
|
||||
>
|
||||
{animation.passenger.avatar}
|
||||
</animated.text>
|
||||
);
|
||||
},
|
||||
);
|
||||
BoardingPassengerAnimation.displayName = "BoardingPassengerAnimation";
|
||||
return (
|
||||
<animated.text
|
||||
x={spring.x}
|
||||
y={spring.y}
|
||||
textAnchor="middle"
|
||||
opacity={spring.opacity}
|
||||
style={{
|
||||
fontSize: '55px',
|
||||
pointerEvents: 'none',
|
||||
filter: animation.passenger.isUrgent
|
||||
? 'drop-shadow(0 0 8px rgba(245, 158, 11, 0.8))'
|
||||
: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
|
||||
}}
|
||||
>
|
||||
{animation.passenger.avatar}
|
||||
</animated.text>
|
||||
)
|
||||
})
|
||||
BoardingPassengerAnimation.displayName = 'BoardingPassengerAnimation'
|
||||
|
||||
const DisembarkingPassengerAnimation = memo(
|
||||
({ animation }: { animation: DisembarkingAnimation }) => {
|
||||
@@ -57,7 +55,7 @@ const DisembarkingPassengerAnimation = memo(
|
||||
from: { x: animation.fromX, y: animation.fromY, opacity: 1 },
|
||||
to: { x: animation.toX, y: animation.toY, opacity: 1 },
|
||||
config: { tension: 120, friction: 14 },
|
||||
});
|
||||
})
|
||||
|
||||
return (
|
||||
<animated.text
|
||||
@@ -66,25 +64,25 @@ const DisembarkingPassengerAnimation = memo(
|
||||
textAnchor="middle"
|
||||
opacity={spring.opacity}
|
||||
style={{
|
||||
fontSize: "55px",
|
||||
pointerEvents: "none",
|
||||
filter: "drop-shadow(0 0 12px rgba(16, 185, 129, 0.8))",
|
||||
fontSize: '55px',
|
||||
pointerEvents: 'none',
|
||||
filter: 'drop-shadow(0 0 12px rgba(16, 185, 129, 0.8))',
|
||||
}}
|
||||
>
|
||||
{animation.passenger.avatar}
|
||||
</animated.text>
|
||||
);
|
||||
},
|
||||
);
|
||||
DisembarkingPassengerAnimation.displayName = "DisembarkingPassengerAnimation";
|
||||
)
|
||||
}
|
||||
)
|
||||
DisembarkingPassengerAnimation.displayName = 'DisembarkingPassengerAnimation'
|
||||
|
||||
interface SteamTrainJourneyProps {
|
||||
momentum: number;
|
||||
trainPosition: number;
|
||||
pressure: number;
|
||||
elapsedTime: number;
|
||||
currentQuestion: ComplementQuestion | null;
|
||||
currentInput: string;
|
||||
momentum: number
|
||||
trainPosition: number
|
||||
pressure: number
|
||||
elapsedTime: number
|
||||
currentQuestion: ComplementQuestion | null
|
||||
currentInput: string
|
||||
}
|
||||
|
||||
export function SteamTrainJourney({
|
||||
@@ -95,33 +93,30 @@ export function SteamTrainJourney({
|
||||
currentQuestion,
|
||||
currentInput,
|
||||
}: SteamTrainJourneyProps) {
|
||||
const { state } = useComplementRace();
|
||||
const { getSkyGradient, getTimeOfDayPeriod } = useSteamJourney();
|
||||
const _skyGradient = getSkyGradient();
|
||||
const period = getTimeOfDayPeriod();
|
||||
const { players } = useGameMode();
|
||||
const { profile: _profile } = useUserProfile();
|
||||
const { state } = useComplementRace()
|
||||
const { getSkyGradient, getTimeOfDayPeriod } = useSteamJourney()
|
||||
const _skyGradient = getSkyGradient()
|
||||
const period = getTimeOfDayPeriod()
|
||||
const { players } = useGameMode()
|
||||
const { profile: _profile } = useUserProfile()
|
||||
|
||||
// Get the first active player's emoji
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id);
|
||||
const firstActivePlayer = activePlayers[0];
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? "👤";
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id)
|
||||
const firstActivePlayer = activePlayers[0]
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
|
||||
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const pathRef = useRef<SVGPathElement>(null);
|
||||
const [trackGenerator] = useState(() => new RailroadTrackGenerator(800, 600));
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const pathRef = useRef<SVGPathElement>(null)
|
||||
const [trackGenerator] = useState(() => new RailroadTrackGenerator(800, 600))
|
||||
|
||||
// Calculate the number of train cars dynamically based on max concurrent passengers
|
||||
const maxCars = useMemo(() => {
|
||||
const maxPassengers = calculateMaxConcurrentPassengers(
|
||||
state.passengers,
|
||||
state.stations,
|
||||
);
|
||||
const maxPassengers = calculateMaxConcurrentPassengers(state.passengers, state.stations)
|
||||
// Ensure at least 1 car, even if no passengers
|
||||
return Math.max(1, maxPassengers);
|
||||
}, [state.passengers, state.stations]);
|
||||
return Math.max(1, maxPassengers)
|
||||
}, [state.passengers, state.stations])
|
||||
|
||||
const carSpacing = 7; // Distance between cars (in % of track)
|
||||
const carSpacing = 7 // Distance between cars (in % of track)
|
||||
|
||||
// Train transforms (extracted to hook)
|
||||
const { trainTransform, trainCars, locomotiveOpacity } = useTrainTransforms({
|
||||
@@ -130,7 +125,7 @@ export function SteamTrainJourney({
|
||||
pathRef,
|
||||
maxCars,
|
||||
carSpacing,
|
||||
});
|
||||
})
|
||||
|
||||
// Track management (extracted to hook)
|
||||
const {
|
||||
@@ -149,46 +144,37 @@ export function SteamTrainJourney({
|
||||
passengers: state.passengers,
|
||||
maxCars,
|
||||
carSpacing,
|
||||
});
|
||||
})
|
||||
|
||||
// Passenger animations (extracted to hook)
|
||||
const { boardingAnimations, disembarkingAnimations } = usePassengerAnimations(
|
||||
{
|
||||
passengers: state.passengers,
|
||||
stations: state.stations,
|
||||
stationPositions,
|
||||
trainPosition,
|
||||
trackGenerator,
|
||||
pathRef,
|
||||
},
|
||||
);
|
||||
const { boardingAnimations, disembarkingAnimations } = usePassengerAnimations({
|
||||
passengers: state.passengers,
|
||||
stations: state.stations,
|
||||
stationPositions,
|
||||
trainPosition,
|
||||
trackGenerator,
|
||||
pathRef,
|
||||
})
|
||||
|
||||
// Time remaining (60 seconds total)
|
||||
const timeRemaining = Math.max(0, 60 - Math.floor(elapsedTime / 1000));
|
||||
const timeRemaining = Math.max(0, 60 - Math.floor(elapsedTime / 1000))
|
||||
|
||||
// Period names for display
|
||||
const periodNames = [
|
||||
"Dawn",
|
||||
"Morning",
|
||||
"Midday",
|
||||
"Afternoon",
|
||||
"Dusk",
|
||||
"Night",
|
||||
];
|
||||
const periodNames = ['Dawn', 'Morning', 'Midday', 'Afternoon', 'Dusk', 'Night']
|
||||
|
||||
// Get current route theme
|
||||
const routeTheme = getRouteTheme(state.currentRoute);
|
||||
const routeTheme = getRouteTheme(state.currentRoute)
|
||||
|
||||
// Memoize filtered passenger lists to avoid recalculating on every render
|
||||
const boardedPassengers = useMemo(
|
||||
() => displayPassengers.filter((p) => p.isBoarded && !p.isDelivered),
|
||||
[displayPassengers],
|
||||
);
|
||||
[displayPassengers]
|
||||
)
|
||||
|
||||
const nonDeliveredPassengers = useMemo(
|
||||
() => displayPassengers.filter((p) => !p.isDelivered),
|
||||
[displayPassengers],
|
||||
);
|
||||
[displayPassengers]
|
||||
)
|
||||
|
||||
// Memoize ground texture circles to avoid recreating on every render
|
||||
const groundTextureCircles = useMemo(
|
||||
@@ -199,23 +185,23 @@ export function SteamTrainJourney({
|
||||
cy: 140 + (i % 5) * 60,
|
||||
r: 2 + (i % 3),
|
||||
})),
|
||||
[],
|
||||
);
|
||||
[]
|
||||
)
|
||||
|
||||
if (!trackData) return null;
|
||||
if (!trackData) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="steam-train-journey"
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "transparent",
|
||||
overflow: "visible",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "stretch",
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'transparent',
|
||||
overflow: 'visible',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'stretch',
|
||||
}}
|
||||
>
|
||||
{/* Game HUD - overlays and UI elements */}
|
||||
@@ -237,10 +223,10 @@ export function SteamTrainJourney({
|
||||
ref={svgRef}
|
||||
viewBox="-50 -50 900 700"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
aspectRatio: "800 / 600",
|
||||
overflow: "visible",
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: '800 / 600',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
{/* Terrain background - ground, mountains, and tunnels */}
|
||||
@@ -328,5 +314,5 @@ export function SteamTrainJourney({
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,41 +1,38 @@
|
||||
"use client";
|
||||
'use client'
|
||||
|
||||
import { memo } from "react";
|
||||
import type {
|
||||
BoardingAnimation,
|
||||
DisembarkingAnimation,
|
||||
} from "../../hooks/usePassengerAnimations";
|
||||
import type { Passenger } from "../../lib/gameTypes";
|
||||
import { memo } from 'react'
|
||||
import type { BoardingAnimation, DisembarkingAnimation } from '../../hooks/usePassengerAnimations'
|
||||
import type { Passenger } from '../../lib/gameTypes'
|
||||
|
||||
interface TrainCarTransform {
|
||||
x: number;
|
||||
y: number;
|
||||
rotation: number;
|
||||
position: number;
|
||||
opacity: number;
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
position: number
|
||||
opacity: number
|
||||
}
|
||||
|
||||
interface TrainTransform {
|
||||
x: number;
|
||||
y: number;
|
||||
rotation: number;
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
}
|
||||
|
||||
interface TrainAndCarsProps {
|
||||
boardingAnimations: Map<string, BoardingAnimation>;
|
||||
disembarkingAnimations: Map<string, DisembarkingAnimation>;
|
||||
boardingAnimations: Map<string, BoardingAnimation>
|
||||
disembarkingAnimations: Map<string, DisembarkingAnimation>
|
||||
BoardingPassengerAnimation: React.ComponentType<{
|
||||
animation: BoardingAnimation;
|
||||
}>;
|
||||
animation: BoardingAnimation
|
||||
}>
|
||||
DisembarkingPassengerAnimation: React.ComponentType<{
|
||||
animation: DisembarkingAnimation;
|
||||
}>;
|
||||
trainCars: TrainCarTransform[];
|
||||
boardedPassengers: Passenger[];
|
||||
trainTransform: TrainTransform;
|
||||
locomotiveOpacity: number;
|
||||
playerEmoji: string;
|
||||
momentum: number;
|
||||
animation: DisembarkingAnimation
|
||||
}>
|
||||
trainCars: TrainCarTransform[]
|
||||
boardedPassengers: Passenger[]
|
||||
trainTransform: TrainTransform
|
||||
locomotiveOpacity: number
|
||||
playerEmoji: string
|
||||
momentum: number
|
||||
}
|
||||
|
||||
export const TrainAndCars = memo(
|
||||
@@ -72,7 +69,7 @@ export const TrainAndCars = memo(
|
||||
{/* Train cars - render in reverse order so locomotive appears on top */}
|
||||
{trainCars.map((carTransform, carIndex) => {
|
||||
// Assign passenger to this car (if one exists for this car index)
|
||||
const passenger = boardedPassengers[carIndex];
|
||||
const passenger = boardedPassengers[carIndex]
|
||||
|
||||
return (
|
||||
<g
|
||||
@@ -81,7 +78,7 @@ export const TrainAndCars = memo(
|
||||
transform={`translate(${carTransform.x}, ${carTransform.y}) rotate(${carTransform.rotation}) scale(-1, 1)`}
|
||||
opacity={carTransform.opacity}
|
||||
style={{
|
||||
transition: "opacity 0.5s ease-in",
|
||||
transition: 'opacity 0.5s ease-in',
|
||||
}}
|
||||
>
|
||||
{/* Train car */}
|
||||
@@ -91,9 +88,9 @@ export const TrainAndCars = memo(
|
||||
y={0}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: "65px",
|
||||
filter: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))",
|
||||
pointerEvents: "none",
|
||||
fontSize: '65px',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
🚃
|
||||
@@ -107,18 +104,18 @@ export const TrainAndCars = memo(
|
||||
y={0}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: "42px",
|
||||
fontSize: '42px',
|
||||
filter: passenger.isUrgent
|
||||
? "drop-shadow(0 0 6px rgba(245, 158, 11, 0.8))"
|
||||
: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))",
|
||||
pointerEvents: "none",
|
||||
? 'drop-shadow(0 0 6px rgba(245, 158, 11, 0.8))'
|
||||
: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{passenger.avatar}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Locomotive - rendered last so it appears on top */}
|
||||
@@ -127,7 +124,7 @@ export const TrainAndCars = memo(
|
||||
transform={`translate(${trainTransform.x}, ${trainTransform.y}) rotate(${trainTransform.rotation}) scale(-1, 1)`}
|
||||
opacity={locomotiveOpacity}
|
||||
style={{
|
||||
transition: "opacity 0.5s ease-in",
|
||||
transition: 'opacity 0.5s ease-in',
|
||||
}}
|
||||
>
|
||||
{/* Train locomotive */}
|
||||
@@ -137,9 +134,9 @@ export const TrainAndCars = memo(
|
||||
y={0}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: "100px",
|
||||
filter: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))",
|
||||
pointerEvents: "none",
|
||||
fontSize: '100px',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
🚂
|
||||
@@ -152,9 +149,9 @@ export const TrainAndCars = memo(
|
||||
y={0}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: "70px",
|
||||
filter: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))",
|
||||
pointerEvents: "none",
|
||||
fontSize: '70px',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{playerEmoji}
|
||||
@@ -170,10 +167,10 @@ export const TrainAndCars = memo(
|
||||
r="10"
|
||||
fill="rgba(255, 255, 255, 0.6)"
|
||||
style={{
|
||||
filter: "blur(4px)",
|
||||
filter: 'blur(4px)',
|
||||
animation: `steamPuffSVG 2s ease-out infinite`,
|
||||
animationDelay: `${delay}s`,
|
||||
pointerEvents: "none",
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -188,16 +185,16 @@ export const TrainAndCars = memo(
|
||||
r="3"
|
||||
fill="#2c2c2c"
|
||||
style={{
|
||||
animation: "coalFallingSVG 1.2s ease-out infinite",
|
||||
animation: 'coalFallingSVG 1.2s ease-out infinite',
|
||||
animationDelay: `${delay}s`,
|
||||
pointerEvents: "none",
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
TrainAndCars.displayName = "TrainAndCars";
|
||||
TrainAndCars.displayName = 'TrainAndCars'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user