Compare commits

...

263 Commits

Author SHA1 Message Date
semantic-release-bot
0eed26966c chore(release): 3.20.0 [skip ci]
## [3.20.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.19.0...v3.20.0) (2025-10-15)

### Features

* adjust tier probabilities for more abacus flavor ([49219e3](49219e34cd))

### Code Refactoring

* use per-word-type tier selection for name generation ([499ee52](499ee525a8))
2025-10-15 19:10:15 +00:00
Thomas Hallock
49219e34cd feat: adjust tier probabilities for more abacus flavor
Change weighted selection from 70/20/10 to 50/25/25:
- Emoji-specific: 70% → 50%
- Category-specific: 20% → 25%
- Global abacus: 10% → 25%

This increases abacus-themed words by 2.5x, ensuring stronger
presence of core abacus vocabulary (Calculator, Abacist, Counter)
while still maintaining emoji personality theming.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 14:09:25 -05:00
Thomas Hallock
499ee525a8 refactor: use per-word-type tier selection for name generation
Changed from tier-then-mix approach to per-word-type selection:
- Before: Pick one tier, then optionally mix with abacus words
- After: Pick tier independently for adjective and noun

Benefits:
- Simpler, cleaner code
- More natural variety in name combinations
- Adjective and noun can come from different tiers naturally
- Examples: "Grinning Calculator" (emoji + global), "Ancient Smiler" (global + emoji)

Each word still uses weighted selection:
- 70% emoji-specific
- 20% category-specific
- 10% global abacus

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 14:03:21 -05:00
semantic-release-bot
843b45b14e chore(release): 3.19.0 [skip ci]
## [3.19.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.18.1...v3.19.0) (2025-10-15)

### Features

* implement avatar-themed name generation with probabilistic mixing ([76a8472](76a8472f12))
2025-10-15 19:01:15 +00:00
Thomas Hallock
76a8472f12 feat: implement avatar-themed name generation with probabilistic mixing
Add comprehensive emoji-themed player name generation system:
- 150+ emoji-specific word lists (10 adjectives + 10 nouns each)
- 45+ category-themed word lists as fallback
- Generic abacus-themed words as ultimate fallback

Probabilistic tier selection for variety:
- 70% emoji-specific (e.g., "Grinning Grinner" for 😀)
- 20% category-specific (e.g., "Cheerful Optimist" for happy faces)
- 10% global abacus theme (e.g., "Lightning Calculator")

Cross-tier mixing for abacus flavor infusion:
- 60% pure themed words
- 30% themed adjective + abacus noun
- 10% abacus adjective + themed noun

Updated all name generation call sites to pass player emoji:
- PlayerConfigDialog.tsx: Pass emoji when generating name
- GameModeContext.tsx: Theme names for default players and new players

This creates ultra-personalized, variety-rich names while maintaining
abacus theme presence across all generated names.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 14:00:18 -05:00
semantic-release-bot
bf02bc14fd chore(release): 3.18.1 [skip ci]
## [3.18.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.18.0...v3.18.1) (2025-10-15)

### Bug Fixes

* **arcade:** prevent empty update in settings API when only gameConfig changes ([ffb626f](ffb626f403))
2025-10-15 18:55:55 +00:00
Thomas Hallock
ffb626f403 fix(arcade): prevent empty update in settings API when only gameConfig changes
When only gameConfig is updated (without accessMode, password, or gameName),
the updateData object remained empty, causing Drizzle to throw "No values to set"
error when attempting to update arcade_rooms table.

Now only updates arcade_rooms if there are actual fields to update, preventing
the 500 error while still allowing gameConfig-only updates to room_game_configs table.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:54:47 -05:00
semantic-release-bot
860fd607be chore(release): 3.18.0 [skip ci]
## [3.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.14...v3.18.0) (2025-10-15)

### Features

* add drizzle migration for room_game_configs table ([3bae00b](3bae00b9a9))

### Documentation

* document manual migration of room_game_configs table ([ff79140](ff791409cf))
2025-10-15 18:41:45 +00:00
Thomas Hallock
3bae00b9a9 feat: add drizzle migration for room_game_configs table
Creates migration 0011 to:
- Create room_game_configs table with proper schema
- Add unique index on (room_id, game_name)
- Migrate existing game_config data from arcade_rooms table

Migration is idempotent and safe to run on any database state:
- Uses IF NOT EXISTS for table and index creation
- Uses INSERT OR IGNORE to avoid duplicate data
- Will work on both fresh databases and existing production

This ensures production will automatically get the new table structure
when the migration runs on deployment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:40:40 -05:00
Thomas Hallock
ff791409cf docs: document manual migration of room_game_configs table
Manual migration applied on 2025-10-15:
- Created room_game_configs table via sqlite3 CLI
- Migrated 6000 existing configs from arcade_rooms.game_config
- 5991 matching configs + 9 memory-quiz configs
- Table created directly instead of through drizzle migration system

The manually created drizzle migration SQL file has been removed since
the migration was applied directly to the database. See
.claude/MANUAL_MIGRATION_0011.md for complete details on the migration
process, verification steps, and rollback plan.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:31:55 -05:00
semantic-release-bot
c1be0277c1 chore(release): 3.17.14 [skip ci]
## [3.17.14](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.13...v3.17.14) (2025-10-15)

### Bug Fixes

* **arcade:** resolve TypeScript errors in game config helpers ([04c9944](04c9944f2e))

### Documentation

* **arcade:** update GAME_SETTINGS_PERSISTENCE.md for new schema ([260bdc2](260bdc2e9d))
2025-10-15 18:21:03 +00:00
Thomas Hallock
04c9944f2e fix(arcade): resolve TypeScript errors in game config helpers
Fixed three TypeScript compilation errors:

1. game-config-helpers.ts:82 - Cast existing.config to object for spread
2. game-config-helpers.ts:116 - Fixed dynamic field assignment in updateGameConfigField
3. MatchingGameValidator.ts:540 - Use proper Difficulty type in MatchingGameConfig

Changes:
- Import Difficulty and GameType from matching types
- Update MatchingGameConfig to use proper union types
- Cast existing.config as object before spreading
- Rewrite updateGameConfigField to avoid type assertion issue

All TypeScript errors resolved. Compilation passes cleanly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:19:58 -05:00
Thomas Hallock
260bdc2e9d docs(arcade): update GAME_SETTINGS_PERSISTENCE.md for new schema
Updated documentation to reflect the refactored implementation:

- Documented new room_game_configs table structure
- Explained shared type system and benefits
- Updated all code examples to use new helpers
- Revised debugging checklist for new architecture
- Added migration notes and rollback plan
- Clarified the four critical systems (was three, now includes helpers)

The documentation now accurately describes the normalized database
schema approach instead of the old monolithic JSON column.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:17:34 -05:00
semantic-release-bot
8dbdc837cc chore(release): 3.17.13 [skip ci]
## [3.17.13](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.12...v3.17.13) (2025-10-15)

### Code Refactoring

* **arcade:** migrate game settings to normalized database schema ([1bd7354](1bd73544df))
2025-10-15 18:17:00 +00:00
Thomas Hallock
1bd73544df refactor(arcade): migrate game settings to normalized database schema
### Schema Changes
- Create `room_game_configs` table with one row per game per room
- Migrate existing gameConfig data from arcade_rooms.game_config JSON column
- Add unique index on (roomId, gameName) for efficient queries

### Benefits
-  Type-safe config access with shared types
-  Smaller rows (only configs for used games)
-  Easier updates (single row vs entire JSON blob)
-  Better concurrency (no lock contention between games)
-  Foundation for per-game audit trail

### Core Changes
1. **Shared Config Types** (`game-configs.ts`)
   - `MatchingGameConfig`, `MemoryQuizGameConfig` interfaces
   - Default configs for each game
   - Single source of truth for all settings

2. **Helper Functions** (`game-config-helpers.ts`)
   - `getGameConfig<T>()` - type-safe config retrieval with defaults
   - `setGameConfig()` - upsert game config
   - `getAllGameConfigs()` - aggregate all game configs for a room
   - `validateGameConfig()` - runtime validation

3. **API Routes**
   - `/api/arcade/rooms/current`: Aggregates configs from new table
   - `/api/arcade/rooms/[roomId]/settings`: Writes to new table

4. **Socket Server** (`socket-server.ts`)
   - Uses `getGameConfig()` helper for session creation
   - Eliminates manual config extraction and defaults

5. **Validators**
   - `MemoryQuizGameValidator.getInitialState(config: MemoryQuizGameConfig)`
   - `MatchingGameValidator.getInitialState(config: MatchingGameConfig)`
   - Type signatures enforce consistency

### Migration Path
- Existing data migrated automatically (SQL in migration file)
- Old `gameConfig` column preserved temporarily
- Client-side providers unchanged (read from aggregated response)

Next steps:
- Test settings persistence thoroughly
- Drop old `gameConfig` column after validation
- Update documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:16:01 -05:00
semantic-release-bot
506bfeccf2 chore(release): 3.17.12 [skip ci]
## [3.17.12](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.11...v3.17.12) (2025-10-15)

### Code Refactoring

* **arcade:** remove non-productive debug logging from memory-quiz ([38e554e](38e554e6ea))

### Documentation

* **arcade:** document game settings persistence architecture ([8f8f112](8f8f112de2))
2025-10-15 18:05:53 +00:00
Thomas Hallock
38e554e6ea refactor(arcade): remove non-productive debug logging from memory-quiz
Removed verbose console.log statements added during settings persistence debugging:
- socket-server.ts: Removed JSON.stringify logging of gameConfig flow
- RoomMemoryQuizProvider.tsx: Removed logging from mergedInitialState useMemo and setConfig
- MemoryQuizGameValidator.ts: Removed logging from validateAcceptNumber

The actual fix (playMode parameter addition) is preserved. Debug logging was only needed to identify the root cause and is no longer necessary.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:04:49 -05:00
Thomas Hallock
8f8f112de2 docs(arcade): document game settings persistence architecture
Added comprehensive documentation for game settings persistence system
after fixing multiple settings bugs (gameType, playMode not persisting).

New documentation:
- .claude/GAME_SETTINGS_PERSISTENCE.md: Complete architecture guide
  - How settings are structured (nested by game name)
  - Three critical systems that must stay in sync
  - Common bugs with detailed solutions
  - Debugging checklist
  - Step-by-step guide for adding new settings

- .claude/GAME_SETTINGS_REFACTORING.md: Recommended improvements
  - Shared config types to prevent type mismatches
  - Helper functions to reduce duplication (getGameConfig, updateGameConfig)
  - Validator config type enforcement
  - Exhaustiveness checking
  - Runtime validation
  - Migration strategy with priority order

Updated .claude/CLAUDE.md to reference these docs with quick reference guide.

This documentation will prevent similar bugs in the future by making the
architecture explicit and providing clear patterns to follow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:54:15 -05:00
semantic-release-bot
f3080b50d9 chore(release): 3.17.11 [skip ci]
## [3.17.11](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.10...v3.17.11) (2025-10-15)

### Bug Fixes

* **memory-quiz:** fix playMode persistence by updating validator ([de0efd5](de0efd5932))
2025-10-15 17:51:21 +00:00
Thomas Hallock
de0efd5932 fix(memory-quiz): fix playMode persistence by updating validator
ROOT CAUSE FOUND:
The MemoryQuizGameValidator.getInitialState() method was hardcoding
playMode to 'cooperative' and not accepting it as a config parameter.

Even though socket-server.ts was passing playMode from the saved config,
the validator's TypeScript signature didn't include it:

BEFORE:
```typescript
getInitialState(config: {
  selectedCount: number
  displayTime: number
  selectedDifficulty: DifficultyLevel
}): SorobanQuizState {
  return {
    // ...
    playMode: 'cooperative',  // ← ALWAYS HARDCODED!
  }
}
```

AFTER:
```typescript
getInitialState(config: {
  selectedCount: number
  displayTime: number
  selectedDifficulty: DifficultyLevel
  playMode?: 'cooperative' | 'competitive'  // ← NEW!
}): SorobanQuizState {
  return {
    // ...
    playMode: config.playMode || 'cooperative',  // ← USES CONFIG VALUE!
  }
}
```

Also added comprehensive debug logging throughout the flow:
- socket-server.ts: logs room.gameConfig, extracted config, and resulting playMode
- RoomMemoryQuizProvider.tsx: logs roomData.gameConfig and merged state
- MemoryQuizGameValidator.ts: logs config received and playMode returned

This will help identify any remaining persistence issues.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:50:25 -05:00
semantic-release-bot
c9e5c473e6 chore(release): 3.17.10 [skip ci]
## [3.17.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.9...v3.17.10) (2025-10-15)

### Bug Fixes

* **memory-quiz:** persist playMode setting across game switches ([487ca7f](487ca7fba6))
2025-10-15 17:47:48 +00:00
Thomas Hallock
487ca7fba6 fix(memory-quiz): persist playMode setting across game switches
The socket-server was missing playMode when creating the initial session
for memory-quiz games. It was only loading selectedCount, displayTime, and
selectedDifficulty from the saved config, causing playMode to always reset
to the default 'cooperative' even when 'competitive' was saved.

Now includes playMode in the initial state config:
- selectedCount
- displayTime
- selectedDifficulty
- playMode (NEW)

This ensures the playMode setting persists when users:
1. Set playMode to 'competitive'
2. Go back to game selection
3. Select memory-quiz again
4. PlayMode is still 'competitive' (not reset to 'cooperative')

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:46:48 -05:00
semantic-release-bot
8f7eebce4b chore(release): 3.17.9 [skip ci]
## [3.17.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.8...v3.17.9) (2025-10-15)

### Bug Fixes

* **arcade:** read nested gameConfig correctly when creating sessions ([94ef392](94ef39234d))
2025-10-15 17:45:11 +00:00
Thomas Hallock
94ef39234d fix(arcade): read nested gameConfig correctly when creating sessions
The session initialization was looking for settings at the wrong level:
- Was reading: room.gameConfig.gameType (undefined, falls back to default)
- Should read: room.gameConfig.matching.gameType (saved value)

gameConfig is structured as:
{
  "matching": { "gameType": "...", "difficulty": ..., "turnTimer": ... },
  "memory-quiz": { "selectedCount": ..., "displayTime": ..., ... }
}

This caused the session to be created with default settings even though
the settings were saved in the database. The client would load the correct
settings from roomData.gameConfig, but then the socket would immediately
overwrite them with the session's default state.

Now properly accesses the nested config for each game type.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:44:10 -05:00
semantic-release-bot
6d14dd8b47 chore(release): 3.17.8 [skip ci]
## [3.17.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.7...v3.17.8) (2025-10-15)

### Bug Fixes

* **arcade:** preserve game settings when returning to game selection ([0ee7739](0ee7739091))
2025-10-15 17:42:27 +00:00
Thomas Hallock
0ee7739091 fix(arcade): preserve game settings when returning to game selection
When users clicked "back to game selection", the clearRoomGameApi function
was sending both gameName: null AND gameConfig: null to the server. This
destroyed all saved game settings (like gameType, difficulty, etc.).

Now clearRoomGameApi only sends gameName: null and preserves gameConfig,
so settings persist when users select a game again.

Root cause discovered via comprehensive database-level logging that traced
the exact data flow through the system.

Fixes settings persistence bug in room mode.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:41:36 -05:00
Thomas Hallock
5c135358fc debug(arcade): add comprehensive database-level logging for gameConfig
Add detailed logging at every layer to trace gameConfig through the system:

Server-side (Settings API):
- Log incoming PATCH request body
- Log database state BEFORE update
- Log what will be written to database
- Log database state AFTER update

Server-side (Current Room API):
- Log what's READ from database when fetching room

Client-side:
- Track roomData.gameConfig changes with useEffect

This will show us exactly when and where gameConfig is being overwritten.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:36:36 -05:00
semantic-release-bot
74554c3669 chore(release): 3.17.7 [skip ci]
## [3.17.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.6...v3.17.7) (2025-10-15)

### Bug Fixes

* **arcade:** prevent gameConfig from being overwritten when switching games ([a89d3a9](a89d3a9701))
2025-10-15 17:33:21 +00:00
Thomas Hallock
a89d3a9701 fix(arcade): prevent gameConfig from being overwritten when switching games
Root cause: setRoomGameApi was sending `gameConfig: {}` when gameConfig
was undefined, which overwrote all saved settings in the database.

Changes:
- Client: Only include gameConfig in request body if explicitly provided
- Server: Only include gameConfig in socket broadcast if provided
- Client handler: Update gameConfig from broadcast if present

This preserves all game settings (difficulty, card count, etc.) when
switching between games in a room.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:32:31 -05:00
semantic-release-bot
180e213d00 chore(release): 3.17.6 [skip ci]
## [3.17.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.5...v3.17.6) (2025-10-15)

### Code Refactoring

* **logging:** use JSON.stringify for all object logging ([c33698c](c33698ce52))
2025-10-15 17:30:09 +00:00
Thomas Hallock
c33698ce52 refactor(logging): use JSON.stringify for all object logging
Replace collapsed object logging with JSON.stringify to ensure full
object details are visible when console logs are copied/pasted.

This affects all settings persistence logging:
- Loading settings from database
- Saving settings to database
- API calls to server
- Cache updates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 12:29:08 -05:00
semantic-release-bot
5b4cb7d35a chore(release): 3.17.5 [skip ci]
## [3.17.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.4...v3.17.5) (2025-10-15)

### Bug Fixes

* **arcade:** implement settings persistence for matching game ([08fe432](08fe4326a6))
2025-10-15 16:04:05 +00:00
Thomas Hallock
eacbafb1ea debug(arcade): add detailed logging for settings persistence
Add comprehensive console logging to trace the settings persistence flow:
- Load settings from database on initialization
- Save settings to database when changed (gameType, difficulty, turnTimer)
- API calls to server with full request/response logging

This will help diagnose if settings are being persisted correctly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 11:03:07 -05:00
Thomas Hallock
08fe4326a6 fix(arcade): implement settings persistence for matching game
- Add useUpdateGameConfig hook and database saves to RoomMemoryPairsProvider
- Load saved settings from gameConfig['matching'] on init
- Save gameType, difficulty, and turnTimer changes to database
- Apply lint fixes: use dot notation instead of bracket notation

Matching game now persists settings when switching between games,
matching the behavior of memory-quiz.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 11:03:07 -05:00
semantic-release-bot
fabb33252c chore(release): 3.17.4 [skip ci]
## [3.17.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.3...v3.17.4) (2025-10-15)

### Bug Fixes

* **matching:** add settings persistence to matching game ([00dcb87](00dcb872b7))
2025-10-15 15:16:36 +00:00
Thomas Hallock
00dcb872b7 fix(matching): add settings persistence to matching game
The matching game was not saving settings to the database at all.
When you changed gameType, difficulty, or turnTimer, it only sent
a move to the arcade session but never saved to the database.

This adds the same persistence logic that memory-quiz uses:

**On Load:**
- Reads settings from gameConfig['matching'] in the database
- Merges with initialState
- Passes to useArcadeSession

**On Change:**
- Sends SET_CONFIG move (for real-time sync)
- Saves to gameConfig['matching'] via updateGameConfig
- Updates TanStack Query cache

Changes:
- Import useUpdateGameConfig hook
- Add mergedInitialState with settings from database
- Save settings in setGameType, setDifficulty, setTurnTimer

Now settings persist when switching between games!

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 10:15:42 -05:00
semantic-release-bot
ea23651cb6 chore(release): 3.17.3 [skip ci]
## [3.17.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.2...v3.17.3) (2025-10-15)

### Bug Fixes

* **arcade:** preserve gameConfig when switching games ([2273c71](2273c71a87))

### Code Refactoring

* remove verbose console logging for cleaner debugging ([9cb5fdd](9cb5fdd2fa))
2025-10-15 15:13:35 +00:00
Thomas Hallock
2273c71a87 fix(arcade): preserve gameConfig when switching games
**ROOT CAUSE:**
When switching games, setRoomGame was called with gameConfig: {},
which OVERWROTE the entire gameConfig in the database, destroying
all saved settings for ALL games.

**THE FIX:**
Remove gameConfig parameter from setRoomGame call - only change the
game name, preserve all existing settings.

**ADDED DEBUG LOGGING:**
Added detailed logging in RoomMemoryQuizProvider to help diagnose
settings persistence issues:
- Log gameConfig on component init
- Log what settings are being loaded
- Log what settings are being saved
- Log the full updated gameConfig

Changes:
- src/app/arcade/room/page.tsx: Don't pass gameConfig when switching games
- src/app/arcade/memory-quiz/context/RoomMemoryQuizProvider.tsx: Added debug logs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 10:12:33 -05:00
Thomas Hallock
9cb5fdd2fa refactor: remove verbose console logging for cleaner debugging
Removed excessive console.log statements from:
- RoomMemoryQuizProvider.tsx: Removed ~14 verbose logs related to player
  metadata, scores, and move processing
- useRoomData.ts: Removed logs for moderation events and player updates

Kept critical logs for debugging settings persistence:
- Loading saved game config
- Saving game config
- Room game changed
- Cache updates

This cleanup makes console output much more manageable when debugging
settings persistence issues.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 10:04:24 -05:00
semantic-release-bot
73c54a7ebc chore(release): 3.17.2 [skip ci]
## [3.17.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.1...v3.17.2) (2025-10-15)

### Bug Fixes

* **room-data:** update query cache when gameConfig changes ([7cea297](7cea297095))
2025-10-15 15:01:02 +00:00
Thomas Hallock
7cea297095 fix(room-data): update query cache when gameConfig changes
The issue was that useUpdateGameConfig was saving settings to the database
but not updating the TanStack Query cache. This meant that when components
re-mounted (e.g., when switching games), they would read stale data from
the cache instead of the newly saved settings.

Changes:
- Added onSuccess callback to useUpdateGameConfig to update the cache
- Added gameConfig field to RoomData interface
- Updated all API functions to include gameConfig in returned data:
  - fetchCurrentRoom
  - createRoomApi
  - joinRoomApi
  - getRoomByCodeApi

Now when settings are saved, the cache is immediately updated, so switching
games and returning shows the correct saved settings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 10:00:05 -05:00
semantic-release-bot
019d36a0ab chore(release): 3.17.1 [skip ci]
## [3.17.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.0...v3.17.1) (2025-10-15)

### Bug Fixes

* **arcade-rooms:** navigate to invite link after room creation ([1922b21](1922b2122b))
* **memory-quiz:** scope game settings by game name for proper persistence ([3dfe54f](3dfe54f1cb))
2025-10-15 14:51:46 +00:00
Thomas Hallock
1922b2122b fix(arcade-rooms): navigate to invite link after room creation
Previously, when creating a new room, users were navigated to
/arcade-rooms/{roomId}, which is the direct room route.

Now users are navigated to /join/{code}, which is the invite link
format. This provides a better user experience as it follows the
same flow as joining via an invite link.

Changes:
- Changed router.push from /arcade-rooms/{id} to /join/{code}

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:50:53 -05:00
Thomas Hallock
3dfe54f1cb fix(memory-quiz): scope game settings by game name for proper persistence
Previously, settings were stored at the root of gameConfig, causing each
game to overwrite the other's settings when switching between games.

Now settings are stored under gameConfig['memory-quiz'], allowing each
game to maintain its own settings independently. When you switch from
memory-quiz to another game and back, the memory-quiz settings are
preserved exactly as you left them.

Changes:
- Load settings from gameConfig['memory-quiz'] instead of root gameConfig
- Save settings to gameConfig['memory-quiz'] to avoid overwriting other games
- Added comments explaining the scoping strategy

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:50:13 -05:00
semantic-release-bot
5f04a3b622 chore(release): 3.17.0 [skip ci]
## [3.17.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.16.0...v3.17.0) (2025-10-15)

### Features

* **memory-quiz:** persist game settings per-game across sessions ([05a8e0a](05a8e0a842))
2025-10-15 14:46:51 +00:00
Thomas Hallock
05a8e0a842 feat(memory-quiz): persist game settings per-game across sessions
Implement per-game settings persistence so that when users switch between
games and come back, their settings are restored. Settings are saved to
the room's gameConfig field in the database.

Changes:
- Add useUpdateGameConfig hook to save settings to room
- Load settings from roomData.gameConfig on provider initialization
- Merge saved config with initialState using useMemo
- Save settings to database when setConfig is called
- Settings persist across:
  - Game switches (memory-quiz -> matching -> memory-quiz)
  - Page refreshes
  - New arcade sessions

Settings saved: selectedCount, displayTime, selectedDifficulty, playMode

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:45:56 -05:00
semantic-release-bot
9dac9b7a36 chore(release): 3.16.0 [skip ci]
## [3.16.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.15.2...v3.16.0) (2025-10-15)

### Features

* **arcade:** broadcast game selection changes to all room members ([b99e754](b99e754395))
2025-10-15 14:44:39 +00:00
Thomas Hallock
b99e754395 feat(arcade): broadcast game selection changes to all room members
Fix issue where game selection by the host was not synchronized to other
room members. When the host selects a game, all players now see the change
in real-time via socket.io.

Server changes:
- Add 'room-game-changed' socket broadcast when gameName is updated
- Emit to all members in the room channel when game is set/changed

Client changes:
- Add socket listener for 'room-game-changed' event in useRoomData
- Update local cache when game change is received
- Room page automatically re-renders with new game selection

This ensures all players stay synchronized when the host selects or changes
the game for the room.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:42:39 -05:00
semantic-release-bot
3eaa84d157 chore(release): 3.15.2 [skip ci]
## [3.15.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.15.1...v3.15.2) (2025-10-15)

### Bug Fixes

* **memory-quiz:** prevent duplicate card processing from optimistic updates ([51676fc](51676fc15f))
2025-10-15 14:36:42 +00:00
Thomas Hallock
51676fc15f fix(memory-quiz): prevent duplicate card processing from optimistic updates
Fix race condition where the host would skip cards due to the effect
running twice on the same card index - once for the optimistic update
and potentially again for the server update.

The issue: When the host calls nextCard(), it immediately applies an
optimistic update that changes currentCardIndex. This triggers the effect
to re-run before the timer has even finished. Since isProcessingRef was
set to false right before calling nextCard(), the effect would start
processing the next card immediately, causing cards to be skipped.

Solution: Track the last processed card index in a ref (lastProcessedIndexRef)
and skip the effect if we're trying to process the same index again. This
ensures each card is only shown once, regardless of how many times the
effect runs due to state changes.

- Add lastProcessedIndexRef to track the last card we processed
- Check at start of effect if currentCardIndex === lastProcessedIndexRef
- Skip duplicate processing to prevent race conditions
- Remove unnecessary dependency on state.quizCards[currentCardIndex]
- Add detailed logging to help debug timing issues

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:35:48 -05:00
semantic-release-bot
82ca31029c chore(release): 3.15.1 [skip ci]
## [3.15.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.15.0...v3.15.1) (2025-10-15)

### Bug Fixes

* **memory-quiz:** synchronize card display across all players in multiplayer ([472f201](472f201088))
2025-10-15 14:26:34 +00:00
Thomas Hallock
472f201088 fix(memory-quiz): synchronize card display across all players in multiplayer
Fix race condition where each player's browser independently timed card
progression, causing desync where different players saw different numbers
of cards during the memorization phase.

Solution: Only the room creator controls card timing by sending NEXT_CARD
moves. All other players react to state.currentCardIndex changes from the
server, ensuring all players see the same cards at the same time.

- Add isRoomCreator flag to MemoryQuizContext
- Detect room creator in RoomMemoryQuizProvider
- Modify DisplayPhase to only call nextCard() if room creator or local mode
- Add debug logging to track timing control

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:25:40 -05:00
semantic-release-bot
86b75cba5a chore(release): 3.15.0 [skip ci]
## [3.15.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.4...v3.15.0) (2025-10-15)

### Features

* **memory-quiz:** add multiplayer support with redesigned scoreboards ([1cf4469](1cf44696c2))
* **memory-quiz:** show player emojis on cards to indicate who found them ([05bd11a](05bd11a133))

### Bug Fixes

* **arcade:** add defensive checks and update test fixtures ([a93d981](a93d981d1a))
2025-10-15 14:18:13 +00:00
Thomas Hallock
a93d981d1a fix(arcade): add defensive checks and update test fixtures
- Add defensive state corruption checks to RoomMemoryPairsProvider
- Update test fixtures to include userId field in GameMove objects
- Add git restore to allowed bash commands in local settings

These changes improve robustness when game state becomes corrupted
(e.g., from game type mismatches between room sessions).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:16:54 -05:00
Thomas Hallock
05bd11a133 feat(memory-quiz): show player emojis on cards to indicate who found them
Replace checkmark indicators with player emojis on correctly guessed cards
in the results view. This provides visual feedback about which team found
each number in both cooperative and competitive modes.

- Display team player emojis vertically stacked for multi-player teams
- Use rounded rectangle background instead of circle for better fit
- Set maxHeight and overflow:hidden to prevent clipping issues
- Fallback to checkmark if no player data available

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:16:54 -05:00
Thomas Hallock
1cf44696c2 feat(memory-quiz): add multiplayer support with redesigned scoreboards
- Add multiplayer state tracking (playerMetadata, playerScores, activePlayers)
- Add cooperative and competitive play modes
- Preserve multiplayer state through server-side validation
- Redesign scoreboard layout to stack players vertically with larger stats
- Add live scoreboard during gameplay (competitive mode)
- Add final leaderboard on results screen for both modes
- Track scores by userId to properly handle multi-player teams

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 09:16:54 -05:00
semantic-release-bot
297927401c chore(release): 3.14.4 [skip ci]
## [3.14.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.3...v3.14.4) (2025-10-15)

### Bug Fixes

* **memory-quiz:** prevent input lag during rapid typing in room mode ([b45139b](b45139b588))
2025-10-15 00:31:10 +00:00
Thomas Hallock
b45139b588 fix(memory-quiz): prevent input lag during rapid typing in room mode
When typing rapidly in room mode, users had to type each digit
8+ times before it registered. This was caused by reading stale
state.currentInput values during rapid keypresses before React
could re-render with the optimistically updated state.

Solution: Use a ref to track the current input value and update
it immediately when keys are pressed, before waiting for the
network round-trip and React re-render.

Changes:
- Add currentInputRef to track input value immediately
- Update ref in useEffect to stay in sync with state
- Use ref instead of state.currentInput in keyboard handlers
- Clear ref immediately when accepting/rejecting numbers

This fixes the async network validation issue where local state
updates were too slow for rapid user input.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 19:30:12 -05:00
semantic-release-bot
a57ebdf142 chore(release): 3.14.3 [skip ci]
## [3.14.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.2...v3.14.3) (2025-10-15)

### Bug Fixes

* **arcade:** delete old session when room game changes ([98a3a25](98a3a2573d))
2025-10-15 00:17:53 +00:00
Thomas Hallock
98a3a2573d fix(arcade): delete old session when room game changes
When changing a room's game via the settings API, the old arcade
session was persisting with the previous game's state. This caused
users to still see the old game after selecting a new one.

Changes:
- Delete existing arcade session when gameName is updated in room settings
- Add debug logging to room page game selection handler
- Ensure fresh session is created with new game settings

This fixes the issue where clicking Memory Lightning would not
properly switch the game from Battle Arena.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 19:17:02 -05:00
semantic-release-bot
0fd680396c chore(release): 3.14.2 [skip ci]
## [3.14.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.1...v3.14.2) (2025-10-15)

### Bug Fixes

* **room:** update GAME_TYPE_TO_NAME mapping for memory-quiz ([4afa171](4afa171af2))
2025-10-15 00:06:54 +00:00
Thomas Hallock
4afa171af2 fix(room): update GAME_TYPE_TO_NAME mapping for memory-quiz
The GAMES_CONFIG was changed from 'memory-lightning' to 'memory-quiz'
but the GAME_TYPE_TO_NAME mapping in room/page.tsx still used the old key.

This caused the handleGameSelect function to fail silently when users
clicked on Memory Lightning in the "Change Game" screen, as it couldn't
find the mapping for 'memory-quiz'.

Also added debug logging to GameCard component to help diagnose issues.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 19:06:00 -05:00
semantic-release-bot
f37733bff6 chore(release): 3.14.1 [skip ci]
## [3.14.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.14.0...v3.14.1) (2025-10-14)

### Bug Fixes

* resolve Memory Quiz room-based multiplayer validation issues ([2ffeade](2ffeade437))
2025-10-14 23:29:00 +00:00
Thomas Hallock
2ffeade437 fix: resolve Memory Quiz room-based multiplayer validation issues
Root Cause:
- GAMES_CONFIG used 'memory-lightning' as key but validator was registered as 'memory-quiz'
- When rooms were created with gameName 'memory-lightning', getValidator() couldn't find the validator
- This caused all move validations to fail, breaking configuration changes and guess validation

Key Changes:
1. Fixed game identifier mismatch:
   - Changed GAMES_CONFIG key from 'memory-lightning' to 'memory-quiz'
   - Updated games/page.tsx to use 'memory-quiz' for routing

2. Completed Memory Quiz room-based multiplayer implementation:
   - Added MemoryQuizGameValidator with all 9 move types (START_QUIZ, NEXT_CARD, SHOW_INPUT_PHASE, ACCEPT_NUMBER, REJECT_NUMBER, SET_INPUT, SHOW_RESULTS, RESET_QUIZ, SET_CONFIG)
   - Created RoomMemoryQuizProvider for network-synchronized gameplay
   - Implemented optimistic client-side updates with server validation
   - Added proper serialization handling (send numbers instead of React components)
   - Split memory-quiz/page.tsx into modular components (SetupPhase, DisplayPhase, InputPhase, ResultsPhase)

3. Updated socket-server:
   - Fixed to use getValidator() instead of hardcoded matchingGameValidator
   - Added game-specific initial state handling for both 'matching' and 'memory-quiz'

4. Fixed test failures from arcade_sessions schema changes:
   - Updated arcade-session-validation.e2e.test.ts to create rooms before sessions (roomId is now primary key)
   - Added missing playerMetadata and playerHovers fields to arcade-session-integration.test.ts
   - Skipped obsolete test in orphaned-session-cleanup.test.ts (roomId can't be null as it's the primary key)

5. Code quality fixes:
   - Removed unused type imports from room-moderation.ts
   - Changed to optional chain in MemoryQuizGameValidator.ts
   - Removed unnecessary fragment in MemoryQuizGame.tsx

Testing:
- All modified tests updated to match new schema requirements
- TypeScript errors resolved (excluding pre-existing @soroban/abacus-react issues)
- Lint passes with 0 errors and 0 warnings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 18:28:01 -05:00
semantic-release-bot
d8b5201af9 chore(release): 3.14.0 [skip ci]
## [3.14.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.7...v3.14.0) (2025-10-14)

### Features

* **arcade:** add Change Game functionality for room hosts ([ee39241](ee39241e3c))
* **arcade:** add game selection screen with navigation to room page ([4124f1c](4124f1cc08))

### Bug Fixes

* **player-config:** correct label positioning in player settings dialog ([554cc40](554cc4063b))

### Code Refactoring

* implement in-room game selection UI ([f07b96d](f07b96d26e))
* make game_name nullable to support in-room game selection ([a9a6cef](a9a6cefafc))
* **nav:** rename emphasizeGameContext to emphasizePlayerSelection ([6bb7016](6bb7016eea))
2025-10-14 17:31:58 +00:00
Thomas Hallock
554cc4063b fix(player-config): correct label positioning in player settings dialog
Reorganizes layout so labels appear under their corresponding elements:
- Character count under name input
- "Random name" under dice button

Previously labels were misaligned and confusing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 12:30:45 -05:00
Thomas Hallock
6bb7016eea refactor(nav): rename emphasizeGameContext to emphasizePlayerSelection
Improves clarity by renaming the prop to better describe its purpose:
highlighting the player selection/roster UI in the navigation bar.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 12:30:38 -05:00
Thomas Hallock
4124f1cc08 feat(arcade): add game selection screen with navigation to room page
- Wraps game selection in PageWithNav for consistent navigation
- Adds game type mapping (GameType keys to internal game names)
- Enables player selection mode on game selection screen
- Adds navigation to "unsupported game" screen
- Fixes 400 error when selecting games like "Matching Pairs Battle"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 12:30:30 -05:00
Thomas Hallock
ee39241e3c feat(arcade): add Change Game functionality for room hosts
Allows room hosts to return to game selection screen by clearing the
room's game selection. Adds useClearRoomGame hook and "Change Game"
menu item in room dropdown (only visible when a game is selected).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 12:30:22 -05:00
Thomas Hallock
f07b96d26e refactor: implement in-room game selection UI
Phase 2: UI and workflow updates

- Update room settings API to support setting game via PATCH
- Add useSetRoomGame hook for client-side game selection
- Update /arcade/room page to show game selection when no game set
- Create beautiful game selection UI with gradient cards
- Update AddPlayerButton to create rooms without games
- Navigate to /arcade/room after creating or joining rooms
- Remove dependency on local-only play - all games now room-based

Workflow:
1. User clicks "Create Room" from (+) menu
2. Room is created without a game (gameName = null)
3. User is navigated to /arcade/room
4. Game selection screen is shown
5. User clicks a game
6. Room game is set via API
7. Game loads - URL never changes, it's always /arcade/room

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 11:33:39 -05:00
Thomas Hallock
a9a6cefafc refactor: make game_name nullable to support in-room game selection
Phase 1: Database and API updates

- Create migration 0010 to make game_name and game_config nullable
- Update arcade_rooms schema to support rooms without games
- Update RoomData interface to make gameName optional
- Update CreateRoomParams to make gameName optional
- Update room creation API to allow null gameName
- Update all room data parsing to handle null gameName

This allows rooms to be created without a game selected, enabling
users to choose a game inside the room itself. The URL remains
/arcade/room regardless of selection, setup, or gameplay state.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 11:30:27 -05:00
Thomas Hallock
710e93c997 revert(nav): restore original room creation/join behavior
Reverts navigation changes that broke lifted state popover behavior.

Original behavior (now restored):
- Create room: Keep popover open, switch to invite tab to share code
- Join room: Close popover, stay on current page

The navigation changes caused the popover to close immediately,
breaking the lifted state pattern that was intentionally designed
to keep the popover open for sharing room codes after creation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 11:11:03 -05:00
semantic-release-bot
b419e5e3ad chore(release): 3.13.7 [skip ci]
## [3.13.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.6...v3.13.7) (2025-10-14)

### Bug Fixes

* **toast:** scope animations to prevent affecting other UI elements ([245ed8a](245ed8a625))
2025-10-14 16:02:24 +00:00
Thomas Hallock
245ed8a625 fix(toast): scope animations to prevent affecting other UI elements
The toast CSS animations were using overly broad selectors like
[data-state='open'] which affected ANY element with data-state
attributes, causing nav items and other components to trigger the
toast slide-in/slide-out animations on hover.

Fixed by:
- Renaming animations: slideIn → toastSlideIn, etc.
- Scoping selectors: [data-radix-toast-viewport] [data-state='open']
- Now only toast elements within the viewport are animated

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 11:01:28 -05:00
semantic-release-bot
2b68ddc732 chore(release): 3.13.6 [skip ci]
## [3.13.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.5...v3.13.6) (2025-10-14)

### Bug Fixes

* **nav:** navigate to /arcade/room (not /arcade/rooms/{id}) ([1c55f36](1c55f3630c))
2025-10-14 15:40:44 +00:00
Thomas Hallock
1c55f3630c fix(nav): navigate to /arcade/room (not /arcade/rooms/{id})
Rooms are modal and use a single route /arcade/room that fetches
the user's current room. Fixed navigation for both:
- Creating a new room
- Joining an existing room

Both now navigate to /arcade/room instead of /arcade/rooms/{id}

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 10:39:50 -05:00
semantic-release-bot
1e34d57ad6 chore(release): 3.13.5 [skip ci]
## [3.13.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.4...v3.13.5) (2025-10-14)

### Bug Fixes

* **nav:** navigate to room after creation from (+) menu ([21e6e33](21e6e33173))

### Documentation

* add production deployment guide ([6d16436](6d16436133))
2025-10-14 15:29:11 +00:00
Thomas Hallock
21e6e33173 fix(nav): navigate to room after creation from (+) menu
When creating a room from the /arcade page using the (+) button:
- Add room to recent rooms list
- Close the popover
- Navigate to the room page immediately

This fixes the UX issue where users would create a room but
remain on the /arcade page without any clear indication of
how to access their new room.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 10:28:14 -05:00
Thomas Hallock
6d16436133 docs: add production deployment guide
Add comprehensive deployment documentation including:
- Production server infrastructure details
- Docker configuration and paths
- Database management and migration procedures
- CI/CD pipeline explanation
- Manual deployment procedures
- Troubleshooting guide

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 10:26:30 -05:00
semantic-release-bot
6b489238c8 chore(release): 3.13.4 [skip ci]
## [3.13.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.3...v3.13.4) (2025-10-14)

### Bug Fixes

* **api:** include members and memberPlayers in room creation response ([8320d9e](8320d9e730))
2025-10-14 15:14:53 +00:00
Thomas Hallock
8320d9e730 fix(api): include members and memberPlayers in room creation response
The client expects the POST /api/arcade/rooms response to include
members and memberPlayers fields, but the API was only returning
room and joinUrl. This caused room creation to fail on the client.

Fixes the "failed to create room" error on production.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 10:13:57 -05:00
semantic-release-bot
a4251e660d chore(release): 3.13.3 [skip ci]
## [3.13.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.2...v3.13.3) (2025-10-14)

### Bug Fixes

* **migrations:** add migration 0009 for display_password column ([040d749](040d7495a0))

### Code Refactoring

* replace browser alert() calls with toast notifications ([87ef356](87ef35682e))
2025-10-14 14:57:31 +00:00
Thomas Hallock
040d7495a0 fix(migrations): add migration 0009 for display_password column
- Create 0009_add_display_password.sql migration
- Add entry to drizzle journal
- This adds the display_password column that was missing in production

The plan is to nuke the production database and let all migrations
run from scratch on container restart.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 09:56:26 -05:00
Thomas Hallock
87ef35682e refactor: replace browser alert() calls with toast notifications
- Create ToastContext with useToast hook for app-wide toast management
- Add ToastProvider to ClientProviders for global toast access
- Replace all 13 alert() calls across arcade room pages and components
- Use consistent toast patterns: showError, showSuccess, showInfo
- Improve UX with dismissible, auto-timing toast notifications

Files updated:
- src/components/common/ToastContext.tsx (new)
- src/components/ClientProviders.tsx
- src/app/arcade-rooms/page.tsx
- src/app/arcade-rooms/[roomId]/page.tsx
- src/components/nav/ModerationNotifications.tsx
- src/components/nav/AddPlayerButton.tsx
- src/components/nav/PendingInvitations.tsx

Also removed invalid manually-created migration 0009 (will be regenerated properly)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 09:08:41 -05:00
semantic-release-bot
2fb6ead4f2 chore(release): 3.13.2 [skip ci]
## [3.13.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.1...v3.13.2) (2025-10-14)

### Bug Fixes

* **arcade:** only notify room creator of join requests ([bc571e3](bc571e3d0d))
2025-10-14 13:55:57 +00:00
Thomas Hallock
bc571e3d0d fix(arcade): only notify room creator of join requests
Fixes issue where ALL room members were seeing join request approval
toasts, but only the creator can approve them, leading to confusing
error messages when non-creators clicked approve/deny.

Changes:
- Join request notifications now sent only to room creator's user channel
- Changed from broadcasting to entire room to targeted user notification
- Uses `user:${room.createdBy}` channel instead of `room:${roomId}`

Non-host users will no longer see approval toasts they cannot act on.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:55:10 -05:00
semantic-release-bot
eed7c9b938 chore(release): 3.13.1 [skip ci]
## [3.13.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.0...v3.13.1) (2025-10-14)

### Bug Fixes

* **arcade:** allow room creator to rejoin restricted/approval rooms ([654ba19](654ba19ccc))
2025-10-14 13:54:31 +00:00
Thomas Hallock
654ba19ccc fix(arcade): allow room creator to rejoin restricted/approval rooms
Fixes catch-22 where room creator who leaves their own approval-only
or restricted room cannot rejoin because:
- Approval-only: They need approval but can't approve themselves
- Restricted: They need an invitation but can't invite themselves

Changes:
- Room creator now bypasses invitation check for restricted rooms
- Room creator now bypasses approval check for approval-only rooms
- Other users still require proper authorization

This ensures hosts can always access their own rooms regardless of
access mode restrictions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:53:39 -05:00
semantic-release-bot
f5469cda0c chore(release): 3.13.0 [skip ci]
## [3.13.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.12.0...v3.13.0) (2025-10-14)

### Features

* **moderation:** add inline feedback and persistent password display ([86e3d41](86e3d41996))
2025-10-14 13:53:19 +00:00
Thomas Hallock
86e3d41996 feat(moderation): add inline feedback and persistent password display
- Add success/error message UI component to ModerationPanel
- Replace all browser alert() calls with inline React-based feedback
- Add displayPassword field to arcade_rooms schema for plain text storage
- Create migration to add display_password column
- Update settings PATCH route to store both hashed and display passwords
- Update room GET route to return displayPassword only to room creator
- Update ModerationPanel to populate password field when loading settings
- Fix room-manager test to include displayPassword field

Password field now persists and displays correctly when reloading the page
for room owners in password-protected rooms.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:52:19 -05:00
semantic-release-bot
cb11bec975 chore(release): 3.12.0 [skip ci]
## [3.12.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.11.1...v3.12.0) (2025-10-14)

### Features

* **moderation:** improve password input with copy button ([2580e47](2580e474d0))
2025-10-14 13:40:37 +00:00
Thomas Hallock
2580e474d0 feat(moderation): improve password input with copy button
Enhances the password-protected room settings UX:

Changes:
1. Password input now stays visible and editable
   - Plain text input (not hidden) for easy viewing
   - Focus state with orange border
   - Clear placeholder text

2. Copy button next to password input
   - 📋 Copy icon with text
   - Visual feedback: changes to "✓ Copied!" for 2 seconds
   - Disabled state when no password entered
   - Green success color after copying

3. Better labeling and hints
   - "Room Password" label above input
   - Helper text: "Share this password with guests to allow them to join"
   - More descriptive placeholder

Note: Passwords are hashed in the database for security, so existing
passwords cannot be retrieved. This UI is for setting/changing passwords
and easily copying them to share with guests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:39:42 -05:00
semantic-release-bot
55e0be8e42 chore(release): 3.11.1 [skip ci]
## [3.11.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.11.0...v3.11.1) (2025-10-14)

### Bug Fixes

* **moderation:** improve access mode settings UX ([dd9e657](dd9e657db8))
2025-10-14 13:36:29 +00:00
Thomas Hallock
dd9e657db8 fix(moderation): improve access mode settings UX
Enhances the room moderation settings UX to prevent accidental closure
with unsaved changes:

Changes:
1. "Update Access Mode" button now only appears when there are changes
   - Tracks original access mode on load
   - Compares current selection to detect changes
   - Button hidden when no changes made

2. "Close" button disabled when there are unsaved access mode changes
   - Prevents accidentally losing changes
   - Visual feedback: dimmed appearance, orange border
   - Hover state brightens orange border to draw attention

3. Tooltip on disabled "Close" button
   - Shows "Please update access mode settings before closing"
   - Helps users understand why close is disabled

This prevents the frustrating UX issue where users want to close but
are forced to update settings they didn't intend to change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:35:36 -05:00
semantic-release-bot
51d9a37f9b chore(release): 3.11.0 [skip ci]
## [3.11.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.10.0...v3.11.0) (2025-10-14)

### Features

* add name generator button and abacus emoji ([07212e4](07212e4df0))

### Code Refactoring

* make player names abacus and arithmetic themed ([97daad9](97daad9abb))
2025-10-14 13:31:03 +00:00
Thomas Hallock
07212e4df0 feat: add name generator button and abacus emoji
Adds two enhancements to player customization:

1. Name generator button in PlayerConfigDialog
   - Dice emoji (🎲) button next to name input
   - Generates new themed names on click
   - Excludes current player from collision check
   - Maintains auto-save behavior

2. Abacus emoji option
   - Added 🧮 (abacus) as first emoji choice
   - Perfect thematic fit for the application

Now players can easily try different generated names without leaving
the settings dialog.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:30:14 -05:00
Thomas Hallock
97daad9abb refactor: make player names abacus and arithmetic themed
Replaces generic fantasy/gaming words with abacus and math-themed vocabulary.

Examples of new names:
- "Ancient Abacist", "Sliding Counter", "Soroban Master"
- "Calculating Mathematician", "Rapid Solver", "Precise Adder"
- "Bamboo Scholar", "Golden Merchant", "Mental Genius"

Changes:
- 25 abacus-specific adjectives (Ancient, Wooden, Soroban, etc.)
- 25 arithmetic adjectives (Adding, Calculating, Prime, etc.)
- 25 abacus-specific nouns (Counter, Abacist, Bead, Rod, etc.)
- 25 arithmetic nouns (Mathematician, Solver, Adder, etc.)
- Still maintains 2,500 unique combinations
- All tests pass with new vocabulary

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:29:13 -05:00
semantic-release-bot
225104c3a7 chore(release): 3.10.0 [skip ci]
## [3.10.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.2...v3.10.0) (2025-10-14)

### Features

* add fun automatic player naming system ([249257c](249257c6c7))
2025-10-14 13:26:03 +00:00
Thomas Hallock
249257c6c7 feat: add fun automatic player naming system
Implements automatic generation of creative player names combining
adjectives with nouns/roles (e.g., "Swift Ninja", "Cosmic Wizard").

Changes:
- Created playerNames utility with 50 adjectives and 50 nouns
- Generates unique names with collision detection
- Applied to default player creation and addPlayer function
- Replaces generic "Player 1", "Player 2" with fun names
- Manual override still available via PlayerConfigDialog
- Added comprehensive unit tests (10 passing tests)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:25:05 -05:00
semantic-release-bot
b37e29e53e chore(release): 3.9.2 [skip ci]
## [3.9.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.1...v3.9.2) (2025-10-14)

### Bug Fixes

* remove duplicate ModerationNotifications causing double toasts ([c6886a0](c6886a0e59))
2025-10-14 13:19:07 +00:00
Thomas Hallock
c6886a0e59 fix: remove duplicate ModerationNotifications causing double toasts
**Root Cause:**
- ModerationNotifications was rendered in BOTH /arcade/room/page.tsx AND PageWithNav
- Both had separate useRoomData hooks, creating two socket listeners
- When join-request-submitted event fired, BOTH instances showed a toast
- Clicking "Approve" only closed one toast, leaving the other visible

**Fix:**
- Removed ModerationNotifications from room page entirely
- PageWithNav (inside MemoryPairsGame) already handles all moderation events
- Now only ONE instance listens and renders, so approvals properly dismiss

**Files Changed:**
- Removed ModerationNotifications import from room page
- Removed all 4 instances of <ModerationNotifications /> from room page
- Removed moderationEvent and clearModerationEvent from useRoomData destructuring
- Added comment explaining PageWithNav handles notifications

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:18:12 -05:00
semantic-release-bot
cb2fec1da5 chore(release): 3.9.1 [skip ci]
## [3.9.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.0...v3.9.1) (2025-10-14)

### Bug Fixes

* reset join request toast state when moderation event cleared ([6beb58a](6beb58a7b8))
2025-10-14 13:14:31 +00:00
Thomas Hallock
6beb58a7b8 fix: reset join request toast state when moderation event cleared
**Issue:**
Toast notification was persisting after successful approval because
showJoinRequestToast state was never reset to false.

**Fix:**
- Add else clause to useEffect that resets toast state when event cleared
- Reset both showJoinRequestToast and requestError when event changes type
- Ensures toast properly dismisses after approval/denial

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:13:40 -05:00
semantic-release-bot
544b06e290 chore(release): 3.9.0 [skip ci]
## [3.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.1...v3.9.0) (2025-10-14)

### Features

* prevent invitations to retired rooms ([a7c3c1f](a7c3c1f4cd))
2025-10-14 13:13:04 +00:00
Thomas Hallock
a7c3c1f4cd feat: prevent invitations to retired rooms
- Add room access mode check in invite POST endpoint
- Block invitation creation if room is retired (403 status)
- Clear error message: "Cannot send invitations to retired rooms"
- Check happens before host validation to catch retired rooms early

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:12:10 -05:00
semantic-release-bot
090d4dac2b chore(release): 3.8.1 [skip ci]
## [3.8.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.0...v3.8.1) (2025-10-14)

### Bug Fixes

* improve kicked modal message for retired room ejections ([f865ce1](f865ce16ec))
2025-10-14 13:10:33 +00:00
Thomas Hallock
f865ce16ec fix: improve kicked modal message for retired room ejections
**Socket handler update:**
- Capture reason field from kicked-from-room socket event

**Modal UI improvements:**
- Detect when reason contains "retired"
- Show 🏁 emoji instead of ⚠️ for retired rooms
- Title changes from "Kicked from Room" to "Room Retired"
- Message explains room owner retired the room and access is closed
- Clarify only room owner can access retired rooms

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:09:41 -05:00
semantic-release-bot
50f45ab08e chore(release): 3.8.0 [skip ci]
## [3.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.1...v3.8.0) (2025-10-14)

### Features

* implement proper retired room behavior with member expulsion ([a2d5368](a2d53680f2))
2025-10-14 13:08:48 +00:00
Thomas Hallock
a2d53680f2 feat: implement proper retired room behavior with member expulsion
**Join endpoint changes:**
- Only room creator can access retired rooms
- All other users blocked with 410 status and clear message

**Settings endpoint changes:**
- When room set to 'retired', all non-owner members immediately expelled
- Expelled members removed from database
- Each expulsion recorded in member history
- Socket notifications sent to all expelled members (kicked-from-room event)
- Owner notified of member expulsions via member-left event

**Member expulsion flow:**
- Similar pattern to ban ejection
- Expelled members see "kicked from room" modal with reason "Room has been retired"
- All expelled members logged in history with 'left' action

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:07:49 -05:00
semantic-release-bot
b9e7267f15 chore(release): 3.7.1 [skip ci]
## [3.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.0...v3.7.1) (2025-10-14)

### Bug Fixes

* improve join request approval error handling with actionable messages ([57bf846](57bf8460c8))
2025-10-14 13:03:49 +00:00
Thomas Hallock
57bf8460c8 fix: improve join request approval error handling with actionable messages
- Add requestError state to track approval/deny errors
- Toast dismisses only on successful approval/deny
- On error, toast remains visible and displays error message inline
- Parse API error response to show meaningful error messages
- User can retry approval/deny action after error
- Replace generic alert() with styled error message within toast

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:02:56 -05:00
semantic-release-bot
059a9fe750 chore(release): 3.7.0 [skip ci]
## [3.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.3...v3.7.0) (2025-10-14)

### Features

* add prominent join request approval notifications for room moderators ([036da6d](036da6de66))
2025-10-14 13:00:09 +00:00
Thomas Hallock
036da6de66 feat: add prominent join request approval notifications for room moderators
- Add 'join-request' type to ModerationEvent interface
- Add socket listener for 'join-request-submitted' event in useRoomData
- Update join request POST endpoint to broadcast socket event to room members
- Add prominent toast notification with inline approve/deny buttons in ModerationNotifications
- Toast appears immediately when host receives join request (not buried in settings)
- Approve/deny actions handled directly from toast with API calls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:59:10 -05:00
semantic-release-bot
556e5e4ca0 chore(release): 3.6.3 [skip ci]
## [3.6.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.2...v3.6.3) (2025-10-14)

### Bug Fixes

* update locked room terminology and allow existing members ([1ddf985](1ddf985938))
2025-10-14 12:50:45 +00:00
Thomas Hallock
1ddf985938 fix: update locked room terminology and allow existing members
Update locked room terminology and implementation:
- Change description from "No members" to "No new members"
- Allow existing members to continue using locked rooms
- Only block new members from joining locked rooms
- Update join API to check membership before rejecting

This clarifies that "locked" means no NEW members, but existing members
can continue to participate in the room.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:49:52 -05:00
semantic-release-bot
8c851462de chore(release): 3.6.2 [skip ci]
## [3.6.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.1...v3.6.2) (2025-10-14)

### Bug Fixes

* allow join with pending invitation for restricted rooms ([85b2cf9](85b2cf9816))
2025-10-14 12:48:24 +00:00
Thomas Hallock
85b2cf9816 fix: allow join with pending invitation for restricted rooms
Remove premature check that blocked access to restricted rooms. Now:
- Frontend no longer blocks restricted room access upfront
- Backend API checks for pending invitation
- Users with valid invitations can join successfully
- Users without invitations get appropriate error message

This fixes the issue where users with pending invitations couldn't join
restricted rooms via the join link.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:47:28 -05:00
semantic-release-bot
4c6eb01f1e chore(release): 3.6.1 [skip ci]
## [3.6.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.0...v3.6.1) (2025-10-14)

### Bug Fixes

* join user socket channel to receive approval notifications ([7d08fdd](7d08fdd906))

### Code Refactoring

* remove redundant polling from approval notifications ([0d4f400](0d4f400dca))
2025-10-14 12:44:53 +00:00
Thomas Hallock
7d08fdd906 fix: join user socket channel to receive approval notifications
The socket wasn't receiving join-request-approved events because it hadn't
joined the user-specific channel. Now:

- Fetch viewer ID from /api/viewer endpoint
- Emit 'join-user-channel' with userId on socket connect
- Socket joins `user:${userId}` room to receive moderation events
- Approval notifications now trigger automatic room join

This completes the real-time approval notification flow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:43:49 -05:00
Thomas Hallock
0d4f400dca refactor: remove redundant polling from approval notifications
Remove polling interval that checked every 5 seconds for approval status.
The socket.io listener provides real-time notifications, making polling
unnecessary and wasteful.

Now relies solely on socket.io for instant approval notifications, which:
- Reduces network traffic
- Simplifies code
- Provides faster response time

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:40:02 -05:00
semantic-release-bot
396b6c07c7 chore(release): 3.6.0 [skip ci]
## [3.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.5.0...v3.6.0) (2025-10-14)

### Features

* add socket listener and polling for approval notifications ([35b4a72](35b4a72c8b))
2025-10-14 12:38:33 +00:00
Thomas Hallock
35b4a72c8b feat: add socket listener and polling for approval notifications
When users request to join an approval-only room, they now receive real-time
notifications when their request is approved:

- Add socket.io-client listener for 'join-request-approved' events
- Implement polling fallback (every 5 seconds) to check approval status
- Automatically join room when approval is detected via socket or polling
- Apply to both share link page and JoinRoomModal

This completes the approval flow - users no longer need to reload the page
to see if their join request was approved.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:37:28 -05:00
semantic-release-bot
ba916e0f65 chore(release): 3.5.0 [skip ci]
## [3.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.4.0...v3.5.0) (2025-10-14)

### Features

* replace access mode dropdown with visual button grid ([e5d0672](e5d0672059))
2025-10-14 12:31:26 +00:00
Thomas Hallock
e5d0672059 feat: replace access mode dropdown with visual button grid
Updated the ModerationPanel Settings tab to use a visual button grid
for access mode selection, matching the CreateRoomModal UX.

Changes:
- Replaced <select> dropdown with 3x2 grid of buttons
- Each button shows emoji + label + description
- Visual feedback for selected state and hover
- Includes all 6 access modes: open, password, approval-only,
  restricted, locked, retired
- Maintains same functionality with improved UX

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:30:26 -05:00
semantic-release-bot
5b4c69693d chore(release): 3.4.0 [skip ci]
## [3.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.3.1...v3.4.0) (2025-10-14)

### Features

* add waiting state for approval requests in JoinRoomModal ([f9b0429](f9b0429a2e))
2025-10-14 12:29:42 +00:00
Thomas Hallock
f9b0429a2e feat: add waiting state for approval requests in JoinRoomModal
When users enter an approval-only room code in the JoinRoomModal, they now:
- See a prompt to send a join request
- After sending, see a "Waiting for Approval" screen
- Can close the modal and check back later

This matches the UX flow from the share link approval flow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:28:41 -05:00
semantic-release-bot
34998d6b27 chore(release): 3.3.1 [skip ci]
## [3.3.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.3.0...v3.3.1) (2025-10-14)

### Bug Fixes

* add POST handler for join requests API endpoint ([d3e5cdf](d3e5cdfc54))
2025-10-14 12:27:05 +00:00
Thomas Hallock
d3e5cdfc54 fix: add POST handler for join requests API endpoint
Previously the endpoint only had a GET handler, causing a 405 error
when users tried to request approval for approval-only rooms.

Now users can POST to create join requests with optional displayName.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:26:08 -05:00
semantic-release-bot
f949003870 chore(release): 3.3.0 [skip ci]
## [3.3.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.2.1...v3.3.0) (2025-10-14)

### Features

* implement approval request flow for share links ([4a6b3ca](4a6b3cabe5))
2025-10-14 12:20:01 +00:00
Thomas Hallock
4a6b3cabe5 feat: implement approval request flow for share links
When users click share links for approval-only rooms, they now:
- See a prompt to request approval from the room moderator
- Can send a join request with one click
- Get a waiting screen showing their request is pending

Room moderators now see:
- A prominent blue badge showing pending join request count
- Combined count of join requests + reports in the badge
- Badge turns blue when join requests exist (vs red for reports only)
- Detailed tooltip showing breakdown of pending items
- Real-time polling (30s intervals) for new join requests

Also includes improvements to room display names using emoji prefixes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:19:05 -05:00
semantic-release-bot
2cb6a512fe chore(release): 3.2.1 [skip ci]
## [3.2.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.2.0...v3.2.1) (2025-10-14)

### Bug Fixes

* allow password retry when joining via share link ([e469363](e469363699))
2025-10-14 12:13:25 +00:00
Thomas Hallock
e469363699 fix: allow password retry when joining via share link
- Password errors now stay in password prompt UI instead of redirecting to error page
- Error message clears when user starts typing new password
- Users can now retry incorrect passwords without losing the join flow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:12:26 -05:00
semantic-release-bot
b230cd7a1f chore(release): 3.2.0 [skip ci]
## [3.2.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.1.2...v3.2.0) (2025-10-14)

### Features

* improve room creation UX and add password support for share links ([dcbb507](dcbb5072d8))
2025-10-14 12:10:25 +00:00
Thomas Hallock
dcbb5072d8 feat: improve room creation UX and add password support for share links
- Update placeholder text in room creation forms to show auto-generated format
- Make room.name nullable in database schema (migration 0008)
- Add accessMode field to RoomData interface
- Implement password prompt UI for password-protected rooms via share links
- Add password support to room browser join flow
- Remove autoFocus attribute for accessibility compliance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:09:22 -05:00
semantic-release-bot
f9ec5d32c5 chore(release): 3.1.2 [skip ci]
## [3.1.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.1.1...v3.1.2) (2025-10-14)

### Bug Fixes

* replace last remaining isLoading with isPending in CreateRoomModal ([85d13cc](85d13cc552))
2025-10-14 01:14:40 +00:00
Thomas Hallock
85d13cc552 fix: replace last remaining isLoading with isPending in CreateRoomModal
Missed one instance in the select dropdown cursor style.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 20:13:44 -05:00
semantic-release-bot
ef8a29e8ef chore(release): 3.1.1 [skip ci]
## [3.1.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.1.0...v3.1.1) (2025-10-14)

### Bug Fixes

* use useCreateRoom hook instead of nonexistent createRoom from useRoomData ([f7d63b3](f7d63b30ac))
2025-10-14 00:54:35 +00:00
Thomas Hallock
f7d63b30ac fix: use useCreateRoom hook instead of nonexistent createRoom from useRoomData
The CreateRoomModal was trying to destructure createRoom from useRoomData(),
but that hook doesn't export it. Changed to use the proper useCreateRoom() hook.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 19:53:39 -05:00
semantic-release-bot
441c04f9e6 chore(release): 3.1.0 [skip ci]
## [3.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.0.0...v3.1.0) (2025-10-14)

### Features

* add room access modes and ownership transfer ([6ff21c4](6ff21c4f1d))

### Bug Fixes

* replace isLocked with accessMode and add bcryptjs ([a74b96b](a74b96bb6f))
2025-10-14 00:45:23 +00:00
Thomas Hallock
a74b96bb6f fix: replace isLocked with accessMode and add bcryptjs
- Updated all test files to use accessMode instead of isLocked field
- Fixed room-manager tests to reflect new access control schema
- Installed bcryptjs dependency for password hashing
- All access mode TypeScript compilation errors resolved

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 19:44:14 -05:00
Thomas Hallock
6ff21c4f1d feat: add room access modes and ownership transfer
Add comprehensive access control system for arcade rooms with 6 modes:
- open: Anyone can join (default)
- locked: Only current members allowed
- retired: Room no longer functions
- password: Requires password to join
- restricted: Only users with pending invitations can join
- approval-only: Requires host approval via join request system

Database Changes:
- Add accessMode field to arcade_rooms (replaces isLocked boolean with enum)
- Add password field to arcade_rooms (hashed with bcrypt)
- Create room_join_requests table for approval-only mode

New API Endpoints:
- PATCH /api/arcade/rooms/:roomId/settings - Update room access mode and password (host only)
- POST /api/arcade/rooms/:roomId/transfer-ownership - Transfer ownership to another member (host only)
- POST /api/arcade/rooms/:roomId/join-request - Request to join approval-only room
- GET /api/arcade/rooms/:roomId/join-requests - Get pending join requests (host only)
- POST /api/arcade/rooms/:roomId/join-requests/:requestId/approve - Approve join request (host only)
- POST /api/arcade/rooms/:roomId/join-requests/:requestId/deny - Deny join request (host only)

Updated Endpoints:
- POST /api/arcade/rooms/:roomId/join - Now validates access modes before allowing join:
  * locked: Rejects all joins
  * retired: Rejects all joins (410 Gone)
  * password: Requires password validation
  * restricted: Requires valid pending invitation
  * approval-only: Requires approved join request
  * open: Allows anyone (existing behavior)

Libraries:
- Add room-join-requests.ts for managing join request lifecycle
- Ownership transfer updates room.createdBy and member.isCreator flags
- Socket.io events for join request notifications and ownership transfers

Migration: 0007_access_modes.sql

Next Steps (UI not included in this commit):
- RoomSettingsModal for configuring access mode and password
- Join request approval UI in ModerationPanel
- Ownership transfer UI in ModerationPanel
- Password input in join flow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 19:19:49 -05:00
semantic-release-bot
21009f8a34 chore(release): 3.0.0 [skip ci]
## [3.0.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v3.0.0) (2025-10-13)

### ⚠ BREAKING CHANGES

* Added DELETE /api/arcade/rooms/:roomId/invite endpoint for declining invitations

Authorization Error Handling:
- ModerationPanel: Parse and display API error messages (kick, ban, unban, invite, data loading)
- PendingInvitations: Parse and display API error messages (decline, fetch)
- All moderation actions now show specific auth errors like "Only the host can kick users"

New Endpoint:
- DELETE /api/arcade/rooms/:roomId/invite: Allow users to decline their pending invitations
  * Validates invitation exists and is pending
  * Only invited user can decline their own invitation
  * Returns proper error messages for auth failures

Bug Fix:
- Fixed invitations/pending/route.ts ban check query (removed reference to non-existent unbannedAt field)
- Ban records are deleted when unbanned, so any existing ban is active

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

### Features

* add API routes for moderation and invitations ([79a8518](79a8518557))
* add backend library functions for room moderation ([84f3c4b](84f3c4bcfd))
* add centralized player ownership utilities ([1019a48](1019a487f8))
* add common UI components ([cd3115a](cd3115aa6d))
* add database schema for room moderation and invitations ([97d1604](97d16041df))
* add invitation system UI components ([fd3a2d1](fd3a2d1f76))
* add moderation panel with unban & invite feature ([a2d0169](a2d0169f80))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* add real-time socket updates for moderation events ([86ceba3](86ceba3df3))
* add room creation and join flow UI ([7f95032](7f95032253))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* integrate moderation system into arcade pages ([087652f](087652f9e7))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add prominent turn indicator arrow badge ([f574558](f574558dff))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** add turn indicators to network players ([623314b](623314bd38))
* **nav:** add turn label text under current player avatars ([52a66d5](52a66d5f68))
* **nav:** center game context with hamburger menu for utilities ([a35a7d5](a35a7d56df))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* improve authorization error handling and add missing decline invitation endpoint ([97669ad](97669ad084))
* **matching:** apply turn indicators to arcade version too ([e6f96a8](e6f96a8b99))
* **matching:** make MemoryGrid generic to support different card types ([dcda826](dcda826b9a))
* **matching:** only apply turn indicator when game is active ([cb4c061](cb4c061d11))
* **matching:** replace mismatch banner with card shake animation ([804096f](804096fd8a))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **moderation:** don't show pending invitation for users already in room ([fae5920](fae5920e2f))
* move invitations into nav and filter out current/banned rooms ([cfaf82b](cfaf82b2cc))
* **nav:** add delay to hamburger menu hover to prevent premature closing ([95cd72e](95cd72e9bf))
* **nav:** add z-index to turn labels to prevent avatar overlap ([7c294da](7c294dafff))
* **nav:** close hamburger menu when nested dropdown closes and mouse not hovering ([7d65212](7d652126d0))
* **nav:** enable tooltips for local players during gameplay ([5499700](54997007b8))
* **nav:** improve readability of turn label text ([bbd1da0](bbd1da02b5))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** prevent hamburger menu from closing when toggling Style dropdown ([a898fbc](a898fbc187))
* **nav:** prevent style dropdown from closing hamburger menu ([560a052](560a05266e))
* **nav:** prevent turn label text from being obscured ([c4b00dd](c4b00dd679))
* **nav:** properly prevent nested style dropdown from closing hamburger menu ([c5b6a82](c5b6a82ca4))
* **nav:** remove animation/enlargement from network player turn indicator ([53079ed](53079ede13))
* **nav:** remove blue gradient background from network players ([2881aff](2881affecc))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* **nav:** remove play arrow badge from turn indicators ([80cfc10](80cfc10f78))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* **matching:** integrate game type into nav, remove redundant header ([389df29](389df29dc3))
* **matching:** unify duplicate MemoryGrid components into shared implementation ([5f7067a](5f7067a106))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* remove old arcade guard system ([2e6469b](2e6469bed4))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* update core UI components ([55ccf09](55ccf097d9))
* update game pages for room-based multiplayer ([54846bd](54846bdc3f))
* update nav components for room-based system ([31ac958](31ac958d33))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))
* **zIndex:** create centralized z-index layering system ([a204c83](a204c83afc))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
* add tests for room and moderation features ([063a8e5](063a8e52fe))
2025-10-13 19:57:45 +00:00
Thomas Hallock
97669ad084 fix: improve authorization error handling and add missing decline invitation endpoint
BREAKING CHANGE: Added DELETE /api/arcade/rooms/:roomId/invite endpoint for declining invitations

Authorization Error Handling:
- ModerationPanel: Parse and display API error messages (kick, ban, unban, invite, data loading)
- PendingInvitations: Parse and display API error messages (decline, fetch)
- All moderation actions now show specific auth errors like "Only the host can kick users"

New Endpoint:
- DELETE /api/arcade/rooms/:roomId/invite: Allow users to decline their pending invitations
  * Validates invitation exists and is pending
  * Only invited user can decline their own invitation
  * Returns proper error messages for auth failures

Bug Fix:
- Fixed invitations/pending/route.ts ban check query (removed reference to non-existent unbannedAt field)
- Ban records are deleted when unbanned, so any existing ban is active

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 14:56:33 -05:00
semantic-release-bot
233bd342a8 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-13)

### Features

* add API routes for moderation and invitations ([79a8518](79a8518557))
* add backend library functions for room moderation ([84f3c4b](84f3c4bcfd))
* add centralized player ownership utilities ([1019a48](1019a487f8))
* add common UI components ([cd3115a](cd3115aa6d))
* add database schema for room moderation and invitations ([97d1604](97d16041df))
* add invitation system UI components ([fd3a2d1](fd3a2d1f76))
* add moderation panel with unban & invite feature ([a2d0169](a2d0169f80))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* add real-time socket updates for moderation events ([86ceba3](86ceba3df3))
* add room creation and join flow UI ([7f95032](7f95032253))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* integrate moderation system into arcade pages ([087652f](087652f9e7))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add prominent turn indicator arrow badge ([f574558](f574558dff))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** add turn indicators to network players ([623314b](623314bd38))
* **nav:** add turn label text under current player avatars ([52a66d5](52a66d5f68))
* **nav:** center game context with hamburger menu for utilities ([a35a7d5](a35a7d56df))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** apply turn indicators to arcade version too ([e6f96a8](e6f96a8b99))
* **matching:** make MemoryGrid generic to support different card types ([dcda826](dcda826b9a))
* **matching:** only apply turn indicator when game is active ([cb4c061](cb4c061d11))
* **matching:** replace mismatch banner with card shake animation ([804096f](804096fd8a))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **moderation:** don't show pending invitation for users already in room ([fae5920](fae5920e2f))
* move invitations into nav and filter out current/banned rooms ([cfaf82b](cfaf82b2cc))
* **nav:** add delay to hamburger menu hover to prevent premature closing ([95cd72e](95cd72e9bf))
* **nav:** add z-index to turn labels to prevent avatar overlap ([7c294da](7c294dafff))
* **nav:** close hamburger menu when nested dropdown closes and mouse not hovering ([7d65212](7d652126d0))
* **nav:** enable tooltips for local players during gameplay ([5499700](54997007b8))
* **nav:** improve readability of turn label text ([bbd1da0](bbd1da02b5))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** prevent hamburger menu from closing when toggling Style dropdown ([a898fbc](a898fbc187))
* **nav:** prevent style dropdown from closing hamburger menu ([560a052](560a05266e))
* **nav:** prevent turn label text from being obscured ([c4b00dd](c4b00dd679))
* **nav:** properly prevent nested style dropdown from closing hamburger menu ([c5b6a82](c5b6a82ca4))
* **nav:** remove animation/enlargement from network player turn indicator ([53079ed](53079ede13))
* **nav:** remove blue gradient background from network players ([2881aff](2881affecc))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* **nav:** remove play arrow badge from turn indicators ([80cfc10](80cfc10f78))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* **matching:** integrate game type into nav, remove redundant header ([389df29](389df29dc3))
* **matching:** unify duplicate MemoryGrid components into shared implementation ([5f7067a](5f7067a106))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* remove old arcade guard system ([2e6469b](2e6469bed4))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* update core UI components ([55ccf09](55ccf097d9))
* update game pages for room-based multiplayer ([54846bd](54846bdc3f))
* update nav components for room-based system ([31ac958](31ac958d33))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))
* **zIndex:** create centralized z-index layering system ([a204c83](a204c83afc))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
* add tests for room and moderation features ([063a8e5](063a8e52fe))
2025-10-13 18:06:59 +00:00
Thomas Hallock
cfaf82b2cc fix: move invitations into nav and filter out current/banned rooms
Improvements to invitation banner UX:
- Move PendingInvitations from arcade page into GameContextNav
- Now appears as part of the mini app nav (not underneath it)
- Filter out invitations for the room user is currently in
- Filter out invitations for rooms where user is banned
- Backend filters banned room invitations automatically

This resolves awkward situations where users see invitations for:
1. The room they're already in
2. Rooms they were kicked from or banned from

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 13:05:44 -05:00
semantic-release-bot
3e0b254df9 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-13)

### Features

* add API routes for moderation and invitations ([79a8518](79a8518557))
* add backend library functions for room moderation ([84f3c4b](84f3c4bcfd))
* add centralized player ownership utilities ([1019a48](1019a487f8))
* add common UI components ([cd3115a](cd3115aa6d))
* add database schema for room moderation and invitations ([97d1604](97d16041df))
* add invitation system UI components ([fd3a2d1](fd3a2d1f76))
* add moderation panel with unban & invite feature ([a2d0169](a2d0169f80))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* add real-time socket updates for moderation events ([86ceba3](86ceba3df3))
* add room creation and join flow UI ([7f95032](7f95032253))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* integrate moderation system into arcade pages ([087652f](087652f9e7))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add prominent turn indicator arrow badge ([f574558](f574558dff))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** add turn indicators to network players ([623314b](623314bd38))
* **nav:** add turn label text under current player avatars ([52a66d5](52a66d5f68))
* **nav:** center game context with hamburger menu for utilities ([a35a7d5](a35a7d56df))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** apply turn indicators to arcade version too ([e6f96a8](e6f96a8b99))
* **matching:** make MemoryGrid generic to support different card types ([dcda826](dcda826b9a))
* **matching:** only apply turn indicator when game is active ([cb4c061](cb4c061d11))
* **matching:** replace mismatch banner with card shake animation ([804096f](804096fd8a))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **moderation:** don't show pending invitation for users already in room ([fae5920](fae5920e2f))
* **nav:** add delay to hamburger menu hover to prevent premature closing ([95cd72e](95cd72e9bf))
* **nav:** add z-index to turn labels to prevent avatar overlap ([7c294da](7c294dafff))
* **nav:** close hamburger menu when nested dropdown closes and mouse not hovering ([7d65212](7d652126d0))
* **nav:** enable tooltips for local players during gameplay ([5499700](54997007b8))
* **nav:** improve readability of turn label text ([bbd1da0](bbd1da02b5))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** prevent hamburger menu from closing when toggling Style dropdown ([a898fbc](a898fbc187))
* **nav:** prevent style dropdown from closing hamburger menu ([560a052](560a05266e))
* **nav:** prevent turn label text from being obscured ([c4b00dd](c4b00dd679))
* **nav:** properly prevent nested style dropdown from closing hamburger menu ([c5b6a82](c5b6a82ca4))
* **nav:** remove animation/enlargement from network player turn indicator ([53079ed](53079ede13))
* **nav:** remove blue gradient background from network players ([2881aff](2881affecc))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* **nav:** remove play arrow badge from turn indicators ([80cfc10](80cfc10f78))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* **matching:** integrate game type into nav, remove redundant header ([389df29](389df29dc3))
* **matching:** unify duplicate MemoryGrid components into shared implementation ([5f7067a](5f7067a106))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* remove old arcade guard system ([2e6469b](2e6469bed4))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* update core UI components ([55ccf09](55ccf097d9))
* update game pages for room-based multiplayer ([54846bd](54846bdc3f))
* update nav components for room-based system ([31ac958](31ac958d33))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))
* **zIndex:** create centralized z-index layering system ([a204c83](a204c83afc))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
* add tests for room and moderation features ([063a8e5](063a8e52fe))
2025-10-13 18:01:59 +00:00
Thomas Hallock
fae5920e2f fix(moderation): don't show pending invitation for users already in room
Fixed logic error where users currently in the room would still show
a "Pending invitation" indicator in the History tab. The invitation
status should only be displayed for users who are:
- Not currently in the room
- Not banned
- Have a pending invitation

This prevents the confusing UI state where a user appears to be
both "In Room" and have a "Pending invitation" at the same time.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 13:00:45 -05:00
semantic-release-bot
3a9016977d chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-13)

### Features

* add API routes for moderation and invitations ([79a8518](79a8518557))
* add backend library functions for room moderation ([84f3c4b](84f3c4bcfd))
* add centralized player ownership utilities ([1019a48](1019a487f8))
* add common UI components ([cd3115a](cd3115aa6d))
* add database schema for room moderation and invitations ([97d1604](97d16041df))
* add invitation system UI components ([fd3a2d1](fd3a2d1f76))
* add moderation panel with unban & invite feature ([a2d0169](a2d0169f80))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* add real-time socket updates for moderation events ([86ceba3](86ceba3df3))
* add room creation and join flow UI ([7f95032](7f95032253))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* integrate moderation system into arcade pages ([087652f](087652f9e7))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add prominent turn indicator arrow badge ([f574558](f574558dff))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** add turn indicators to network players ([623314b](623314bd38))
* **nav:** add turn label text under current player avatars ([52a66d5](52a66d5f68))
* **nav:** center game context with hamburger menu for utilities ([a35a7d5](a35a7d56df))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** apply turn indicators to arcade version too ([e6f96a8](e6f96a8b99))
* **matching:** make MemoryGrid generic to support different card types ([dcda826](dcda826b9a))
* **matching:** only apply turn indicator when game is active ([cb4c061](cb4c061d11))
* **matching:** replace mismatch banner with card shake animation ([804096f](804096fd8a))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **nav:** add delay to hamburger menu hover to prevent premature closing ([95cd72e](95cd72e9bf))
* **nav:** add z-index to turn labels to prevent avatar overlap ([7c294da](7c294dafff))
* **nav:** close hamburger menu when nested dropdown closes and mouse not hovering ([7d65212](7d652126d0))
* **nav:** enable tooltips for local players during gameplay ([5499700](54997007b8))
* **nav:** improve readability of turn label text ([bbd1da0](bbd1da02b5))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** prevent hamburger menu from closing when toggling Style dropdown ([a898fbc](a898fbc187))
* **nav:** prevent style dropdown from closing hamburger menu ([560a052](560a05266e))
* **nav:** prevent turn label text from being obscured ([c4b00dd](c4b00dd679))
* **nav:** properly prevent nested style dropdown from closing hamburger menu ([c5b6a82](c5b6a82ca4))
* **nav:** remove animation/enlargement from network player turn indicator ([53079ed](53079ede13))
* **nav:** remove blue gradient background from network players ([2881aff](2881affecc))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* **nav:** remove play arrow badge from turn indicators ([80cfc10](80cfc10f78))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* **matching:** integrate game type into nav, remove redundant header ([389df29](389df29dc3))
* **matching:** unify duplicate MemoryGrid components into shared implementation ([5f7067a](5f7067a106))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* remove old arcade guard system ([2e6469b](2e6469bed4))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* update core UI components ([55ccf09](55ccf097d9))
* update game pages for room-based multiplayer ([54846bd](54846bdc3f))
* update nav components for room-based system ([31ac958](31ac958d33))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))
* **zIndex:** create centralized z-index layering system ([a204c83](a204c83afc))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
* add tests for room and moderation features ([063a8e5](063a8e52fe))
2025-10-13 16:26:58 +00:00
Thomas Hallock
9e025f8a0a chore: update Claude Code settings
Update local settings for development workflow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 11:25:28 -05:00
Thomas Hallock
55ccf097d9 refactor: update core UI components
Update core UI components for new room system:
- AbacusDisplayDropdown: Enhanced styling and accessibility
- AppNavBar: Integration with room info and moderation
- PageWithNav: Room-aware page wrapper

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 11:25:21 -05:00
Thomas Hallock
063a8e52fe test: add tests for room and moderation features
Add comprehensive tests:
- useRoomData.test.tsx: Hook tests for room data management
- orphaned-session.e2e.test.ts: E2E tests for session cleanup
- orphaned-session-cleanup.test.ts: Unit tests for cleanup logic

Tests cover room creation, joining, moderation events,
and socket-based real-time updates.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 11:25:12 -05:00
Thomas Hallock
54846bdc3f refactor: update game pages for room-based multiplayer
Update game implementations to support new room system:
- Arcade matching game with room integration
- Local/Room memory pairs providers
- Complement race game modes
- Memory quiz game
- GameModeContext with room awareness
- MemoryGrid component updates

Games now properly integrate with room-based multiplayer,
moderation, and real-time updates.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 11:25:04 -05:00
Thomas Hallock
31ac958d33 refactor: update nav components for room-based system
Update navigation components to work with new room system:
- AddPlayerButton: Enhanced with multiplayer room integration
- ActivePlayersList: Update for room-based players
- GameContextNav: Add room info and moderation access
- GameControlButtons: Room-aware controls
- NetworkPlayerIndicator: Real-time room status
- PlayerTooltip: Enhanced player information
- Tests: Add comprehensive tests for AddPlayerButton

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 11:24:54 -05:00
Thomas Hallock
2e6469bed4 refactor: remove old arcade guard system
Remove deprecated arcade guard hooks and components:
- useArcadeGuard.ts
- useArcadeRedirect.ts
- ArcadeGuardedPage.tsx
- Related tests

These have been replaced by the new room-based system with
proper moderation, invitations, and real-time updates.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 11:24:35 -05:00
Thomas Hallock
087652f9e7 feat: integrate moderation system into arcade pages
Update arcade pages to use new moderation features:
- /arcade: Add PendingInvitations and PlayOnlineTab with room management
- /arcade/room: Add ModerationNotifications to all render paths
- RoomInfo: Add moderation panel with focus capability for reported players
- join API: Enhanced with better error handling

Users can now:
- See and respond to invitations from the arcade lobby
- Receive real-time moderation notifications in rooms
- Access full moderation panel as room hosts
- Click on report toasts to view reported players

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 11:24:19 -05:00
Thomas Hallock
a2d0169f80 feat: add moderation panel with unban & invite feature
Add comprehensive moderation UI:
- ModerationPanel: Full-featured moderation interface for room hosts
  - Members tab: View active members, reports, kick/ban actions
  - Bans tab: View and manage banned users with inline unban confirmation
  - History tab: View all historical members with status tracking
  - Real-time updates when members join/leave
  - Unban & Invite feature: Banned users can be unbanned and auto-invited
    from the History tab with inline confirmation
- ModerationNotifications: Toast/modal notifications for:
  - Kicked from room
  - Banned from room
  - Player reported (clickable to open moderation panel)
  - Room invitation received (with accept & join)
- ReportPlayerModal: Form for reporting player misconduct

Key features:
- Real-time member status updates across all tabs
- Focus/highlight system for reported players
- Auto-invite on unban workflow
- Inline confirmations for destructive actions
- Accessible UI with Radix primitives

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 11:24:02 -05:00
Thomas Hallock
fd3a2d1f76 feat: add invitation system UI components
Add invitation management UI:
- PendingInvitations: Display pending room invitations with accept/decline
- InvitePlayersTab: Tab for inviting players to rooms
- RoomShareButtons: Share room via link or code

Includes real-time invitation updates, auto-unban celebration UI,
and seamless accept & join flow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 11:23:43 -05:00
Thomas Hallock
7f95032253 feat: add room creation and join flow UI
Add comprehensive room management UI:
- CreateRoomModal: Modal for creating new multiplayer rooms
- JoinRoomModal: Modal for joining rooms via code
- JoinRoomInput: Reusable input component for room codes
- PlayOnlineTab: Tab component for arcade lobby
- RecentRoomsList: List of user's recent rooms
- /join/[code] page: Direct join link page
- E2E test for join flow

Includes shareable room links, clipboard integration,
and user-friendly error handling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 11:23:28 -05:00
Thomas Hallock
cd3115aa6d feat: add common UI components
Add reusable components:
- Modal: Accessible modal component using Radix UI Dialog primitive
- CopyButton: Copy-to-clipboard button with visual feedback
- useClipboard: Hook for clipboard operations with success/error states

These components are used throughout the moderation and room
management UI for consistent user experience.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 11:23:12 -05:00
Thomas Hallock
86ceba3df3 feat: add real-time socket updates for moderation events
Enhance socket server and useRoomData hook to support:
- User-specific channels for personal notifications (kicks, bans, invitations)
- Socket connections maintained when authenticated (not just in rooms)
- Real-time moderation event handling (kicked, banned, reported, invited)
- ModerationEvent types and handlers in useRoomData

This enables users to receive invitations and moderation notifications
anywhere in the app, including the arcade lobby, without page refresh.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 11:22:55 -05:00
Thomas Hallock
79a8518557 feat: add API routes for moderation and invitations
Add REST API endpoints for:
- POST/DELETE /ban: Ban/unban users (with auto-invite on unban)
- POST /kick: Remove users from room temporarily
- POST /report: Submit player misconduct reports
- GET /reports: Retrieve pending reports for room hosts
- GET /history: Get all historical room members with statuses
- POST /invite: Send room invitations
- GET /invitations/pending: Get user's pending invitations

All endpoints include proper authentication, validation, and
real-time socket notifications for affected users.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 11:22:38 -05:00
Thomas Hallock
84f3c4bcfd feat: add backend library functions for room moderation
Implement core moderation functionality:
- room-moderation.ts: Ban/unban users, kick, submit/manage reports
- room-invitations.ts: Create and manage room invitations
- room-member-history.ts: Track historical room membership
- room-membership.ts: Enhanced to work with new moderation system

These functions provide the business logic layer for all
moderation features including auto-invite on unban.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 11:22:20 -05:00
Thomas Hallock
97d16041df feat: add database schema for room moderation and invitations
Add comprehensive database schema to support:
- Room bans: Track banned users with reasons and timestamps
- Room reports: Allow players to report others for misconduct
- Room invitations: Send and track room invitations
- Room member history: Track all users who have ever been in a room

This foundational schema enables the complete moderation system
including banning, kicking, reporting, and invitation features.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 11:22:02 -05:00
semantic-release-bot
07696f3264 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-12)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add prominent turn indicator arrow badge ([f574558](f574558dff))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** add turn indicators to network players ([623314b](623314bd38))
* **nav:** add turn label text under current player avatars ([52a66d5](52a66d5f68))
* **nav:** center game context with hamburger menu for utilities ([a35a7d5](a35a7d56df))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** apply turn indicators to arcade version too ([e6f96a8](e6f96a8b99))
* **matching:** make MemoryGrid generic to support different card types ([dcda826](dcda826b9a))
* **matching:** only apply turn indicator when game is active ([cb4c061](cb4c061d11))
* **matching:** replace mismatch banner with card shake animation ([804096f](804096fd8a))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **nav:** add delay to hamburger menu hover to prevent premature closing ([95cd72e](95cd72e9bf))
* **nav:** add z-index to turn labels to prevent avatar overlap ([7c294da](7c294dafff))
* **nav:** close hamburger menu when nested dropdown closes and mouse not hovering ([7d65212](7d652126d0))
* **nav:** enable tooltips for local players during gameplay ([5499700](54997007b8))
* **nav:** improve readability of turn label text ([bbd1da0](bbd1da02b5))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** prevent hamburger menu from closing when toggling Style dropdown ([a898fbc](a898fbc187))
* **nav:** prevent style dropdown from closing hamburger menu ([560a052](560a05266e))
* **nav:** prevent turn label text from being obscured ([c4b00dd](c4b00dd679))
* **nav:** properly prevent nested style dropdown from closing hamburger menu ([c5b6a82](c5b6a82ca4))
* **nav:** remove animation/enlargement from network player turn indicator ([53079ed](53079ede13))
* **nav:** remove blue gradient background from network players ([2881aff](2881affecc))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* **nav:** remove play arrow badge from turn indicators ([80cfc10](80cfc10f78))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* **matching:** integrate game type into nav, remove redundant header ([389df29](389df29dc3))
* **matching:** unify duplicate MemoryGrid components into shared implementation ([5f7067a](5f7067a106))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))
* **zIndex:** create centralized z-index layering system ([a204c83](a204c83afc))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-12 11:58:19 +00:00
Thomas Hallock
a898fbc187 fix(nav): prevent hamburger menu from closing when toggling Style dropdown
Simplified nested dropdown state management to allow hamburger menu to stay
open when clicking the Style button to close it. The hamburger now naturally
stays open as long as the mouse is hovering, and closes when the mouse
actually leaves the menu area.

Changes:
- Removed forced close logic when nested dropdown closes
- Removed unused isCurrentlyHovering ref
- Let the existing hover/click state management handle menu visibility naturally

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 06:57:08 -05:00
semantic-release-bot
91013fd632 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-12)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add prominent turn indicator arrow badge ([f574558](f574558dff))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** add turn indicators to network players ([623314b](623314bd38))
* **nav:** add turn label text under current player avatars ([52a66d5](52a66d5f68))
* **nav:** center game context with hamburger menu for utilities ([a35a7d5](a35a7d56df))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** apply turn indicators to arcade version too ([e6f96a8](e6f96a8b99))
* **matching:** make MemoryGrid generic to support different card types ([dcda826](dcda826b9a))
* **matching:** only apply turn indicator when game is active ([cb4c061](cb4c061d11))
* **matching:** replace mismatch banner with card shake animation ([804096f](804096fd8a))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **nav:** add delay to hamburger menu hover to prevent premature closing ([95cd72e](95cd72e9bf))
* **nav:** add z-index to turn labels to prevent avatar overlap ([7c294da](7c294dafff))
* **nav:** close hamburger menu when nested dropdown closes and mouse not hovering ([7d65212](7d652126d0))
* **nav:** enable tooltips for local players during gameplay ([5499700](54997007b8))
* **nav:** improve readability of turn label text ([bbd1da0](bbd1da02b5))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** prevent style dropdown from closing hamburger menu ([560a052](560a05266e))
* **nav:** prevent turn label text from being obscured ([c4b00dd](c4b00dd679))
* **nav:** properly prevent nested style dropdown from closing hamburger menu ([c5b6a82](c5b6a82ca4))
* **nav:** remove animation/enlargement from network player turn indicator ([53079ed](53079ede13))
* **nav:** remove blue gradient background from network players ([2881aff](2881affecc))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* **nav:** remove play arrow badge from turn indicators ([80cfc10](80cfc10f78))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* **matching:** integrate game type into nav, remove redundant header ([389df29](389df29dc3))
* **matching:** unify duplicate MemoryGrid components into shared implementation ([5f7067a](5f7067a106))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))
* **zIndex:** create centralized z-index layering system ([a204c83](a204c83afc))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-12 11:48:16 +00:00
Thomas Hallock
7d652126d0 fix(nav): close hamburger menu when nested dropdown closes and mouse not hovering
Fixed issue where hamburger menu would stay open after:
1. Hover over hamburger (opens)
2. Click style dropdown (opens)
3. Click outside to close style dropdown
4. Hamburger stays open until clicked

Solution:
- Track hover state with useRef to know real-time mouse position
- When nested dropdown closes, check if mouse is still hovering
- If not hovering, close hamburger immediately

Also cleaned up debug console.log statements now that the issue is resolved.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 06:47:04 -05:00
Thomas Hallock
a204c83afc refactor(zIndex): create centralized z-index layering system
Replace magic z-index numbers with named constants in a centralized
system to prevent conflicts and make the layering hierarchy clear.

Created src/constants/zIndex.ts with:
- Logical layers (BASE, NAV, DROPDOWN, MODAL, etc.)
- Special game navigation layer (HAMBURGER_MENU, HAMBURGER_NESTED_DROPDOWN)
- Helper function for accessing nested values

Updated components:
- AppNavBar: Use Z_INDEX.GAME_NAV.HAMBURGER_MENU (was 9999)
- AppNavBar: Use Z_INDEX.NAV_BAR (was 100)
- AbacusDisplayDropdown: Use Z_INDEX.GAME_NAV.HAMBURGER_NESTED_DROPDOWN (was 10000)

Benefits:
- No more magic numbers
- Clear hierarchy of what appears on top
- Easy to adjust entire layers
- Self-documenting code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 06:47:04 -05:00
Thomas Hallock
c5b6a82ca4 fix(nav): properly prevent nested style dropdown from closing hamburger menu
The issue was Radix UI's onInteractOutside behavior, not event propagation.
When clicking the nested Style dropdown, Radix detected it as an "outside"
interaction and closed the parent hamburger menu.

Solution: Add onInteractOutside handler to hamburger menu Content that
checks if the interaction is within a nested Radix popup (role="dialog"
or data-radix-popper-content-wrapper) and prevents the close.

Reverted previous stopPropagation approach as it was addressing the wrong
layer of the problem.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 06:47:04 -05:00
Thomas Hallock
560a05266e fix(nav): prevent style dropdown from closing hamburger menu
Stop click event propagation from the nested AbacusDisplayDropdown
to prevent it from closing the parent hamburger menu.

Added stopPropagation to:
- Style dropdown trigger button
- Style dropdown content wrapper

This allows the nested dropdown to function independently without
affecting the parent menu state.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 06:47:04 -05:00
github-actions[bot]
af209fe6ac 📚 Update AbacusReact examples and documentation
Auto-generated fresh examples and balanced documentation from latest component changes.
Includes comprehensive usage patterns, API documentation, and educational examples.

Files updated:
- packages/abacus-react/README.md
- packages/abacus-react/src/AbacusReact.examples.stories.tsx

🤖 Generated with GitHub Actions

Co-Authored-By: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-12 03:18:33 +00:00
github-actions[bot]
76163a0846 🎨 Update template examples and crop mark gallery
Auto-generated fresh SVG examples and unified gallery from latest templates.
Includes comprehensive crop mark demonstrations with before/after comparisons.

Files updated:
- packages/templates/gallery-unified.html

🤖 Generated with GitHub Actions

Co-Authored-By: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-12 02:30:55 +00:00
semantic-release-bot
fe2e6a98b9 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-12)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add prominent turn indicator arrow badge ([f574558](f574558dff))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** add turn indicators to network players ([623314b](623314bd38))
* **nav:** add turn label text under current player avatars ([52a66d5](52a66d5f68))
* **nav:** center game context with hamburger menu for utilities ([a35a7d5](a35a7d56df))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** apply turn indicators to arcade version too ([e6f96a8](e6f96a8b99))
* **matching:** make MemoryGrid generic to support different card types ([dcda826](dcda826b9a))
* **matching:** only apply turn indicator when game is active ([cb4c061](cb4c061d11))
* **matching:** replace mismatch banner with card shake animation ([804096f](804096fd8a))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **nav:** add delay to hamburger menu hover to prevent premature closing ([95cd72e](95cd72e9bf))
* **nav:** add z-index to turn labels to prevent avatar overlap ([7c294da](7c294dafff))
* **nav:** enable tooltips for local players during gameplay ([5499700](54997007b8))
* **nav:** improve readability of turn label text ([bbd1da0](bbd1da02b5))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** prevent turn label text from being obscured ([c4b00dd](c4b00dd679))
* **nav:** remove animation/enlargement from network player turn indicator ([53079ed](53079ede13))
* **nav:** remove blue gradient background from network players ([2881aff](2881affecc))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* **nav:** remove play arrow badge from turn indicators ([80cfc10](80cfc10f78))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* **matching:** integrate game type into nav, remove redundant header ([389df29](389df29dc3))
* **matching:** unify duplicate MemoryGrid components into shared implementation ([5f7067a](5f7067a106))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-12 01:11:45 +00:00
Thomas Hallock
dcda826b9a fix(matching): make MemoryGrid generic to support different card types
Fix TypeScript error where GameCard type was incompatible with MemoryCard
by making the shared MemoryGrid component generic over the card type.

This allows each route to use its own card type definition while still
sharing the grid implementation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 20:10:38 -05:00
Thomas Hallock
5f7067a106 refactor(matching): unify duplicate MemoryGrid components into shared implementation
Consolidate two nearly-identical MemoryGrid components (arcade vs games)
into a single shared component with optional multiplayer features.

Changes:
- Create src/components/matching/ for shared matching game components
- Extract HoverAvatar into standalone component for reusability
- Create unified MemoryGrid with enableMultiplayerPresence prop
- Update arcade GamePhase to use shared grid with presence features
- Update games GamePhase to use shared grid without presence features
- Remove duplicate MemoryGrid files from both routes

Benefits:
- Bug fixes only need to be applied once
- Features won't diverge over time
- Reduced testing surface area
- 420+ lines of duplicate code eliminated

The shared MemoryGrid uses a render prop pattern for GameCard to allow
each route to use its own card implementation while sharing grid logic.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 20:10:38 -05:00
Thomas Hallock
804096fd8a fix(matching): replace mismatch banner with card shake animation
Remove the obtrusive "Not a match! Try again." banner overlay that
appeared over cards when incorrect pairs were selected. Replace with
a kinetic shake animation applied directly to the mismatched cards
before they flip back over.

Changes:
- Remove banner overlay from both arcade and games MemoryGrid components
- Add shouldShake state to identify mismatched cards
- Apply cardShake animation (horizontal wiggle + rotation) to cards
- Update animation keyframes from banner shake to card shake

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 20:10:38 -05:00
semantic-release-bot
1948ba2dde chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add prominent turn indicator arrow badge ([f574558](f574558dff))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** add turn indicators to network players ([623314b](623314bd38))
* **nav:** add turn label text under current player avatars ([52a66d5](52a66d5f68))
* **nav:** center game context with hamburger menu for utilities ([a35a7d5](a35a7d56df))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** apply turn indicators to arcade version too ([e6f96a8](e6f96a8b99))
* **matching:** only apply turn indicator when game is active ([cb4c061](cb4c061d11))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **nav:** add delay to hamburger menu hover to prevent premature closing ([95cd72e](95cd72e9bf))
* **nav:** add z-index to turn labels to prevent avatar overlap ([7c294da](7c294dafff))
* **nav:** enable tooltips for local players during gameplay ([5499700](54997007b8))
* **nav:** improve readability of turn label text ([bbd1da0](bbd1da02b5))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** prevent turn label text from being obscured ([c4b00dd](c4b00dd679))
* **nav:** remove animation/enlargement from network player turn indicator ([53079ed](53079ede13))
* **nav:** remove blue gradient background from network players ([2881aff](2881affecc))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* **nav:** remove play arrow badge from turn indicators ([80cfc10](80cfc10f78))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* **matching:** integrate game type into nav, remove redundant header ([389df29](389df29dc3))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 17:03:39 +00:00
Thomas Hallock
95cd72e9bf fix(nav): add delay to hamburger menu hover to prevent premature closing
Added 150ms delay when mouse leaves the hamburger button or menu to allow
smooth transition between them without the menu closing. This fixes the issue
where the menu would immediately close when moving the mouse from the button
to the dropdown content.

Implementation:
- useRef to track timeout
- handleMouseEnter: cancels any pending close timeout
- handleMouseLeave: sets 150ms delay before closing
- Applied to both button and menu content
- Cleanup effect to clear timeout on unmount

The menu now stays open while hovering either the button or the dropdown,
providing smooth UX for both hover and click interactions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 12:02:34 -05:00
semantic-release-bot
3e5fa41d08 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add prominent turn indicator arrow badge ([f574558](f574558dff))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** add turn indicators to network players ([623314b](623314bd38))
* **nav:** add turn label text under current player avatars ([52a66d5](52a66d5f68))
* **nav:** center game context with hamburger menu for utilities ([a35a7d5](a35a7d56df))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** apply turn indicators to arcade version too ([e6f96a8](e6f96a8b99))
* **matching:** only apply turn indicator when game is active ([cb4c061](cb4c061d11))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **nav:** add z-index to turn labels to prevent avatar overlap ([7c294da](7c294dafff))
* **nav:** enable tooltips for local players during gameplay ([5499700](54997007b8))
* **nav:** improve readability of turn label text ([bbd1da0](bbd1da02b5))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** prevent turn label text from being obscured ([c4b00dd](c4b00dd679))
* **nav:** remove animation/enlargement from network player turn indicator ([53079ed](53079ede13))
* **nav:** remove blue gradient background from network players ([2881aff](2881affecc))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* **nav:** remove play arrow badge from turn indicators ([80cfc10](80cfc10f78))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* **matching:** integrate game type into nav, remove redundant header ([389df29](389df29dc3))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 17:00:42 +00:00
Thomas Hallock
a35a7d56df feat(nav): center game context with hamburger menu for utilities
Complete redesign of minimal navigation for game pages:

**Layout Changes:**
- Game context (room info + players) now centered horizontally on viewport
- Hamburger menu (☰) in top-left with all utility items
- Responsive: game context shrinks to fit narrow viewports

**Hamburger Menu Features:**
- Opens on hover (instant access) OR click (traditional)
- Stays open while hovering menu or button
- Contains three organized sections:
  1. Navigation: Home, Create, Guide, Games
  2. Controls: Fullscreen toggle, Exit Arcade
  3. Abacus Style: Dropdown integrated inline

**Benefits:**
- Clean, uncluttered interface focused on game/players
- Utilities accessible but out of the way
- Discoverable affordance (visible hamburger icon)
- Works on mobile (click) and desktop (hover)
- Maintains fullscreen badge when active

Removed old top-right utility panes and CompactNavLink component.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 11:59:40 -05:00
semantic-release-bot
ab0d8081d3 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add prominent turn indicator arrow badge ([f574558](f574558dff))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** add turn indicators to network players ([623314b](623314bd38))
* **nav:** add turn label text under current player avatars ([52a66d5](52a66d5f68))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** apply turn indicators to arcade version too ([e6f96a8](e6f96a8b99))
* **matching:** only apply turn indicator when game is active ([cb4c061](cb4c061d11))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **nav:** add z-index to turn labels to prevent avatar overlap ([7c294da](7c294dafff))
* **nav:** enable tooltips for local players during gameplay ([5499700](54997007b8))
* **nav:** improve readability of turn label text ([bbd1da0](bbd1da02b5))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** prevent turn label text from being obscured ([c4b00dd](c4b00dd679))
* **nav:** remove animation/enlargement from network player turn indicator ([53079ed](53079ede13))
* **nav:** remove blue gradient background from network players ([2881aff](2881affecc))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* **nav:** remove play arrow badge from turn indicators ([80cfc10](80cfc10f78))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* **matching:** integrate game type into nav, remove redundant header ([389df29](389df29dc3))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 16:52:42 +00:00
Thomas Hallock
54997007b8 fix(nav): enable tooltips for local players during gameplay
Removed `pointerEvents: canModifyPlayers ? 'auto' : 'none'` from the active
players container that was blocking tooltips during gameplay.

During gameplay (canModifyPlayers=false), this was preventing hover events
from reaching the PlayerTooltip component, so tooltips wouldn't show for
local players. Network players didn't have this restriction, so their
tooltips worked fine.

The pointer-events restriction is not needed because:
- Interactive buttons (configure/remove) only show during setup (shouldEmphasize)
- Avatar click handlers already respect the shouldEmphasize flag
- Tooltips should always be accessible for player information

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 11:51:37 -05:00
Thomas Hallock
80cfc10f78 fix(nav): remove play arrow badge from turn indicators
Removed the ▶ arrow badge from both local and network player turn indicators.
Turn indication is now shown through:
- Border ring with player color (pulsing)
- "Your turn" / "Their turn" text label below avatar
- Size increase and animation (local players only)
- Opacity dimming of non-current players

The arrow badge was unnecessary visual clutter with the text labels providing
clear indication of whose turn it is.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 11:50:55 -05:00
semantic-release-bot
c1472f7865 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add prominent turn indicator arrow badge ([f574558](f574558dff))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** add turn indicators to network players ([623314b](623314bd38))
* **nav:** add turn label text under current player avatars ([52a66d5](52a66d5f68))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** apply turn indicators to arcade version too ([e6f96a8](e6f96a8b99))
* **matching:** only apply turn indicator when game is active ([cb4c061](cb4c061d11))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **nav:** add z-index to turn labels to prevent avatar overlap ([7c294da](7c294dafff))
* **nav:** improve readability of turn label text ([bbd1da0](bbd1da02b5))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** prevent turn label text from being obscured ([c4b00dd](c4b00dd679))
* **nav:** remove animation/enlargement from network player turn indicator ([53079ed](53079ede13))
* **nav:** remove blue gradient background from network players ([2881aff](2881affecc))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* **matching:** integrate game type into nav, remove redundant header ([389df29](389df29dc3))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 15:58:41 +00:00
Thomas Hallock
389df29dc3 refactor(matching): integrate game type into nav, remove redundant header
Removed redundant in-game header and integrated all game info into the nav bar:

**Changes:**
- MemoryPairsGame now sets navTitle/navEmoji based on gameType:
  - Complement Pairs (🤝) for complement-pairs mode
  - Abacus Match (🧮) for abacus-numeral mode
- Removed redundant game header from GamePhase that showed:
  - Game type emoji and name (now in nav)
  - Player count "⚔️ 3Ps" (visible via player avatars in nav)
  - New Game button (already in nav dropdown menu)
- Cleaned up unused imports and variables

**Result:**
- No duplicate information displayed
- Cleaner game interface with more space for gameplay
- All game/room info consolidated in the persistent nav bar

Applied to both /games/matching and /arcade/matching versions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 10:57:43 -05:00
semantic-release-bot
c13da68a98 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add prominent turn indicator arrow badge ([f574558](f574558dff))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** add turn indicators to network players ([623314b](623314bd38))
* **nav:** add turn label text under current player avatars ([52a66d5](52a66d5f68))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** apply turn indicators to arcade version too ([e6f96a8](e6f96a8b99))
* **matching:** only apply turn indicator when game is active ([cb4c061](cb4c061d11))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **nav:** add z-index to turn labels to prevent avatar overlap ([7c294da](7c294dafff))
* **nav:** improve readability of turn label text ([bbd1da0](bbd1da02b5))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** prevent turn label text from being obscured ([c4b00dd](c4b00dd679))
* **nav:** remove animation/enlargement from network player turn indicator ([53079ed](53079ede13))
* **nav:** remove blue gradient background from network players ([2881aff](2881affecc))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 14:30:35 +00:00
Thomas Hallock
2881affecc fix(nav): remove blue gradient background from network players
Removed the decorative blue/purple/pink gradient "network frame border" that
was showing around remote player avatars. This background was not requested
and is unnecessary with the current turn indicator system.

Also cleaned up unused hover state tracking that was only used for the frame.

Network players now display cleanly with just their emoji and the network
badge (📡), showing the turn indicator border only when it's their turn.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 09:29:29 -05:00
semantic-release-bot
c34e28e3ab chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add prominent turn indicator arrow badge ([f574558](f574558dff))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** add turn indicators to network players ([623314b](623314bd38))
* **nav:** add turn label text under current player avatars ([52a66d5](52a66d5f68))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** apply turn indicators to arcade version too ([e6f96a8](e6f96a8b99))
* **matching:** only apply turn indicator when game is active ([cb4c061](cb4c061d11))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **nav:** add z-index to turn labels to prevent avatar overlap ([7c294da](7c294dafff))
* **nav:** improve readability of turn label text ([bbd1da0](bbd1da02b5))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** prevent turn label text from being obscured ([c4b00dd](c4b00dd679))
* **nav:** remove animation/enlargement from network player turn indicator ([53079ed](53079ede13))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 14:27:16 +00:00
Thomas Hallock
bbd1da02b5 fix(nav): improve readability of turn label text
Made turn labels much more readable with white outline and bolder styling:

- Increased font weight from bold (700) to 900
- Increased font size from 11px to 12px
- Added white text outline using 4-directional text shadows
- Enhanced drop shadow for better depth

The white outline creates strong contrast against the player's theme color,
making "Your turn" and "Their turn" text clearly readable over any avatar.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 09:26:16 -05:00
semantic-release-bot
2b8faad9d6 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add prominent turn indicator arrow badge ([f574558](f574558dff))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** add turn indicators to network players ([623314b](623314bd38))
* **nav:** add turn label text under current player avatars ([52a66d5](52a66d5f68))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** apply turn indicators to arcade version too ([e6f96a8](e6f96a8b99))
* **matching:** only apply turn indicator when game is active ([cb4c061](cb4c061d11))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **nav:** add z-index to turn labels to prevent avatar overlap ([7c294da](7c294dafff))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** prevent turn label text from being obscured ([c4b00dd](c4b00dd679))
* **nav:** remove animation/enlargement from network player turn indicator ([53079ed](53079ede13))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 14:26:06 +00:00
Thomas Hallock
7c294dafff fix(nav): add z-index to turn labels to prevent avatar overlap
Added position: relative and zIndex: 10 to turn label text to ensure it
renders above the animated avatar emojis. The floating/scaling animations
were causing the avatars to obscure the text below them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 09:25:00 -05:00
semantic-release-bot
6105cae17c chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add prominent turn indicator arrow badge ([f574558](f574558dff))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** add turn indicators to network players ([623314b](623314bd38))
* **nav:** add turn label text under current player avatars ([52a66d5](52a66d5f68))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** apply turn indicators to arcade version too ([e6f96a8](e6f96a8b99))
* **matching:** only apply turn indicator when game is active ([cb4c061](cb4c061d11))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** prevent turn label text from being obscured ([c4b00dd](c4b00dd679))
* **nav:** remove animation/enlargement from network player turn indicator ([53079ed](53079ede13))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 14:24:33 +00:00
Thomas Hallock
c4b00dd679 fix(nav): prevent turn label text from being obscured
Changed parent containers to use flex-end alignment and increased bottom
padding to prevent the turn label text from being clipped or hidden:

- Changed alignItems from 'center' to 'flex-end' for both network and local
  player containers
- Increased bottom padding to accommodate the text labels
- Network player container: 6px 12px → 6px 12px 12px 12px
- Active player container: varies by emphasis state, added 4-6px bottom

This ensures "Your turn" and "Their turn" text is fully visible below avatars.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 09:23:28 -05:00
semantic-release-bot
5357433c41 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add prominent turn indicator arrow badge ([f574558](f574558dff))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** add turn indicators to network players ([623314b](623314bd38))
* **nav:** add turn label text under current player avatars ([52a66d5](52a66d5f68))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** apply turn indicators to arcade version too ([e6f96a8](e6f96a8b99))
* **matching:** only apply turn indicator when game is active ([cb4c061](cb4c061d11))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** remove animation/enlargement from network player turn indicator ([53079ed](53079ede13))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 14:22:54 +00:00
Thomas Hallock
52a66d5f68 feat(nav): add turn label text under current player avatars
Players now see clear text labels under avatars when it's someone's turn:
- "Your turn" under local players (in player's color)
- "Their turn" under network/remote players (in player's color)

Labels are:
- Small uppercase text (11px, bold)
- Colored with player's theme color
- Only shown during active gameplay (hasGameState)
- Only appear under the current player's avatar

This provides explicit confirmation of whose turn it is, complementing
the visual indicators (border ring, arrow badge, size/animation changes).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 09:21:49 -05:00
semantic-release-bot
09147f95a5 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add prominent turn indicator arrow badge ([f574558](f574558dff))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** add turn indicators to network players ([623314b](623314bd38))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** apply turn indicators to arcade version too ([e6f96a8](e6f96a8b99))
* **matching:** only apply turn indicator when game is active ([cb4c061](cb4c061d11))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** remove animation/enlargement from network player turn indicator ([53079ed](53079ede13))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 14:21:09 +00:00
Thomas Hallock
53079ede13 fix(nav): remove animation/enlargement from network player turn indicator
When it's a network player's turn, the local player is waiting, not acting.
Network players now show turn indicators without animated/enlarged behavior:

- Border ring and arrow badge still show whose turn it is
- Avatar stays at normal size (56px, no enlargement)
- No floating/bobbing animation
- Opacity still changes to emphasize current player

This makes it clear which remote player has the turn while signaling that
no action is required from the current session.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 09:20:11 -05:00
semantic-release-bot
a7309cb414 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add prominent turn indicator arrow badge ([f574558](f574558dff))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** add turn indicators to network players ([623314b](623314bd38))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** apply turn indicators to arcade version too ([e6f96a8](e6f96a8b99))
* **matching:** only apply turn indicator when game is active ([cb4c061](cb4c061d11))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 14:19:16 +00:00
Thomas Hallock
eaf17e07fc chore: add biome permissions to Claude Code settings
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 09:18:17 -05:00
Thomas Hallock
623314bd38 feat(nav): add turn indicators to network players
Network players now display the same turn indicator features as local players:
- Size increase and float animation for current player
- Border ring with player color
- Turn arrow badge (▶) in top-left
- Score badge in bottom-right
- Streak badge (🔥) in top-right when streak >= 2
- Opacity dimming for non-current players during game

This ensures all players (local and remote) have identical turn indicator
treatment, making it clear whose turn it is from any player's perspective.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 09:18:17 -05:00
semantic-release-bot
fc4556803b chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add prominent turn indicator arrow badge ([f574558](f574558dff))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** apply turn indicators to arcade version too ([e6f96a8](e6f96a8b99))
* **matching:** only apply turn indicator when game is active ([cb4c061](cb4c061d11))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 14:12:37 +00:00
Thomas Hallock
f574558dff feat(nav): add prominent turn indicator arrow badge
Add a pulsing arrow badge (▶) on the top-left of the current player's
avatar to make it crystal clear whose turn it is, even when viewing
from another player's perspective.

The badge:
- Circular with player's color gradient
- White border for contrast
- Pulsing animation to draw attention
- Shows right-pointing arrow to indicate active turn
- Positioned at top-left corner

This addresses the issue where non-current players couldn't easily
tell whose turn it was - they'd see themselves dimmed but no clear
indicator of who had the turn.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 09:11:33 -05:00
semantic-release-bot
4ec0312049 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** apply turn indicators to arcade version too ([e6f96a8](e6f96a8b99))
* **matching:** only apply turn indicator when game is active ([cb4c061](cb4c061d11))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 14:09:39 +00:00
Thomas Hallock
e6f96a8b99 fix(matching): apply turn indicators to arcade version too
The previous commits only updated the /games/ version but not the
/arcade/ version. This commit applies the same changes to the arcade
version so turn indicators work properly there.

Changes:
- Pass game state (currentPlayer, scores, streaks) from arcade
  MemoryPairsGame to PageWithNav
- Remove PlayerStatusBar import and usage from arcade GamePhase

Now both versions use nav avatars as turn indicators.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 09:08:45 -05:00
semantic-release-bot
22984b4423 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** only apply turn indicator when game is active ([cb4c061](cb4c061d11))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 14:02:53 +00:00
Thomas Hallock
cb4c061d11 fix(matching): only apply turn indicator when game is active
Fix avatars being dimmed when no game is running. Only apply turn
indicator styles (opacity, size, border, animations) when currentPlayerId
is defined (game is actually playing).

Without this check, all players were dimmed to 0.65 opacity when the
game wasn't running because isCurrentPlayer was false for everyone.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 09:02:00 -05:00
semantic-release-bot
39f64208ea chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **matching:** use nav avatars as turn indicators ([7263828](7263828ed4))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 13:58:10 +00:00
Thomas Hallock
7263828ed4 feat(matching): use nav avatars as turn indicators
Replace the in-game PlayerStatusBar with turn indicators integrated
directly into the nav bar avatars. This creates a cleaner, more
unified UI with less visual clutter while making turn state always
visible at the top of the screen.

Changes:
- Pass game state (currentPlayer, scores, streaks) through PageWithNav
  to GameContextNav to ActivePlayersList
- Add turn indicator styling to avatars:
  - Current player: larger (70px vs 56px), colored border ring with glow
  - Other players: dimmed opacity (0.65)
  - Floating animation on current player
- Add score badge (bottom-right) showing pairs matched
- Add streak badge (top-right) with fire emoji, color-coded by level:
  - Green: 2+ streak (great)
  - Orange: 3+ streak (epic)
  - Purple: 5+ streak (legendary)
- Remove PlayerStatusBar from GamePhase since nav now shows all info
- Add CSS animations: avatarFloat, borderPulse, streakPulse

Benefits:
- Less visual clutter in game area
- Turn state always visible in persistent nav
- More space for the actual game
- Consistent with nav-as-control-center design

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 08:57:08 -05:00
semantic-release-bot
cecf07e572 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **matching:** use UUID instead of numeric index for scores ([5036cb0](5036cb00b6))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 13:53:33 +00:00
Thomas Hallock
5036cb00b6 fix(matching): use UUID instead of numeric index for scores
Fix critical bug where PlayerStatusBar was looking up scores and
consecutiveMatches by numeric index (1, 2, 3...) instead of by
player UUID. This caused all scores and streaks to show as 0 in
multiplayer games.

The game state stores scores keyed by UUID strings, not numeric
indices, so we need to use player.id for the lookup.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 08:52:26 -05:00
semantic-release-bot
b579c35db1 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **nav:** add pulsing indicator for offline network players ([64fb30e](64fb30e7ec))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 13:42:50 +00:00
Thomas Hallock
64fb30e7ec feat(nav): add pulsing indicator for offline network players
Add smooth opacity pulsing animation to the network badge (📡) when
network players are offline. The badge smoothly fades from full
opacity to 30% opacity and back over 2 seconds, providing a clear
visual indicator of disconnection status.

When online, the badge remains at full opacity with no animation.
This subtle indicator helps users quickly identify connection issues
without being distracting.

Changes:
- Add isOnline property to NetworkPlayer interface
- Apply offlinePulse animation when isOnline is false
- Default to online if isOnline is not specified

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 08:41:58 -05:00
semantic-release-bot
2c074c3444 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **nav:** combine room info and network players in single pane ([d5473ab](d5473ab66a))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 13:40:53 +00:00
Thomas Hallock
d5473ab66a feat(nav): combine room info and network players in single pane
Merge room info and network players into one bordered container for
better visual cohesion. This creates a clearer hierarchy with room
info + network players on the left, and the user's own players on
the right, maintaining vertical alignment.

Also remove the confusing pulsing green "online" indicator from
network players - the gradient border frame is sufficient to
distinguish them from local players.

Changes:
- Combine RoomInfo and NetworkPlayerIndicator in shared bordered pane
- Add subtle vertical divider between room info and network players
- Remove animated pulse indicator
- Simplify network player visual design

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 08:39:53 -05:00
semantic-release-bot
2cbc5ca5f2 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* **nav:** remove opacity reduction from local players ([5215af8](5215af801f))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 13:36:11 +00:00
Thomas Hallock
5215af801f fix(nav): remove opacity reduction from local players
Remove the opacity: 0.6 style that was making local players appear
greyed out during network games. This was creating an inconsistent
appearance where network players looked vibrant but local players
looked faded.

The pointer-events property already prevents interaction when players
can't be modified, so the visual opacity hint was redundant and
confusing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 08:35:07 -05:00
semantic-release-bot
e49e42de76 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* **nav:** improve text contrast in room info pane ([3e691cb](3e691cb06d))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 13:33:06 +00:00
Thomas Hallock
3e691cb06d fix(nav): improve text contrast in room info pane
Change text colors from white to dark gray for better readability
against the light background. The previous white-on-light-purple
was nearly unreadable.

- Game name: dark gray (rgba(17, 24, 39, 0.9))
- Room name: dark gray (rgba(17, 24, 39, 0.65))
- Dropdown indicator: dark gray (rgba(17, 24, 39, 0.5))
- Mode text: keeps color-coded text for visual distinction

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 08:32:11 -05:00
semantic-release-bot
74136d225c chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* **nav:** unify room dropdown with join code and game menu ([f7b83f8](f7b83f8c14))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 13:30:51 +00:00
Thomas Hallock
f7b83f8c14 feat(nav): unify room dropdown with join code and game menu
Combine the room code tooltip and game menu dropdown into a single
unified dropdown triggered from the room info pane. This creates a
more cohesive UX with a single interaction point for both viewing
the join code and accessing game controls.

Key changes:
- Convert RoomInfo from Tooltip to DropdownMenu component
- Add game name to room pane with dropdown indicator
- Improve text contrast (white instead of purple)
- Include join code section + game menu items in one dropdown
- Make GameTitleMenu conditional (only show when not in room)

The unified dropdown provides:
- Click-to-copy join code button
- Setup, New Game, and Quit to Arcade menu items
- Clear visual hierarchy and better accessibility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 08:29:50 -05:00
semantic-release-bot
c6c3e4ac24 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* stack game title dropdown ABOVE room pane, not inside it ([7bc815f](7bc815fd7d))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 13:25:00 +00:00
Thomas Hallock
7bc815fd7d fix: stack game title dropdown ABOVE room pane, not inside it
Corrects the layout to properly separate title from room details:

**Before (incorrect):**
- Game name was inside the room pane, no dropdown

**After (correct):**
┌──────────────┐
│ 🧩 Memory    │ ← Title with dropdown (Setup/New Game/Quit)
│ Pairs ▼     │
└──────────────┘

┌──────────────┐
│ ⚔️ Battle    │ ← Room pane (hover for join code)
│ Auto-gen     │
│ Room         │
└──────────────┘

[player avatars]

**Implementation:**
- GameTitleMenu always shown with dropdown functionality
- When in room: flexDirection: 'column' stacks title above room pane
- When not in room: flexDirection: 'row' shows title + mode horizontally
- RoomInfo back to 2-line: mode + room name
- 4px gap between stacked elements

Result: Title dropdown is separate and accessible, room pane shows
session details and serves as hover target for join code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 08:24:00 -05:00
semantic-release-bot
4165db206d chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* move game name into room pane, remove player count ([eeb8d52](eeb8d52d03))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 13:21:57 +00:00
Thomas Hallock
eeb8d52d03 refactor: move game name into room pane, remove player count
Major restructure of navigation when in a room:

**Before:**
- Separate title on left: "Memory Pairs ▼"
- Small room pill on right with mode and room name

**After:**
- Single comprehensive pane with 3-line stack:
  1. 🧩 Memory Pairs (12px, white, game name)
  2. ⚔️ Battle (11px, mode color, mode)
  3. Auto-generated Room (10px, light purple, room name)

**Changes:**
- Removed redundant player count (visible in avatars to the right)
- Game name now at top of room pane instead of separate title
- Entire pane is hover target for join code tooltip
- When NOT in room: shows original title + mode layout
- Progressive text sizing: 12px → 11px → 10px for hierarchy

**Benefits:**
- All session info in one cohesive, hoverable pane
- No duplicate game name on screen
- Clearer visual hierarchy with game name most prominent
- More space for player avatars on right

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 08:20:54 -05:00
semantic-release-bot
2d480ee0fa chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* add player count to stacked room info ([540f6b7](540f6b76d0))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 13:19:24 +00:00
Thomas Hallock
540f6b76d0 feat: add player count to stacked room info
Adds player count as third line in the combined mode/room pill:

**3-line vertical stack:**
1. Mode indicator: ⚔️ Battle (12px, bold, mode color)
2. Room name: Auto-generated Room (11px, semibold, light purple)
3. Player count: 2 players (9px, medium, lighter purple)

Styling:
- Player count uses smaller text (9px vs 11px)
- Lighter opacity (0.6 vs 0.8) for visual hierarchy
- Proper singular/plural handling
- lineHeight: 1 for tight spacing

Result: Compact info pill with all relevant room details in one place,
properly hierarchical with decreasing emphasis from mode → name → count.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 08:18:28 -05:00
semantic-release-bot
139a6d8e37 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* combine mode and room into single unified pane ([7a6f2ac](7a6f2ac6eb))
* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 13:18:01 +00:00
Thomas Hallock
7a6f2ac6eb refactor: combine mode and room into single unified pane
Merged GameModeIndicator and RoomInfo into one cohesive pane:

**Before:**
- Two separate pills: [Battle] [Auto-generated Room]
- Only room pill was hover target for join code

**After:**
- Single unified pane with vertical stack:
  ```
  ┌─────────────────────┐
  │ ⚔️ Battle           │  <- hover for join code
  │ Auto-generated Room │
  └─────────────────────┘
  ```

**Implementation:**
- RoomInfo now accepts mode props (gameMode, modeColor, modeEmoji, modeLabel)
- Displays mode indicator on top, room name below
- Entire pane is tooltip trigger for join code
- Uses mode color for border and gradient background
- When not in room, shows standalone GameModeIndicator

**Benefits:**
- Single hover target (entire pane) for join code tooltip
- Clearer visual grouping of related session info
- More compact and cohesive design
- Color-coded by game mode

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 08:16:58 -05:00
semantic-release-bot
a7b2374493 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* pixel-perfect alignment across all nav elements ([fa78a2c](fa78a2c001))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 13:14:35 +00:00
Thomas Hallock
fa78a2c001 fix: pixel-perfect alignment across all nav elements
Got into the pixels! Fixed alignment issues throughout navigation:

**lineHeight: 1 everywhere**
- Title: added lineHeight: 1 to remove extra vertical space
- GameModeIndicator: lineHeight: 1 on container and text spans
- RoomInfo: lineHeight: 1 on pill and text
- Player avatars: lineHeight: 1 already set
- Dropdown arrow: lineHeight: 1 with flex centering

**Display fixes**
- Changed all pills from flex → inline-flex for tighter bounds
- Added display: flex, alignItems: center to title for perfect centering
- Added flexShrink: 0 to AddPlayerButton to prevent compression

**Player avatar alignment**
- Added display: flex, alignItems: center, justifyContent: center
- Ensures emoji glyphs are perfectly centered
- Removed redundant fontSize conditionals (always 56px)

**Container alignment**
- All elements now share common vertical center
- No extra space from default line-heights
- Pills are tight inline elements with no overflow

Result: Every element is pixel-perfect aligned with no extra vertical
space from line-heights, properly centered, and sharing a common baseline.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 08:13:38 -05:00
semantic-release-bot
9da4bd6ceb chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* implement Option A - balanced alignment and visual hierarchy ([6ad7170](6ad71702f9))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 13:11:36 +00:00
Thomas Hallock
6ad71702f9 refactor: implement Option A - balanced alignment and visual hierarchy
Major improvements to navigation alignment and proportions:

**Title:**
- Increased from 18px to 22px for better visual prominence
- Now properly anchors the left side

**Room Info (dramatically simplified):**
- Removed 🎮 emoji (redundant)
- Removed 👥 player count (redundant - visible in player avatars)
- Reduced padding: 4px 10px → 3px 8px
- Reduced border: 2px → 1px
- Reduced font size: 12px → 11px
- Lighter background: 0.2 → 0.15 opacity
- Result: Tiny subtle tag instead of dominant banner

**Spacing & Alignment:**
- Tightened gap in mode/room stack: 4px → 3px
- Reduced gap between title and stack: 16px → 12px
- Increased gap to players: 16px → 20px
- Everything vertically centered with 56px player avatars

**Visual Hierarchy (fixed):**
1. Players (largest, most prominent)
2. Title (22px, clear focal point)
3. Mode indicator (medium pill)
4. Room info (tiny subtle tag)

Result: Clean, balanced layout with proper visual weight distribution.
The title is now prominent, mode/room are compact metadata, and players
dominate the right side.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 08:10:34 -05:00
semantic-release-bot
9353355e26 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* stack mode indicator above room info ([d1aa567](d1aa567c1e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 13:08:01 +00:00
Thomas Hallock
d1aa567c1e refactor: stack mode indicator above room info
Creates cleaner vertical hierarchy in the navigation center section.

**Before:**
[Title▼]  [Battle] [Room]  [Players]

**After:**
[Title▼]  [Battle]  [Players]
          [Room]

Changes:
- Mode and Room now in flexDirection: 'column' with 4px gap
- Aligned to flex-end to keep right-aligned
- Creates more vertical breathing room
- Clearer visual grouping of related info

Result: Better visual hierarchy with mode indicator prominently on top
and room details nested below.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 08:06:44 -05:00
semantic-release-bot
2082843c1d chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* move game controls into title dropdown menu ([e39a031](e39a0313cb))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 13:06:21 +00:00
Thomas Hallock
e39a0313cb refactor: move game controls into title dropdown menu
Replaces separate control buttons with elegant dropdown on game title,
dramatically cleaning up the navigation layout.

**New GameTitleMenu component:**
- Radix dropdown with subtle affordances (small ▼ indicator)
- Obvious but not dominating: hover background + rotating arrow
- Dark translucent menu with color-coded hover states:
  - Purple for Setup ⚙️
  - Blue for New Game 🎮
  - Orange for Quit 🏟️ (separated by divider)
- Shows only when controls are available (!canModifyPlayers)

**Layout improvements:**
- Collapsed from 2-row left column to single row
- Now: [Title▼] [spacer] [Mode] [Room] | [Players]
- Players remain at full height, even more prominent
- Much cleaner, more space-efficient design

Result: Game controls are discoverable but hidden until needed, letting
the player avatars truly dominate the navigation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 08:05:22 -05:00
semantic-release-bot
4c0dc12204 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* restructure nav so player avatars truly span both rows ([e0fd793](e0fd793812))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 12:52:03 +00:00
Thomas Hallock
e0fd793812 refactor: restructure nav so player avatars truly span both rows
Changed from 2x2 grid to split layout where players have nothing above/below:

**Before (2x2 grid):**
```
Row 1: [Title] [Mode + Room]
Row 2: [Controls] [Network + Players]
```
Players were in row 2, with content above them.

**After (split layout):**
```
Left column (2 rows):
  - Row 1: Title | Mode + Room
  - Row 2: Control buttons

Right side (spanning full height):
  - Network players
  - Your players
```

Now the 56px avatars are vertically centered with nothing above or below them,
truly justifying their two-row height. They span the full vertical space while
the left side contains the stacked title/controls.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 07:51:06 -05:00
semantic-release-bot
e4adabea07 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* comprehensive navigation polish - prominence, contrast, cohesion ([1ba58b9](1ba58b9547))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 12:42:04 +00:00
Thomas Hallock
1ba58b9547 refactor: comprehensive navigation polish - prominence, contrast, cohesion
Major improvements across all navigation components for better visual hierarchy
and user experience:

**RoomInfo**
- Redesigned with Radix tooltip for join code (hover to reveal)
- Much better contrast: purple pill with bright text (rgba(196, 181, 253, 1))
- Click-to-copy in elegant tooltip with visual feedback
- Auto-closes after successful copy with "Share this code with friends!" message

**Player Avatars (ActivePlayersList & NetworkPlayerIndicator)**
- Increased size from 20px to 56px - now span both rows for prominence
- Enhanced drop shadows (0 6px 12px) for depth
- Larger, more polished action buttons (configure/remove):
  - 24-26px buttons with 3px borders and gradient backgrounds
  - Better hover effects with color glows
- Network players: larger frame borders, badges, and pulse indicators

**AddPlayerButton**
- Increased from 48px to 56px to match player avatars
- Enhanced with gradient backgrounds and stronger shadows
- Better hover states with 1.08 scale and green glow

**GameContextNav Layout**
- Added minHeight: 64px to row 2 for avatar accommodation
- Network players now in distinct purple-gradient container
- Your players container gets enhanced polish even when not emphasized
- Improved spacing and gaps throughout (12px between player groups)

Result: Navigation now has clear visual hierarchy with player avatars as the
dominant focal point, excellent contrast on all text, and cohesive translucent
design language throughout.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 07:41:14 -05:00
semantic-release-bot
48ec451689 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* redesign GameControlButtons with translucent aesthetic ([5720c7d](5720c7dca3))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 12:36:00 +00:00
Thomas Hallock
5720c7dca3 refactor: redesign GameControlButtons with translucent aesthetic
Unifies control buttons with the refined translucent design system used
throughout the navigation (matching GameModeIndicator and RoomInfo).

Changes:
- Replace solid gradient backgrounds with translucent color-coded pills
- Add subtle borders matching the mode indicator style (2px solid with opacity)
- Reduce padding (4px 10px) and font size (12px) for lighter visual weight
- Color-code each button: gray (Setup), blue (New Game), orange (Quit)
- Add hover effects with gradient translucent backgrounds
- Reduce gap between buttons (6px) for tighter grouping

Result: Navigation components now share a cohesive, polished design language
with consistent translucency, borders, and color patterns.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 07:35:05 -05:00
semantic-release-bot
518a4cf870 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))
* redesign room info as compact inline badge with click-to-copy ([6b3a440](6b3a440369))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* compact room info display with join code in tooltip ([a6b1610](a6b1610993))
* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* restructure GameContextNav to 2x2 grid layout ([9c9270f](9c9270f931))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 12:33:03 +00:00
Thomas Hallock
9c9270f931 refactor: restructure GameContextNav to 2x2 grid layout
Reorganizes navigation into clearer visual hierarchy:
- Row 1: Title (left) | Mode indicator + Room info (right)
- Row 2: Control buttons (left) | Network players + Your players (right)

Changes:
- Separate fullscreen mode into early return for clarity
- Remove unused state management (isTransitioning, layoutMode, containerWidth)
- Use space-between justification for horizontal alignment
- Group network players with local players (separated from control buttons)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 07:32:15 -05:00
Thomas Hallock
6b3a440369 feat: redesign room info as compact inline badge with click-to-copy
Replace large card-style room display with compact single-line badge showing room name, join code, and player count. Add click-to-copy functionality for join code with visual feedback.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 07:32:15 -05:00
Thomas Hallock
a6b1610993 refactor: compact room info display with join code in tooltip
Replace large room info card with minimal inline badge showing just room name and player count. Move join code to a Radix UI tooltip that appears on hover, making it easy to share while keeping the nav bar compact.

Display: 🎮 Auto-generated Room (2)
Tooltip: Shows "Join Code" with code prominently displayed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 07:32:15 -05:00
semantic-release-bot
506252358d chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-11)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))
* improve arcade nav player grouping and add room join code display ([8e9980d](8e9980dc82))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-11 12:18:38 +00:00
Thomas Hallock
f9af0f169e chore: biome formatting fixes 2025-10-11 07:17:38 -05:00
Thomas Hallock
8e9980dc82 feat: improve arcade nav player grouping and add room join code display
Reorder navigation to group all player indicators together by moving game control buttons before network players, creating clearer visual separation. Add room join code display as a second line under room name for easier sharing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 07:16:32 -05:00
semantic-release-bot
4008cd75ff chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-10)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* populate session activePlayers from room members on join ([2d00939](2d00939f1b))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-10 20:19:10 +00:00
Thomas Hallock
2d00939f1b fix: populate session activePlayers from room members on join
**Problem:**
When users joined rooms, the arcade session was created with empty
activePlayers array, causing the game to start in single-player mode
even though multiple users had joined the room.

**Root Cause:**
Initial session creation in `join-arcade-session` handler set
`activePlayers: []` without fetching the actual room members' players.

**Solution:**
1. **Session Creation**: When creating initial session, fetch all room
   members' active players using `getRoomPlayerIds()` and populate
   `activePlayers` field.

2. **Dynamic Updates**: When members join room (`join-room` event) or
   toggle players (`players-updated` event), update session's
   `activePlayers` dynamically using new `updateSessionActivePlayers()`
   function.

3. **Protection**: Only update `activePlayers` if game is in 'setup'
   phase - prevents disrupting games in progress.

**Key Changes:**
- `socket-server.ts`: Import `getRoomPlayerIds`, use it to populate
  activePlayers on session creation, update session when room membership
  changes
- `session-manager.ts`: Add `updateSessionActivePlayers()` function to
  safely update activePlayers during setup phase
- Test fixes: Updated test files to match new schema (roomId PRIMARY KEY)

**Testing:**
- Session correctly populated with all room members' players on creation
- New members' players added to session when they join (if in setup)
- Player toggle updates session activePlayers in real-time
- Games in progress protected from activePlayers modifications

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 15:18:09 -05:00
semantic-release-bot
738dd9a0d1 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-10)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* prevent duplicate arcade sessions per room ([4cc3de5](4cc3de5f43))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-10 20:07:13 +00:00
Thomas Hallock
4cc3de5f43 fix: prevent duplicate arcade sessions per room
**Root Cause:**
Database schema had `user_id` as PRIMARY KEY for `arcade_sessions`,
allowing multiple sessions for the same `roomId`. When two users joined
the same room, each created their own session, causing them to see
completely different games instead of sharing one session.

**Database Changes:**
- Changed PRIMARY KEY from `user_id` to `room_id`
- Now enforces one session per room at database level
- Updated all queries to use `room_id` as primary lookup key

**Code Changes:**
- Updated `createArcadeSession()` to check for existing room session
- Added error handling for race conditions (UNIQUE constraint failures)
- Modified `applyGameMove()`, `deleteArcadeSession()`, and `updateSessionActivity()` to use `room_id`
- Cleaned up orphaned sessions and duplicates before migration

**Migration:**
- Generated migration: `drizzle/0005_jazzy_mimic.sql`
- Cleaned up 58 orphaned sessions (NULL room_id)
- Removed duplicate sessions for same room (kept highest version)
- Migration successfully applied to dev database

**Testing Required:**
- Verify two users in same room now share the same game state
- Confirm session updates broadcast correctly to all room members
- Test that PRIMARY KEY constraint prevents duplicate creation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 15:06:19 -05:00
semantic-release-bot
4cedfdd629 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-10)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* create arcade sessions on room join to enable config changes ([c29501f](c29501f666))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-10 18:53:32 +00:00
Thomas Hallock
c29501f666 fix: create arcade sessions on room join to enable config changes
Fixes "No active session found" error when adjusting game settings
before starting a game in arcade rooms.

**Problem:**
- Sessions were only created on START_GAME move
- SET_CONFIG moves require an active session in setup phase
- Users couldn't adjust settings until after starting game

**Solution:**
- Create session in setup phase when user joins room (if none exists)
- Initialize with room's game config from database
- Allows SET_CONFIG moves before game starts

**Changes:**
- socket-server.ts:72-100 - Auto-create session on join-arcade-session
- RoomMemoryPairsProvider.tsx:4 - Remove unused import
- nas-deployment/docker-compose.yaml:15 - Fix DB volume mount path

**Related:**
- Also fixes database persistence by correcting volume mount from
  ./data:/app/data to ./data:/app/apps/web/data

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 13:52:35 -05:00
semantic-release-bot
9ba00deaaa chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-10)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))

### Bug Fixes

* clear hover state in CLEAR_MISMATCH for clean turn transitions ([43f7c92](43f7c92f6d))
* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-10 18:21:29 +00:00
Thomas Hallock
43f7c92f6d fix: clear hover state in CLEAR_MISMATCH for clean turn transitions
The previous fix only cleared hover on turn switch, but the actual turn
transition happens during CLEAR_MISMATCH (when cards flip back). This
was causing stale hover avatars to persist at the start of new turns.

Changes:
- **MatchingGameValidator.ts**: Clear non-current players' hovers in
  validateClearMismatch to ensure clean state when cards are cleared
- **RoomMemoryPairsProvider.tsx**: Mirror the server logic in optimistic
  CLEAR_MISMATCH handling
- **LocalMemoryPairsProvider.tsx**: Same fix for local-only games

Now when CLEAR_MISMATCH fires (after 1.5s mismatch timeout), we clear
hover state for all players except the current player, ensuring:
- No stale hovers from previous turns
- Only active player's current hover shows
- Clean transition between turns

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 12:55:41 -05:00
semantic-release-bot
a578ce7f8b chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-10)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))

### Bug Fixes

* clear hover state on turn changes and game transitions ([6fd425c](6fd425ce85))
* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-10 17:54:04 +00:00
Thomas Hallock
6fd425ce85 fix: clear hover state on turn changes and game transitions
Clear playerHovers state in three scenarios to prevent stale hover avatars:

1. **Turn switching (mismatch)**: When a player misses a match and turn
   switches to next player, clear the previous player's hover state so
   their avatar doesn't show on the last card they hovered from their
   previous turn.

2. **Starting new game**: Clear all hover state when starting fresh game
   to ensure clean initial state.

3. **Returning to setup**: Clear all hover state when going back to setup
   phase to prevent stale hovers from previous game sessions.

This fixes the issue where players would see their opponent's (or their
own) hover avatar stuck on a card at the start of a turn, even though
no hovering has occurred yet in the current turn.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 12:53:03 -05:00
semantic-release-bot
9e414b01b6 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-10)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))

### Bug Fixes

* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* hide hover avatar for current user's own player ([dba42b5](dba42b5925))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-10 17:46:07 +00:00
Thomas Hallock
dba42b5925 fix: hide hover avatar for current user's own player
Only show hover avatars for remote players (opponents), not the current
user's own player. This prevents the distracting behavior where users
would see a floating avatar following their own mouse cursor.

The filter now checks:
- playerMetadata[playerId].userId !== viewerId (remote player check)
- playerId === state.currentPlayer (only show for active turn)

This maintains the "presence" feature for opponents while keeping the
current user's view clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 12:45:14 -05:00
semantic-release-bot
4125d8eb41 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.18.0) (2025-10-10)

### Features

* add centralized player ownership utilities ([1019a48](1019a487f8))

### Bug Fixes

* correct TypeScript build configuration to prevent .js pollution in src/ ([2b7ff23](2b7ff237cc))
* exclude dist from TypeScript compilation and add missing type import ([b7f1d5a](b7f1d5a569))
* prevent database imports from being bundled into client code ([bda5bc6](bda5bc6c0e))
* remove build artifacts from source control ([29f5adc](29f5adcfbc))
* remove rootDir from tsconfig.server.json ([431e4a6](431e4a61de))
* update Dockerfile to copy dist/ instead of socket-server.js and src/ ([e56517b](e56517b129))

### Code Refactoring

* re-export ownership utilities from player-manager ([e85f800](e85f800c3e))
* use centralized ownership in RoomMemoryPairsProvider ([6c66bb2](6c66bb27b7))
* use centralized player ownership in session-manager ([a362e5e](a362e5e34a))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))

### Tests

* add comprehensive integration tests for player ownership ([76a6390](76a63901c4))
2025-10-10 17:40:51 +00:00
Thomas Hallock
b7f1d5a569 fix: exclude dist from TypeScript compilation and add missing type import
- Add "dist" to tsconfig.server.json exclude to prevent TypeScript from
  seeing compiled .js files as input files (was causing TS5055 errors)
- Fix schema path glob: "src/db/schema.ts" -> "src/db/schema/**/*.ts"
  to properly include all schema files in the directory
- Add missing PlayerOwnershipMap type import in player-ownership.ts
  to fix TS2304 compilation error

These fixes resolve the dev server compilation issues and ensure clean
builds without dist/ pollution.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 12:39:54 -05:00
469 changed files with 82622 additions and 68845 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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.

View 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

View 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

View 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

View 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

View File

@@ -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": []

View File

@@ -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

View File

@@ -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

View File

@@ -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))
}
});
});
});
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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))
}
});
});
});
})
})
})

View File

@@ -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()
})
})
})

View File

@@ -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()
}
});
});
})
})

View File

@@ -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()
})
})

View File

@@ -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))
})
})

View File

@@ -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
View File

View 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

View 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`);

View 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;

View 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`);

View 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`);

View 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;

View 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);

View 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;

View 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');

View 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": {}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

View File

@@ -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/)
})
})

View 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 })
})
})
})

View File

@@ -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()
})
})

View File

@@ -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)
})
})

View File

@@ -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)
})
})

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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)',
},
},
},
},
},
});
})

View File

@@ -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,
},
});
})

View File

@@ -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))

View File

@@ -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}`)
})
})

View File

@@ -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()
})
})

View File

@@ -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>
);
)
}

View File

@@ -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
}

View File

@@ -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')
})
})
})

View File

@@ -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 })
}
}

View File

@@ -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
}

View 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 })
}
}

View 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 })
}
}

View 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 })
}
}

View 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 })
}
}

View 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 })
}
}

View File

@@ -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 })
}
}

View File

@@ -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 })
}
}

View File

@@ -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 })
}
}

View File

@@ -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 })
}
}

View 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 })
}
}

View File

@@ -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 })
}
}

View File

@@ -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 })
}
}

View File

@@ -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 })
}
}

View 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 })
}
}

View 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 })
}
}

View File

@@ -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 })
}
}

View 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 })
}
}

View File

@@ -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 })
}
}

View File

@@ -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 })
}
}

View File

@@ -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 })
}
}

View File

@@ -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 })
}
}

View File

@@ -12,6 +12,6 @@
* - etc.
*/
import { handlers } from "@/auth";
import { handlers } from '@/auth'
export const { GET, POST } = handlers;
export const { GET, POST } = handlers

View File

@@ -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)
}

View File

@@ -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 }
)
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 })
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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 })
}
}

View File

@@ -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)
})
})

View File

@@ -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
}

View File

@@ -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 })
}
}

View File

@@ -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

View File

@@ -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();
});
});
});

View File

@@ -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>
);
)
}

View File

@@ -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>
);
)
}

View File

@@ -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)]
}

View File

@@ -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>
);
)
}

View File

@@ -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>
);
)
}

View File

@@ -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>
);
)
}

View File

@@ -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>
);
)
}

View File

@@ -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>
);
)
}

View File

@@ -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>
);
)
}

View File

@@ -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'
}

View File

@@ -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>
);
});
)
})

View File

@@ -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>
);
)
}

View File

@@ -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>
);
)
}

View File

@@ -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'

View File

@@ -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>
);
)
}

View File

@@ -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'

View File

@@ -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>
);
)
}

View File

@@ -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