Compare commits

...

4 Commits

Author SHA1 Message Date
semantic-release-bot
1a44daf2ce chore(release): 4.10.4 [skip ci]
## [4.10.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.3...v4.10.4) (2025-10-18)

### Bug Fixes

* **card-sorting:** match Python card layout with flex wrap ([9679d68](9679d68154))
2025-10-18 21:35:53 +00:00
Thomas Hallock
9679d68154 fix(card-sorting): match Python card layout with flex wrap
Fix available cards layout to match the Python original implementation.

**Before:**
- Grid layout with responsive columns (1/2/3 cols)
- Cards stretched to fill grid cells
- Each card on its own line on mobile

**After (matching Python web_generator.py lines 3077-3087):**
- Flex container with wrap
- Fixed 90px x 90px card size
- Cards flow naturally and wrap based on container width
- Dashed border container with semi-transparent background
- Revealed numbers positioned absolutely in top-right corner
- Matching hover/selection animations from original

**Visual Changes:**
- Container: flex wrap, centered, dashed border, rgba background
- Cards: 90px x 90px fixed size
- Revealed numbers: absolute positioned badge (top-right)
- Selection state: scale(1.1), blue border, blue background
- Hover: translateY(-5px) lift effect

src/arcade-games/card-sorting/components/PlayingPhase.tsx:265-348

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 16:34:50 -05:00
semantic-release-bot
80ba94203d chore(release): 4.10.3 [skip ci]
## [4.10.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.2...v4.10.3) (2025-10-18)

### Bug Fixes

* **arcade:** remove broken query param from game URLs ([87631af](87631af678))
2025-10-18 21:15:27 +00:00
Thomas Hallock
87631af678 fix(arcade): remove broken query param from game URLs
Remove ?game= query parameter from GameSelector URLs. The /arcade/room
page doesn't use query params - it uses the room's gameName field from
the database and handles game selection through its own UI.

This was causing the URL to have a stale ?game= param that didn't match
the actual selected game, breaking testing and navigation.

**Changed:**
- GameSelector.tsx: url changed from `/arcade/room?game=${name}` to `/arcade/room`

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 16:14:14 -05:00
14 changed files with 852 additions and 508 deletions

View File

@@ -1,3 +1,17 @@
## [4.10.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.3...v4.10.4) (2025-10-18)
### Bug Fixes
* **card-sorting:** match Python card layout with flex wrap ([9679d68](https://github.com/antialias/soroban-abacus-flashcards/commit/9679d68154ac8b6a2f905ec7d17a34b39bc00237))
## [4.10.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.2...v4.10.3) (2025-10-18)
### Bug Fixes
* **arcade:** remove broken query param from game URLs ([87631af](https://github.com/antialias/soroban-abacus-flashcards/commit/87631af6788bd7b42e671374e55ec0ad8435900c))
## [4.10.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.1...v4.10.2) (2025-10-18)

View File

@@ -0,0 +1,440 @@
# Arcade Routing Architecture - Complete Overview
## 1. Current /arcade Page
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/app/arcade/page.tsx` (lines 1-129)
**Purpose:** The main arcade landing page - displays the "Champion Arena"
**Key Components:**
- `ArcadeContent()` - Renders the main arcade interface
- Uses `EnhancedChampionArena` component which contains `GameSelector`
- The `GameSelector` displays all available games as cards
- `GameSelector` includes both legacy games and registry games
**Current Flow:**
1. User navigates to `/arcade`
2. Page renders `FullscreenProvider` wrapper
3. Displays `PageWithNav` with title "🏟️ Champion Arena"
4. Content area shows `EnhancedChampionArena``GameSelector`
5. `GameSelector` renders `GameCard` components for each game
6. When user clicks a game card, `GameCard` calls `router.push(config.url)`
7. For registry games, `config.url` is `/arcade/room?game={gameName}`
8. For legacy games, URL would be direct to their page
**State Management:**
- `GameModeContext` provides player selection (emoji, name, color)
- `PageWithNav` wraps content and provides mini-nav with:
- Active player list
- Add player button
- Game mode indicator (single/battle/tournament)
- Exit session handler
## 2. Current /arcade/room Page
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/app/arcade/room/page.tsx` (lines 1-359)
**Purpose:** "Magical place" that shows either a game OR the game chooser, driven by room state
**Three States:**
### State 1: Loading
- Shows "Loading room..." message
- Waits for `useRoomData()` hook to resolve
### State 2: Game Selection UI (when `!roomData.gameName`)
- Shows large game selection buttons
- User clicks to select a game
- Calls `setRoomGame()` mutation to save selection to room
- Invokes `handleGameSelect()` which:
1. Checks if game exists in registry via `hasGame(gameType)`
2. If registry game: calls `setRoomGame({roomId, gameName: gameType})`
3. If legacy game: maps to internal name via `GAME_TYPE_TO_NAME`, then calls `setRoomGame()`
4. Game selection is persisted to the room database
### State 3: Game Display (when `roomData.gameName` is set)
- Checks game registry first via `hasGame(roomData.gameName)`
- If registry game:
- Gets game definition via `getGame(roomData.gameName)`
- Renders: `<Provider><GameComponent /></Provider>`
- Provider and GameComponent come from game registry definition
- If legacy game:
- Switch statement with TODO for individual games
- Currently only shows "Game not yet supported"
**Key Hook:**
- `useRoomData()` - Fetches current room from API and subscribes to socket updates
- Returns `roomData` with fields: `id`, `name`, `code`, `gameName`, `gameConfig`, `members`, `memberPlayers`
- Also returns `isLoading` boolean
**Navigation Flow:**
1. User navigates to `/arcade`
2. `GameCard` onClick calls `router.push('/arcade/room?game={gameName}')`
3. User arrives at `/arcade/room`
4. If NOT in a room yet: Shows error with link back to `/arcade`
5. If in room but no game selected: Shows game selection UI
6. If game selected: Loads and displays game
## 3. The "Mini App Nav" - GameContextNav Component
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/nav/GameContextNav.tsx` (lines 1-372)
**What It Is:**
The "mini app nav" is actually a sophisticated component within the `PageWithNav` wrapper that intelligently shows different UI based on context:
**Components & Props:**
- `navTitle` - Current page title (e.g., "Champion Arena", "Choose Game", "Speed Complement Race")
- `navEmoji` - Icon emoji for current page
- `gameMode` - Computed from active player count: 'none' | 'single' | 'battle' | 'tournament'
- `activePlayers` - Array of selected players
- `inactivePlayers` - Array of available but unselected players
- `shouldEmphasize` - Boolean to emphasize player selection
- `showFullscreenSelection` - Boolean to show fullscreen mode for player selection
- `roomInfo` - Optional arcade room data (roomId, roomName, gameName, playerCount, joinCode)
- `networkPlayers` - Remote players from room members
**Three Display Modes:**
### Mode 1: Fullscreen Player Selection
- When `showFullscreenSelection === true`
- Displays:
- Large title with emoji
- Game mode indicator
- Fullscreen player selection UI
- Shows all inactive players for selection
### Mode 2: Solo Mode (NOT in room)
- When `roomInfo` is undefined
- Shows:
- **Game Title Section** (left side):
- `GameTitleMenu` with game title and emoji
- Menu options: Setup, New Game, Quit
- `GameModeIndicator`
- **Player Section** (right side):
- `ActivePlayersList` - shows selected players
- `AddPlayerButton` - add more players
### Mode 3: Room Mode (IN a room)
- When `roomInfo` is defined
- Shows:
- **Hidden:** Game title section (display: none)
- **Room Info Pane** (left side):
- `RoomInfo` component with room details
- Game mode indicator with color/emoji
- Room name, player count, join code
- `NetworkPlayerIndicator` components for remote players
- **Player Section** (may be hidden):
- Shows local active players
- Add player button (for local players only)
**Key Sub-Components:**
- `GameTitleMenu` - Menu for game options (setup, new game, quit)
- `GameModeIndicator` - Shows 🎯 Solo, ⚔️ Battle, 🏆 Tournament, 👥 Select
- `RoomInfo` - Displays room metadata
- `NetworkPlayerIndicator` - Shows remote players with scores/streaks
- `ActivePlayersList` - List of selected players
- `AddPlayerButton` - Button to add more players with popover
- `FullscreenPlayerSelection` - Large player picker for fullscreen mode
- `PendingInvitations` - Banner for room invitations
**State Management:**
- Lifted from `PageWithNav` to preserve state across remounts:
- `showPopover` / `setShowPopover` - AddPlayerButton popover state
- `activeTab` / `setActiveTab` - 'add' or 'invite' tab selection
## 4. Navigation Flow
### Flow 1: Solo Player → Game Selection → Room Creation → Game Start
```
/arcade (Champion Arena)
↓ [Select players - updates GameModeContext]
↓ [Click game card - GameCard.onClick → router.push]
/arcade/room (if not in room, shows game selector)
↓ [Select game - calls setRoomGame mutation]
↓ [Room created, gameName saved to roomData]
↓ [useRoomData refetch updates roomData.gameName]
/arcade/room (now displays the game)
↓ [Game Provider and Component render]
```
### Flow 2: Multiplayer - Room Invitation
```
User A: Creates room via Champion Arena
User B: Receives invitation
User B: Joins room via /arcade/room
User B: Sees same game selection (if set) or game selector (if not set)
```
### Flow 3: Exit Game
```
/arcade/room (in-game)
↓ [Click "Quit" or "Exit Session" in GameContextNav]
↓ [onExitSession callback → router.push('/arcade')]
/arcade (back to champion arena)
↓ Player selection reset by GameModeContext
```
## 5. Game Chooser / Game Selection System
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/GameSelector.tsx` (lines 1-112)
**How It Works:**
1. `GameSelector` component gets all games from both sources:
- Legacy `GAMES_CONFIG` (currently empty)
- Registry games via `getAllGames()`
2. For each game, creates `GameCard` component with configuration including `url` field
3. Game Cards rendered in 2-column grid (responsive)
4. When card clicked:
- `GameCard` checks `activePlayerCount` against game's `maxPlayers`
- If valid: calls `router.push(config.url)` - client-side navigation via Next.js
- If invalid: blocks navigation with warning
**Two Game Systems:**
### Registry Games (NEW - Modular)
- Location: `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/arcade-games/`
- File: `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/lib/arcade/game-registry.ts`
- Examples: `complement-race`, `memory-quiz`, `matching`
- Each game has: `manifest` (metadata), `Provider` (context), `GameComponent` (UI)
- Games registered globally via `registerGame()` function
### Legacy Games (OLD)
- Location: Directly in `/app/arcade/` directory
- Examples: `/app/arcade/complement-race/page.tsx`
- Currently, only complement-race is partially migrated
- Direct URL structure: `/arcade/{gameName}/page.tsx`
**Game Config Structure (for display):**
```javascript
{
name: string, // Display name
fullName?: string, // Longer name for detailed view
description: string, // Short description
longDescription?: string, // Detailed description
icon: emoji, // Game icon emoji
gradient: css gradient, // Background gradient
borderColor: css color, // Border color for availability
maxPlayers: number, // Player limit for validation
chips?: string[], // Feature labels
color?: 'green'|'purple'|'blue', // Color theme
difficulty?: string, // Difficulty level
available: boolean, // Is game available
}
```
## 6. Key Components Summary
### PageWithNav - Main Layout Wrapper
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/PageWithNav.tsx` (lines 1-192)
**Responsibilities:**
- Wraps all game/arcade pages
- Manages GameContextNav state (mini-nav)
- Handles player configuration dialog
- Shows moderation notifications
- Renders top navigation bar via `AppNavBar`
**Key Props:**
- `navTitle` - Passed to GameContextNav
- `navEmoji` - Passed to GameContextNav
- `gameName` - Internal game name for API
- `emphasizePlayerSelection` - Highlight player controls
- `onExitSession` - Callback when user exits
- `onSetup`, `onNewGame` - Game-specific callbacks
- `children` - Page content
### AppNavBar - Top Navigation Bar
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/AppNavBar.tsx` (lines 1-625)
**Variants:**
- `full` - Standard navigation (default for non-game pages)
- `minimal` - Game navigation (auto-selected for `/arcade` and `/games`)
**Minimal Nav Features:**
- Hamburger menu (left) with:
- Site navigation (Home, Create, Guide, Games)
- Controls (Fullscreen, Exit Arcade)
- Abacus style dropdown
- Centered game context (navSlot)
- Fullscreen indicator badge
### EnhancedChampionArena - Main Arcade Display
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/EnhancedChampionArena.tsx` (lines 1-40)
**Responsibilities:**
- Container for game selector
- Full-height flex layout
- Passes configuration to `GameSelector`
### GameSelector - Game Grid
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/GameSelector.tsx` (lines 1-112)
**Responsibilities:**
- Fetches all games from registry
- Arranges in responsive grid
- Shows header "🎮 Available Games"
- Renders GameCard for each game
### GameCard - Individual Game Button
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/GameCard.tsx` (lines 1-241)
**Responsibilities:**
- Displays game with icon, name, description
- Shows feature chips and player count indicator
- Validates player count against game requirements
- Handles click to navigate to game
- Two variants: compact and detailed
## 7. State Management
### GameModeContext
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/contexts/GameModeContext.tsx` (lines 1-325)
**Manages:**
- Local players (Map<string, Player>)
- Active players (Set<string>)
- Game mode (computed from active player count)
- Player CRUD operations (add, update, remove)
**Key Features:**
- Fetches players from user's local DB via `useUserPlayers()`
- Creates 4 default players if none exist
- When in room: merges room members' players (marked as isLocal: false)
- Syncs to room members via `notifyRoomOfPlayerUpdate()`
**Computed Values:**
- `activePlayerCount` - Size of activePlayers set
- `gameMode`:
- 1 player → 'single'
- 2 players → 'battle'
- 3+ players → 'tournament'
### useRoomData Hook
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/hooks/useRoomData.ts` (lines 1-450+)
**Manages:**
- Current room fetching via TanStack Query
- Socket.io real-time updates
- Room state (members, players, game name)
- Moderation events (kicked, banned, invitations)
**Key Operations:**
- `fetchCurrentRoom()` - GET `/api/arcade/rooms/current`
- `createRoomApi()` - POST `/api/arcade/rooms`
- `joinRoomApi()` - POST `/api/arcade/rooms/{id}/join`
- `leaveRoomApi()` - POST `/api/arcade/rooms/{id}/leave`
- `setRoomGame()` - Updates room's gameName and gameConfig
**Socket Events:**
- `join-user-channel` - Personal notifications
- `join-room` - Subscribe to room updates
- `room-joined` - Refresh when entering room
- `member-joined` - When player joins
- `member-left` - When player leaves
- `room-players-updated` - When players change
- Moderation events (kicked, banned, etc.)
## 8. Routing Summary
**Current URL Structure:**
```
/ → Home page (Soroban Generator)
/create → Create flashcards
/guide → Tutorial guide
/games → Games library (external game pages)
/arcade → Champion Arena (main landing with game selector)
/arcade/room → Active game display or game selection UI
/arcade/room?game={name} → Query param for game selection (optional)
/arcade/complement-race → OLD: Direct complement-race page (legacy)
/arcade/complement-race/practice → Complement-race practice mode
/arcade/complement-race/sprint → Complement-race sprint mode
/arcade/complement-race/survival → Complement-race survival mode
/arcade/memory-quiz → Memory quiz game page (legacy structure)
```
**Query Parameters:**
- `/arcade/room?game={gameName}` - Optional game selection (parsed by GameCard)
## 9. Key Differences: /arcade vs /arcade/room
| Aspect | /arcade | /arcade/room |
|--------|---------|--------------|
| **Purpose** | Game selection hub | Active game display or selection within room |
| **Displays** | GameSelector with all games | Selected game OR game selector if no game in room |
| **Room Context** | Optional (can start solo) | Usually in a room (fetches via useRoomData) |
| **Navigation** | Click game → /arcade/room | Click game → Saves to room → Displays game |
| **GameContextNav** | Shows player selector | Shows room info when joined |
| **Player State** | Local only | Local + remote (room members) |
| **Exit Button** | Usually hidden | Shows "Exit Session" to return to /arcade |
| **Socket Connection** | Optional | Always connected (in room) |
| **Page Transition** | User controls | Driven by room state updates |
## 10. Planning the Merge (/arcade/room → /arcade)
**Challenges to Consider:**
1. **URL Consolidation:**
- `/arcade/room` would become a sub-path or handled by `/arcade` with state
- Query param `?game={name}` could drive game selection
- Current: `/arcade/room?game=complement-race`
- Could become: `/arcade?game=complement-race&mode=play`
2. **Route Disambiguation:**
- `/arcade` needs to handle: game selection display, game display, game loading
- Same page different modes based on state
- Or: Sub-routes like `/arcade/select`, `/arcade/play`
3. **State Layering:**
- Local game mode (solo player, GameModeContext)
- Room state (multiplayer, useRoomData)
- Both need to coexist
4. **Navigation Preservation:**
- Currently: `GameCard``router.push('/arcade/room?game=X')`
- After merge: Would need new logic
- Fullscreen state must persist (uses Next.js router, not reload)
5. **PageWithNav Behavior:**
- Mini-nav shows game selection UI vs room info
- Currently determined by `roomInfo` presence
- After merge: Need same logic but one route
6. **Game Display:**
- Currently: `/arcade/room` fetches game from registry
- New: `/arcade` would need same game registry lookup
- Game Provider/Component rendering must work identically
**Merge Strategy Options:**
### Option A: Single Route with Modes
```
/arcade
├── Mode: browse (default, show GameSelector)
├── Mode: select (game selected, show GameSelector for confirmation)
└── Mode: play (in-game, show game display)
```
### Option B: Sub-routes
```
/arcade
├── /arcade (selector)
├── /arcade/play (game display)
└── /arcade/configure (player config)
```
### Option C: Query-Parameter Driven
```
/arcade
├── /arcade (default - selector)
├── /arcade?game=X (game loading)
└── /arcade?game=X&playing=true (in-game)
```
**Recommendation:** Option C (Query-driven) is closest to current architecture and requires minimal changes to existing logic.

View File

@@ -158,7 +158,7 @@ export default function RoomDetailPage() {
const startGame = () => {
if (!room) return
// Navigate to the room game page
router.push('/arcade/room')
router.push('/arcade')
}
const joinRoom = async () => {

View File

@@ -1,128 +1,357 @@
'use client'
import { useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
import { GAMES_CONFIG } from '@/components/GameSelector'
import type { GameType } from '@/components/GameSelector'
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../styled-system/css'
import { EnhancedChampionArena } from '../../components/EnhancedChampionArena'
import { FullscreenProvider, useFullscreen } from '../../contexts/FullscreenContext'
import { css } from '../../../../styled-system/css'
import { getAllGames, getGame, hasGame } from '@/lib/arcade/game-registry'
function ArcadeContent() {
const { setFullscreenElement } = useFullscreen()
const arcadeRef = useRef<HTMLDivElement>(null)
// Map GameType keys to internal game names
// Note: "battle-arena" removed - now handled by game registry as "matching"
const GAME_TYPE_TO_NAME: Record<GameType, string> = {
'complement-race': 'complement-race',
'master-organizer': 'master-organizer',
}
useEffect(() => {
// Register this component's main div as the fullscreen element
if (arcadeRef.current) {
setFullscreenElement(arcadeRef.current)
}
}, [setFullscreenElement])
/**
* /arcade - Renders the game for the user's current room
* Since users can only be in one room at a time, this is a simple singular route
*
* Shows game selection when no game is set, then shows the game itself once selected.
* URL never changes - it's always /arcade regardless of selection, setup, or gameplay.
*
* Note: We show a friendly message with a link if no room exists to avoid navigation loops.
*
* Note: ModerationNotifications is handled by PageWithNav inside each game component,
* so we don't need to render it here.
*/
export default function RoomPage() {
const router = useRouter()
const { roomData, isLoading } = useRoomData()
const { mutate: setRoomGame } = useSetRoomGame()
return (
<div
ref={arcadeRef}
className={css({
minHeight: 'calc(100vh - 80px)', // Account for mini nav height
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
position: 'relative',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
py: { base: '4', md: '6' },
})}
>
{/* Animated background elements */}
// Show loading state
if (isLoading) {
return (
<div
className={css({
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: `
radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.3) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(255, 119, 198, 0.3) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(120, 219, 255, 0.2) 0%, transparent 50%)
`,
animation: 'arcadeFloat 20s ease-in-out infinite',
})}
/>
{/* Main Champion Arena - takes remaining space */}
<div
className={css({
flex: 1,
style={{
display: 'flex',
px: { base: '2', md: '4' },
position: 'relative',
zIndex: 1,
minHeight: 0, // Important for flex children
})}
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
<EnhancedChampionArena
onConfigurePlayer={() => {}}
Loading room...
</div>
)
}
// Show error if no room (instead of redirecting)
if (!roomData) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
gap: '1rem',
}}
>
<div>No active room found</div>
<a
href="/arcade"
style={{
color: '#3b82f6',
textDecoration: 'underline',
}}
>
Go to Champion Arena
</a>
</div>
)
}
// Show game selection if no game is set
if (!roomData.gameName) {
const handleGameSelect = (gameType: GameType) => {
console.log('[RoomPage] handleGameSelect called with gameType:', gameType)
// Check if it's a registry game first
if (hasGame(gameType)) {
const gameDef = getGame(gameType)
if (!gameDef?.manifest.available) {
console.log('[RoomPage] Registry game not available, blocking selection')
return
}
console.log('[RoomPage] Selecting registry game:', gameType)
setRoomGame({
roomId: roomData.id,
gameName: gameType, // Use the game name directly for registry games
})
return
}
// Legacy game handling
const gameConfig = GAMES_CONFIG[gameType as keyof typeof GAMES_CONFIG]
if (!gameConfig) {
console.log('[RoomPage] Unknown game type:', gameType)
return
}
console.log('[RoomPage] Game config:', {
name: gameConfig.name,
available: 'available' in gameConfig ? gameConfig.available : true,
})
if ('available' in gameConfig && gameConfig.available === false) {
console.log('[RoomPage] Game not available, blocking selection')
return // Don't allow selecting unavailable games
}
// Map GameType to internal game name
const internalGameName = GAME_TYPE_TO_NAME[gameType]
console.log('[RoomPage] Mapping:', {
gameType,
internalGameName,
mappingExists: !!internalGameName,
})
console.log('[RoomPage] Calling setRoomGame with:', {
roomId: roomData.id,
gameName: internalGameName,
preservingGameConfig: true,
})
// Don't pass gameConfig - we want to preserve existing settings for all games
setRoomGame({
roomId: roomData.id,
gameName: internalGameName,
})
}
return (
<PageWithNav
navTitle="Choose Game"
navEmoji="🎮"
emphasizePlayerSelection={true}
onExitSession={() => router.push('/arcade')}
>
<div
className={css({
width: '100%',
height: '100%',
background: 'rgba(255, 255, 255, 0.05)',
backdropFilter: 'blur(20px)',
border: '1px solid rgba(255, 255, 255, 0.1)',
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.3)',
minHeight: '100vh',
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '4',
})}
/>
</div>
</div>
)
}
>
<h1
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
color: 'white',
mb: '8',
textAlign: 'center',
})}
>
Choose a Game
</h1>
function ArcadePageWithRedirect() {
return (
<PageWithNav navTitle="Champion Arena" navEmoji="🏟️" emphasizePlayerSelection={true}>
<ArcadeContent />
</PageWithNav>
)
}
<div
className={css({
display: 'grid',
gridTemplateColumns: { base: '1fr', md: 'repeat(2, 1fr)' },
gap: '4',
maxWidth: '800px',
width: '100%',
})}
>
{/* Legacy games */}
{Object.entries(GAMES_CONFIG).map(([gameType, config]) => {
const isAvailable = !('available' in config) || config.available !== false
return (
<button
key={gameType}
onClick={() => handleGameSelect(gameType as GameType)}
disabled={!isAvailable}
className={css({
background: config.gradient,
border: '2px solid',
borderColor: config.borderColor || 'blue.200',
borderRadius: '2xl',
padding: '6',
cursor: !isAvailable ? 'not-allowed' : 'pointer',
opacity: !isAvailable ? 0.5 : 1,
transition: 'all 0.3s ease',
_hover: !isAvailable
? {}
: {
transform: 'translateY(-4px) scale(1.02)',
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
},
})}
>
<div
className={css({
fontSize: '4xl',
mb: '2',
})}
>
{config.icon}
</div>
<h3
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: 'gray.900',
mb: '2',
})}
>
{config.name}
</h3>
<p
className={css({
fontSize: 'sm',
color: 'gray.600',
})}
>
{config.description}
</p>
</button>
)
})}
export default function ArcadePage() {
return (
<FullscreenProvider>
<ArcadePageWithRedirect />
</FullscreenProvider>
)
}
// Arcade-specific animations
const arcadeAnimations = `
@keyframes arcadeFloat {
0%, 100% {
transform: translateY(0px) rotate(0deg);
opacity: 0.7;
{/* Registry games */}
{getAllGames().map((gameDef) => {
const isAvailable = gameDef.manifest.available
return (
<button
key={gameDef.manifest.name}
onClick={() => handleGameSelect(gameDef.manifest.name)}
disabled={!isAvailable}
className={css({
background: gameDef.manifest.gradient,
border: '2px solid',
borderColor: gameDef.manifest.borderColor,
borderRadius: '2xl',
padding: '6',
cursor: !isAvailable ? 'not-allowed' : 'pointer',
opacity: !isAvailable ? 0.5 : 1,
transition: 'all 0.3s ease',
_hover: !isAvailable
? {}
: {
transform: 'translateY(-4px) scale(1.02)',
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
},
})}
>
<div
className={css({
fontSize: '4xl',
mb: '2',
})}
>
{gameDef.manifest.icon}
</div>
<h3
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: 'gray.900',
mb: '2',
})}
>
{gameDef.manifest.displayName}
</h3>
<p
className={css({
fontSize: 'sm',
color: 'gray.600',
})}
>
{gameDef.manifest.description}
</p>
</button>
)
})}
</div>
</div>
</PageWithNav>
)
}
33% {
transform: translateY(-20px) rotate(1deg);
opacity: 1;
// Check if this is a registry game first
if (hasGame(roomData.gameName)) {
const gameDef = getGame(roomData.gameName)
if (!gameDef) {
return (
<PageWithNav
navTitle="Game Not Found"
navEmoji="⚠️"
emphasizePlayerSelection={true}
onExitSession={() => router.push('/arcade')}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Game "{roomData.gameName}" not found in registry
</div>
</PageWithNav>
)
}
// Render registry game dynamically
const { Provider, GameComponent } = gameDef
return (
<Provider>
<GameComponent />
</Provider>
)
}
66% {
transform: translateY(-10px) rotate(-1deg);
opacity: 0.8;
// Render legacy games based on room's gameName
switch (roomData.gameName) {
// TODO: Add other legacy games (complement-race, etc.) once migrated
default:
return (
<PageWithNav
navTitle="Game Not Available"
navEmoji="⚠️"
emphasizePlayerSelection={true}
onExitSession={() => router.push('/arcade')}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Game "{roomData.gameName}" not yet supported
</div>
</PageWithNav>
)
}
}
@keyframes arcadePulse {
0%, 100% {
box-shadow: 0 0 20px rgba(96, 165, 250, 0.3);
}
50% {
box-shadow: 0 0 40px rgba(96, 165, 250, 0.6);
}
}
`
// Inject arcade animations
if (typeof document !== 'undefined' && !document.getElementById('arcade-animations')) {
const style = document.createElement('style')
style.id = 'arcade-animations'
style.textContent = arcadeAnimations
document.head.appendChild(style)
}

View File

@@ -1,358 +0,0 @@
'use client'
import { useRouter } from 'next/navigation'
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
import { GAMES_CONFIG } from '@/components/GameSelector'
import type { GameType } from '@/components/GameSelector'
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../../styled-system/css'
import { getAllGames, getGame, hasGame } from '@/lib/arcade/game-registry'
// Map GameType keys to internal game names
// Note: "battle-arena" removed - now handled by game registry as "matching"
const GAME_TYPE_TO_NAME: Record<GameType, string> = {
'complement-race': 'complement-race',
'master-organizer': 'master-organizer',
}
/**
* /arcade/room - Renders the game for the user's current room
* Since users can only be in one room at a time, this is a simple singular route
*
* Shows game selection when no game is set, then shows the game itself once selected.
* URL never changes - it's always /arcade/room regardless of selection, setup, or gameplay.
*
* Note: We don't redirect to /arcade if no room exists to avoid navigation loops.
* Instead, we show a friendly message with a link back to the Champion Arena.
*
* Note: ModerationNotifications is handled by PageWithNav inside each game component,
* so we don't need to render it here.
*/
export default function RoomPage() {
const router = useRouter()
const { roomData, isLoading } = useRoomData()
const { mutate: setRoomGame } = useSetRoomGame()
// Show loading state
if (isLoading) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Loading room...
</div>
)
}
// Show error if no room (instead of redirecting)
if (!roomData) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
gap: '1rem',
}}
>
<div>No active room found</div>
<a
href="/arcade"
style={{
color: '#3b82f6',
textDecoration: 'underline',
}}
>
Go to Champion Arena
</a>
</div>
)
}
// Show game selection if no game is set
if (!roomData.gameName) {
const handleGameSelect = (gameType: GameType) => {
console.log('[RoomPage] handleGameSelect called with gameType:', gameType)
// Check if it's a registry game first
if (hasGame(gameType)) {
const gameDef = getGame(gameType)
if (!gameDef?.manifest.available) {
console.log('[RoomPage] Registry game not available, blocking selection')
return
}
console.log('[RoomPage] Selecting registry game:', gameType)
setRoomGame({
roomId: roomData.id,
gameName: gameType, // Use the game name directly for registry games
})
return
}
// Legacy game handling
const gameConfig = GAMES_CONFIG[gameType as keyof typeof GAMES_CONFIG]
if (!gameConfig) {
console.log('[RoomPage] Unknown game type:', gameType)
return
}
console.log('[RoomPage] Game config:', {
name: gameConfig.name,
available: 'available' in gameConfig ? gameConfig.available : true,
})
if ('available' in gameConfig && gameConfig.available === false) {
console.log('[RoomPage] Game not available, blocking selection')
return // Don't allow selecting unavailable games
}
// Map GameType to internal game name
const internalGameName = GAME_TYPE_TO_NAME[gameType]
console.log('[RoomPage] Mapping:', {
gameType,
internalGameName,
mappingExists: !!internalGameName,
})
console.log('[RoomPage] Calling setRoomGame with:', {
roomId: roomData.id,
gameName: internalGameName,
preservingGameConfig: true,
})
// Don't pass gameConfig - we want to preserve existing settings for all games
setRoomGame({
roomId: roomData.id,
gameName: internalGameName,
})
}
return (
<PageWithNav
navTitle="Choose Game"
navEmoji="🎮"
emphasizePlayerSelection={true}
onExitSession={() => router.push('/arcade')}
>
<div
className={css({
minHeight: '100vh',
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '4',
})}
>
<h1
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
color: 'white',
mb: '8',
textAlign: 'center',
})}
>
Choose a Game
</h1>
<div
className={css({
display: 'grid',
gridTemplateColumns: { base: '1fr', md: 'repeat(2, 1fr)' },
gap: '4',
maxWidth: '800px',
width: '100%',
})}
>
{/* Legacy games */}
{Object.entries(GAMES_CONFIG).map(([gameType, config]) => {
const isAvailable = !('available' in config) || config.available !== false
return (
<button
key={gameType}
onClick={() => handleGameSelect(gameType as GameType)}
disabled={!isAvailable}
className={css({
background: config.gradient,
border: '2px solid',
borderColor: config.borderColor || 'blue.200',
borderRadius: '2xl',
padding: '6',
cursor: !isAvailable ? 'not-allowed' : 'pointer',
opacity: !isAvailable ? 0.5 : 1,
transition: 'all 0.3s ease',
_hover: !isAvailable
? {}
: {
transform: 'translateY(-4px) scale(1.02)',
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
},
})}
>
<div
className={css({
fontSize: '4xl',
mb: '2',
})}
>
{config.icon}
</div>
<h3
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: 'gray.900',
mb: '2',
})}
>
{config.name}
</h3>
<p
className={css({
fontSize: 'sm',
color: 'gray.600',
})}
>
{config.description}
</p>
</button>
)
})}
{/* Registry games */}
{getAllGames().map((gameDef) => {
const isAvailable = gameDef.manifest.available
return (
<button
key={gameDef.manifest.name}
onClick={() => handleGameSelect(gameDef.manifest.name)}
disabled={!isAvailable}
className={css({
background: gameDef.manifest.gradient,
border: '2px solid',
borderColor: gameDef.manifest.borderColor,
borderRadius: '2xl',
padding: '6',
cursor: !isAvailable ? 'not-allowed' : 'pointer',
opacity: !isAvailable ? 0.5 : 1,
transition: 'all 0.3s ease',
_hover: !isAvailable
? {}
: {
transform: 'translateY(-4px) scale(1.02)',
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
},
})}
>
<div
className={css({
fontSize: '4xl',
mb: '2',
})}
>
{gameDef.manifest.icon}
</div>
<h3
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: 'gray.900',
mb: '2',
})}
>
{gameDef.manifest.displayName}
</h3>
<p
className={css({
fontSize: 'sm',
color: 'gray.600',
})}
>
{gameDef.manifest.description}
</p>
</button>
)
})}
</div>
</div>
</PageWithNav>
)
}
// Check if this is a registry game first
if (hasGame(roomData.gameName)) {
const gameDef = getGame(roomData.gameName)
if (!gameDef) {
return (
<PageWithNav
navTitle="Game Not Found"
navEmoji="⚠️"
emphasizePlayerSelection={true}
onExitSession={() => router.push('/arcade')}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Game "{roomData.gameName}" not found in registry
</div>
</PageWithNav>
)
}
// Render registry game dynamically
const { Provider, GameComponent } = gameDef
return (
<Provider>
<GameComponent />
</Provider>
)
}
// Render legacy games based on room's gameName
switch (roomData.gameName) {
// TODO: Add other legacy games (complement-race, etc.) once migrated
default:
return (
<PageWithNav
navTitle="Game Not Available"
navEmoji="⚠️"
emphasizePlayerSelection={true}
onExitSession={() => router.push('/arcade')}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Game "{roomData.gameName}" not yet supported
</div>
</PageWithNav>
)
}
}

View File

@@ -231,7 +231,7 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
password: roomPassword,
})
// Navigate to the game
router.push('/arcade/room')
router.push('/arcade')
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to join room')
setIsJoining(false)
@@ -261,7 +261,7 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
// If user is already in this exact room, just navigate to game
if (roomData && roomData.id === room.id) {
router.push('/arcade/room')
router.push('/arcade')
return
}
@@ -313,7 +313,7 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
}
const handleCancel = () => {
router.push('/arcade/room') // Stay in current room
router.push('/arcade') // Stay in current room
}
const handlePasswordSubmit = () => {

View File

@@ -1,13 +1,6 @@
'use client'
import {
type ReactNode,
useCallback,
useMemo,
createContext,
useContext,
useState,
} from 'react'
import { type ReactNode, useCallback, useMemo, createContext, useContext, useState } from 'react'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'

View File

@@ -264,13 +264,15 @@ export function PlayingPhase() {
</h3>
<div
className={css({
display: 'grid',
gridTemplateColumns: {
base: '1',
sm: '2',
md: '3',
},
gap: '0.75rem',
display: 'flex',
flexWrap: 'wrap',
gap: '10px',
justifyContent: 'center',
padding: '15px',
background: 'rgba(255,255,255,0.5)',
borderRadius: '8px',
minHeight: '120px',
border: '2px dashed #2c5f76',
})}
>
{state.availableCards.map((card) => (
@@ -278,27 +280,47 @@ export function PlayingPhase() {
key={card.id}
onClick={() => handleCardClick(card.id)}
className={css({
padding: '0.5rem',
width: '90px',
height: '90px',
padding: '8px',
border: '2px solid',
borderColor: selectedCardId === card.id ? 'blue.500' : 'gray.300',
borderRadius: '0.5rem',
background: selectedCardId === card.id ? 'blue.50' : 'white',
borderColor: selectedCardId === card.id ? '#1976d2' : 'transparent',
borderRadius: '8px',
background: selectedCardId === card.id ? '#e3f2fd' : 'white',
cursor: 'pointer',
transition: 'all 0.2s',
transform: selectedCardId === card.id ? 'scale(1.05)' : 'scale(1)',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
position: 'relative',
userSelect: 'none',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 6px rgba(0,0,0,0.1)',
_hover: {
transform: 'scale(1.05)',
borderColor: 'blue.500',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
transform: 'translateY(-5px)',
boxShadow: '0 8px 16px rgba(0,0,0,0.2)',
borderColor: '#2c5f76',
},
})}
style={
selectedCardId === card.id
? {
transform: 'scale(1.1)',
boxShadow: '0 6px 20px rgba(25, 118, 210, 0.3)',
}
: undefined
}
>
<div
dangerouslySetInnerHTML={{ __html: card.svgContent }}
className={css({
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'& svg': {
width: '100%',
maxWidth: '100%',
height: 'auto',
},
})}
@@ -306,11 +328,15 @@ export function PlayingPhase() {
{state.numbersRevealed && (
<div
className={css({
textAlign: 'center',
marginTop: '0.5rem',
fontSize: 'lg',
position: 'absolute',
top: '5px',
right: '5px',
background: '#ffc107',
color: '#333',
borderRadius: '4px',
padding: '2px 8px',
fontSize: '14px',
fontWeight: 'bold',
color: 'gray.700',
})}
>
{card.number}

View File

@@ -31,7 +31,7 @@ function getAllGameConfigs() {
maxPlayers: gameDef.manifest.maxPlayers,
description: gameDef.manifest.description,
longDescription: gameDef.manifest.longDescription,
url: `/arcade/room?game=${gameDef.manifest.name}`, // Registry games load in room
url: '/arcade', // Arcade page handles game selection through UI
icon: gameDef.manifest.icon,
chips: gameDef.manifest.chips,
color: gameDef.manifest.color,

View File

@@ -80,7 +80,7 @@ export function AddPlayerButton({
})
// Close popover and navigate to room to choose game
setShowPopover(false)
router.push('/arcade/room')
router.push('/arcade')
},
onError: (error) => {
console.error('Failed to create room:', error)
@@ -111,7 +111,7 @@ export function AddPlayerButton({
}
// Close popover and navigate to room
setShowPopover(false)
router.push('/arcade/room')
router.push('/arcade')
},
}
)

View File

@@ -833,7 +833,7 @@ export function ModerationNotifications({
// Close the modal
onClose()
// Navigate to the room
router.push('/arcade/room')
router.push('/arcade')
} catch (error) {
console.error('Failed to join room:', error)
showError(

View File

@@ -68,7 +68,7 @@ export function PendingInvitations({ onInvitationChange, currentRoomId }: Pendin
// Join the room
await joinRoom({ roomId: invitation.roomId })
// Navigate to the room
router.push('/arcade/room')
router.push('/arcade')
// Refresh invitations
await fetchInvitations()
onInvitationChange?.()

View File

@@ -81,7 +81,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
session = await createArcadeSession({
userId,
gameName: room.gameName as GameName,
gameUrl: '/arcade/room',
gameUrl: '/arcade',
initialState,
activePlayers: roomPlayerIds, // Include all room members' active players
roomId: room.id,
@@ -173,7 +173,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
await createArcadeSession({
userId: data.userId,
gameName: 'matching',
gameUrl: '/arcade/room', // Room-based sessions use /arcade/room
gameUrl: '/arcade', // Room-based sessions use /arcade
initialState,
activePlayers,
roomId: room.id,

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "4.10.2",
"version": "4.10.4",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [