feat: add comprehensive error handling for arcade games
Add user-facing error notifications and boundaries to prevent silent failures in arcade games. **Problem:** - Errors only logged to console (e.g., "Failed to fetch session") - Users saw nothing when errors occurred - Buttons stopped working with no feedback - No way to recover from errors **Solution: 4-Part Error Handling System** 1. **ErrorToast Component** - User-facing error notifications - Prominent red toast in bottom-right corner - Auto-dismisses after 10 seconds - Collapsible technical details - Mobile-responsive 2. **ArcadeErrorBoundary** - React error boundary - Catches component render errors - Shows user-friendly fallback UI - Provides "Try Again" and "Return to Lobby" buttons - Collapsible stack trace for debugging 3. **ArcadeErrorContext** - Global error management - Manages error state across the app - Renders error toasts - Auto-cleans up old errors 4. **Enhanced useArcadeSocket** - Automatic socket error handling - Connection errors: "Failed to connect to server" - Disconnections: "Connection lost, attempting to reconnect" - Session errors: "Failed to load/update session" - Move rejections: "Your move was not accepted" - No active session: "No game session found" - Can suppress toasts with `suppressErrorToasts: true` **Files Created:** - src/components/ErrorToast.tsx - src/components/ArcadeErrorBoundary.tsx - src/contexts/ArcadeErrorContext.tsx - .claude/ERROR_HANDLING.md (integration guide) **Files Modified:** - src/hooks/useArcadeSocket.ts (automatic error toasts) **Next Steps (TODO):** - Wrap arcade game pages with error providers - Test all error scenarios - Add error recovery strategies 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
07c25a2296
commit
e8c52561a2
|
|
@ -0,0 +1,188 @@
|
|||
# Arcade Error Handling System
|
||||
|
||||
## Overview
|
||||
|
||||
Comprehensive error handling system for arcade games to ensure users always see meaningful error messages instead of silent failures.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. ErrorToast (`src/components/ErrorToast.tsx`)
|
||||
|
||||
User-facing error notification component:
|
||||
- Prominent red toast in bottom-right corner
|
||||
- Auto-dismisses after 10 seconds
|
||||
- Collapsible technical details
|
||||
- Mobile-responsive
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
<ErrorToast
|
||||
message="Game session error"
|
||||
details="Error: Failed to fetch session..."
|
||||
onDismiss={() => clearError(errorId)}
|
||||
/>
|
||||
```
|
||||
|
||||
### 2. ArcadeErrorBoundary (`src/components/ArcadeErrorBoundary.tsx`)
|
||||
|
||||
React error boundary for catching React errors:
|
||||
- Catches component render errors
|
||||
- Shows user-friendly fallback UI
|
||||
- Provides "Try Again" and "Return to Lobby" buttons
|
||||
- Collapsible stack trace for debugging
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
<ArcadeErrorBoundary>
|
||||
<GameComponent />
|
||||
</ArcadeErrorBoundary>
|
||||
```
|
||||
|
||||
### 3. ArcadeErrorContext (`src/contexts/ArcadeErrorContext.tsx`)
|
||||
|
||||
Global error management context:
|
||||
- Manages error state across the app
|
||||
- Renders error toasts
|
||||
- Auto-cleans up old errors
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
// Wrap your app/page
|
||||
<ArcadeErrorProvider>
|
||||
{children}
|
||||
</ArcadeErrorProvider>
|
||||
|
||||
// Use in components
|
||||
const { addError, clearError } = useArcadeError()
|
||||
addError('Something went wrong', 'Technical details...')
|
||||
```
|
||||
|
||||
### 4. Enhanced useArcadeSocket Hook
|
||||
|
||||
Socket hook now automatically shows error toasts for:
|
||||
- **Connection errors**: Failed to connect to server
|
||||
- **Disconnections**: Connection lost
|
||||
- **Session errors**: Failed to load/update session
|
||||
- **Move rejections**: Invalid moves (non-version-conflict)
|
||||
- **No active session**: Session not found
|
||||
|
||||
Can suppress toasts with `suppressErrorToasts: true` option.
|
||||
|
||||
## Error Categories
|
||||
|
||||
### Network/Connection Errors
|
||||
- **Connection error**: Failed to connect to game server
|
||||
- **Disconnection**: Connection lost, attempting to reconnect
|
||||
|
||||
### Session Errors
|
||||
- **Session error**: Failed to load or update game session
|
||||
- **No active session**: No game session found
|
||||
|
||||
### Game State Errors
|
||||
- **Move rejected**: Invalid move submitted
|
||||
- **Version conflict**: Concurrent update detected (silent, not shown to user)
|
||||
|
||||
### React Errors
|
||||
- **Component errors**: Caught by ErrorBoundary, shows fallback UI
|
||||
|
||||
## Integration Guide
|
||||
|
||||
### For New Arcade Games
|
||||
|
||||
1. **Wrap your game page with error providers:**
|
||||
```typescript
|
||||
// src/app/arcade/your-game/page.tsx
|
||||
import { ArcadeErrorProvider } from '@/contexts/ArcadeErrorContext'
|
||||
import { ArcadeErrorBoundary } from '@/components/ArcadeErrorBoundary'
|
||||
|
||||
export default function YourGamePage() {
|
||||
return (
|
||||
<ArcadeErrorProvider>
|
||||
<ArcadeErrorBoundary>
|
||||
<YourGameProvider>
|
||||
<YourGameComponent />
|
||||
</YourGameProvider>
|
||||
</ArcadeErrorBoundary>
|
||||
</ArcadeErrorProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
2. **Use error context in your components:**
|
||||
```typescript
|
||||
import { useArcadeError } from '@/contexts/ArcadeErrorContext'
|
||||
|
||||
function YourComponent() {
|
||||
const { addError } = useArcadeError()
|
||||
|
||||
try {
|
||||
// Your code
|
||||
} catch (error) {
|
||||
addError(
|
||||
'User-friendly message',
|
||||
`Technical details: ${error.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Socket hook is automatic:**
|
||||
The `useArcadeSocket` hook already shows errors by default. No changes needed unless you want to suppress them.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### DO:
|
||||
- ✅ Use `addError()` for runtime errors
|
||||
- ✅ Provide user-friendly primary messages
|
||||
- ✅ Include technical details in the `details` parameter
|
||||
- ✅ Wrap arcade pages with both ErrorProvider and ErrorBoundary
|
||||
- ✅ Let socket errors show automatically (they're handled)
|
||||
|
||||
### DON'T:
|
||||
- ❌ Don't just log errors to console
|
||||
- ❌ Don't show raw error messages to users
|
||||
- ❌ Don't swallow errors silently
|
||||
- ❌ Don't use `alert()` for errors
|
||||
- ❌ Don't forget to wrap new arcade games
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Wrap all arcade game pages with error providers
|
||||
- [ ] Add error recovery strategies (retry buttons)
|
||||
- [ ] Add error reporting/telemetry
|
||||
- [ ] Test all error scenarios
|
||||
- [ ] Document error codes/types
|
||||
|
||||
## Testing Errors
|
||||
|
||||
To test error handling:
|
||||
|
||||
1. **Connection errors**: Disconnect network, try to join game
|
||||
2. **Session errors**: Use invalid room ID
|
||||
3. **Move rejection**: Submit invalid move
|
||||
4. **React errors**: Throw error in component render
|
||||
|
||||
## Example: Know Your World
|
||||
|
||||
The know-your-world game had a "Failed to fetch session" error that was only logged to console. With the new system:
|
||||
|
||||
**Before:**
|
||||
- Error logged to console
|
||||
- User sees nothing, buttons don't work
|
||||
- No way to know what's wrong
|
||||
|
||||
**After:**
|
||||
- Error toast appears: "Game session error"
|
||||
- Technical details available (collapsible)
|
||||
- User can refresh or return to lobby
|
||||
- Clear actionable feedback
|
||||
|
||||
## Migration
|
||||
|
||||
Existing arcade games need to be updated to wrap with error providers. Priority order:
|
||||
|
||||
1. ✅ useArcadeSocket hook (done)
|
||||
2. ✅ Error components created (done)
|
||||
3. ⏳ Wrap arcade game pages
|
||||
4. ⏳ Test error scenarios
|
||||
5. ⏳ Add recovery strategies
|
||||
|
|
@ -58,7 +58,8 @@
|
|||
"Bash(gh run view:*)",
|
||||
"Bash(pnpm install:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(node server.js:*)"
|
||||
"Bash(node server.js:*)",
|
||||
"Bash(git fetch:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
'use client'
|
||||
|
||||
import React, { Component, type ReactNode } from 'react'
|
||||
import { css } from '../../styled-system/css'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: (error: Error, resetError: () => void) => ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary for arcade games
|
||||
* Catches React errors and displays a user-friendly error UI
|
||||
*/
|
||||
export class ArcadeErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('[ArcadeErrorBoundary] Caught error:', error, errorInfo)
|
||||
}
|
||||
|
||||
resetError = () => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError && this.state.error) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback(this.state.error, this.resetError)
|
||||
}
|
||||
|
||||
return <DefaultErrorFallback error={this.state.error} resetError={this.resetError} />
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default error fallback UI
|
||||
*/
|
||||
function DefaultErrorFallback({ error, resetError }: { error: Error; resetError: () => void }) {
|
||||
const [showDetails, setShowDetails] = React.useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="arcade-error-boundary"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '400px',
|
||||
padding: '32px',
|
||||
backgroundColor: 'red.50',
|
||||
borderRadius: '12px',
|
||||
border: '2px solid',
|
||||
borderColor: 'red.300',
|
||||
margin: '16px',
|
||||
})}
|
||||
>
|
||||
{/* Error icon */}
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '64px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
⚠️
|
||||
</div>
|
||||
|
||||
{/* Error title */}
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: 'red.900',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Something Went Wrong
|
||||
</h2>
|
||||
|
||||
{/* Error message */}
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '16px',
|
||||
color: 'red.800',
|
||||
marginBottom: '24px',
|
||||
textAlign: 'center',
|
||||
maxWidth: '600px',
|
||||
})}
|
||||
>
|
||||
The game encountered an unexpected error. Please try refreshing the page or returning to the arcade
|
||||
lobby.
|
||||
</p>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
onClick={resetError}
|
||||
data-action="reset-error"
|
||||
className={css({
|
||||
padding: '12px 24px',
|
||||
backgroundColor: 'red.600',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s',
|
||||
_hover: {
|
||||
backgroundColor: 'red.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => (window.location.href = '/arcade-rooms')}
|
||||
data-action="return-to-lobby"
|
||||
className={css({
|
||||
padding: '12px 24px',
|
||||
backgroundColor: 'gray.200',
|
||||
color: 'gray.800',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s',
|
||||
_hover: {
|
||||
backgroundColor: 'gray.300',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Return to Lobby
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Technical details (collapsible) */}
|
||||
<div className={css({ marginTop: '16px', width: '100%', maxWidth: '600px' })}>
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
data-action="toggle-error-details"
|
||||
className={css({
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'red.700',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
_hover: {
|
||||
color: 'red.900',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{showDetails ? 'Hide' : 'Show'} technical details
|
||||
</button>
|
||||
|
||||
{showDetails && (
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '12px',
|
||||
padding: '16px',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'red.200',
|
||||
borderRadius: '8px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: 'red.900',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Error: {error.message}
|
||||
</div>
|
||||
|
||||
{error.stack && (
|
||||
<pre
|
||||
className={css({
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace',
|
||||
color: 'gray.700',
|
||||
overflow: 'auto',
|
||||
maxHeight: '200px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
})}
|
||||
>
|
||||
{error.stack}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { css } from '../../styled-system/css'
|
||||
|
||||
export interface ErrorToastProps {
|
||||
message: string
|
||||
details?: string
|
||||
onDismiss: () => void
|
||||
autoHideDuration?: number // milliseconds, default 10000 (10s)
|
||||
}
|
||||
|
||||
/**
|
||||
* Error toast notification component
|
||||
* Shows prominent error messages to users with optional details
|
||||
*/
|
||||
export function ErrorToast({
|
||||
message,
|
||||
details,
|
||||
onDismiss,
|
||||
autoHideDuration = 10000,
|
||||
}: ErrorToastProps) {
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (autoHideDuration > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false)
|
||||
setTimeout(onDismiss, 300) // Wait for fade-out animation
|
||||
}, autoHideDuration)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [autoHideDuration, onDismiss])
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="error-toast"
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
bottom: '24px',
|
||||
right: '24px',
|
||||
maxWidth: '400px',
|
||||
backgroundColor: 'red.700',
|
||||
color: 'white',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||
zIndex: 20000, // Above everything
|
||||
transition: 'all 0.3s ease-out',
|
||||
'@media (max-width: 480px)': {
|
||||
left: '16px',
|
||||
right: '16px',
|
||||
bottom: '16px',
|
||||
maxWidth: 'none',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Header with dismiss button */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flex: 1,
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: '20px' })} aria-label="Error">
|
||||
⚠️
|
||||
</span>
|
||||
<strong className={css({ fontSize: '16px', fontWeight: 'bold' })}>Error</strong>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsVisible(false)
|
||||
setTimeout(onDismiss, 300)
|
||||
}}
|
||||
data-action="dismiss-error"
|
||||
className={css({
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'white',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
padding: '0',
|
||||
lineHeight: '1',
|
||||
opacity: 0.7,
|
||||
transition: 'opacity 0.2s',
|
||||
_hover: {
|
||||
opacity: 1,
|
||||
},
|
||||
})}
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
<div className={css({ fontSize: '14px', lineHeight: '1.5', marginBottom: details ? '8px' : '0' })}>
|
||||
{message}
|
||||
</div>
|
||||
|
||||
{/* Optional details */}
|
||||
{details && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
data-action="toggle-error-details"
|
||||
className={css({
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 0',
|
||||
textDecoration: 'underline',
|
||||
opacity: 0.8,
|
||||
_hover: {
|
||||
opacity: 1,
|
||||
},
|
||||
})}
|
||||
>
|
||||
{showDetails ? 'Hide' : 'Show'} technical details
|
||||
</button>
|
||||
|
||||
{showDetails && (
|
||||
<pre
|
||||
className={css({
|
||||
marginTop: '8px',
|
||||
padding: '8px',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.2)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
overflow: 'auto',
|
||||
maxHeight: '200px',
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
})}
|
||||
>
|
||||
{details}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Error toast container that manages multiple toasts
|
||||
*/
|
||||
export function ErrorToastContainer({ errors }: { errors: Array<{ id: string; message: string; details?: string }> }) {
|
||||
const [visibleErrors, setVisibleErrors] = useState(errors)
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleErrors(errors)
|
||||
}, [errors])
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleErrors.map((error, index) => (
|
||||
<ErrorToast
|
||||
key={error.id}
|
||||
message={error.message}
|
||||
details={error.details}
|
||||
onDismiss={() => {
|
||||
setVisibleErrors((prev) => prev.filter((e) => e.id !== error.id))
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
'use client'
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
|
||||
import { ErrorToast } from '@/components/ErrorToast'
|
||||
|
||||
interface ArcadeError {
|
||||
id: string
|
||||
message: string
|
||||
details?: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
interface ArcadeErrorContextValue {
|
||||
errors: ArcadeError[]
|
||||
addError: (message: string, details?: string) => void
|
||||
clearError: (id: string) => void
|
||||
clearAllErrors: () => void
|
||||
}
|
||||
|
||||
const ArcadeErrorContext = createContext<ArcadeErrorContextValue | null>(null)
|
||||
|
||||
/**
|
||||
* Provider for arcade error management
|
||||
* Manages error toast notifications across arcade games
|
||||
*/
|
||||
export function ArcadeErrorProvider({ children }: { children: ReactNode }) {
|
||||
const [errors, setErrors] = useState<ArcadeError[]>([])
|
||||
|
||||
const addError = useCallback((message: string, details?: string) => {
|
||||
const error: ArcadeError = {
|
||||
id: `error-${Date.now()}-${Math.random()}`,
|
||||
message,
|
||||
details,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
setErrors((prev) => [...prev, error])
|
||||
|
||||
// Auto-remove after 15 seconds as fallback
|
||||
setTimeout(() => {
|
||||
setErrors((prev) => prev.filter((e) => e.id !== error.id))
|
||||
}, 15000)
|
||||
}, [])
|
||||
|
||||
const clearError = useCallback((id: string) => {
|
||||
setErrors((prev) => prev.filter((e) => e.id !== id))
|
||||
}, [])
|
||||
|
||||
const clearAllErrors = useCallback(() => {
|
||||
setErrors([])
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ArcadeErrorContext.Provider value={{ errors, addError, clearError, clearAllErrors }}>
|
||||
{children}
|
||||
|
||||
{/* Render error toasts */}
|
||||
<div data-component="arcade-error-toasts">
|
||||
{errors.map((error) => (
|
||||
<ErrorToast
|
||||
key={error.id}
|
||||
message={error.message}
|
||||
details={error.details}
|
||||
onDismiss={() => clearError(error.id)}
|
||||
autoHideDuration={10000}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ArcadeErrorContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access arcade error context
|
||||
*/
|
||||
export function useArcadeError() {
|
||||
const context = useContext(ArcadeErrorContext)
|
||||
if (!context) {
|
||||
throw new Error('useArcadeError must be used within ArcadeErrorProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import type { GameMove } from '@/lib/arcade/validation'
|
||||
import { useArcadeError } from '@/contexts/ArcadeErrorContext'
|
||||
|
||||
export interface ArcadeSocketEvents {
|
||||
onSessionState?: (data: {
|
||||
|
|
@ -15,6 +16,8 @@ export interface ArcadeSocketEvents {
|
|||
onSessionEnded?: () => void
|
||||
onNoActiveSession?: () => void
|
||||
onError?: (error: { error: string }) => void
|
||||
/** If true, errors will NOT show toasts (for cases where game handles errors directly) */
|
||||
suppressErrorToasts?: boolean
|
||||
}
|
||||
|
||||
export interface UseArcadeSocketReturn {
|
||||
|
|
@ -36,6 +39,7 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
|
|||
const [socket, setSocket] = useState<Socket | null>(null)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const eventsRef = useRef(events)
|
||||
const { addError } = useArcadeError()
|
||||
|
||||
// Update events ref when they change
|
||||
useEffect(() => {
|
||||
|
|
@ -59,6 +63,25 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
|
|||
socketInstance.on('disconnect', () => {
|
||||
console.log('[ArcadeSocket] Disconnected')
|
||||
setConnected(false)
|
||||
|
||||
// Show error toast unless suppressed
|
||||
if (!eventsRef.current.suppressErrorToasts) {
|
||||
addError(
|
||||
'Connection lost',
|
||||
'The connection to the game server was lost. Attempting to reconnect...'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
socketInstance.on('connect_error', (error) => {
|
||||
console.error('[ArcadeSocket] Connection error', error)
|
||||
|
||||
if (!eventsRef.current.suppressErrorToasts) {
|
||||
addError(
|
||||
'Connection error',
|
||||
`Failed to connect to the game server: ${error.message}\n\nPlease check your internet connection and try refreshing the page.`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
socketInstance.on('session-state', (data) => {
|
||||
|
|
@ -66,6 +89,14 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
|
|||
})
|
||||
|
||||
socketInstance.on('no-active-session', () => {
|
||||
// Show error toast unless suppressed
|
||||
if (!eventsRef.current.suppressErrorToasts) {
|
||||
addError(
|
||||
'No active session',
|
||||
'No game session was found. Please start a new game or join an existing room.'
|
||||
)
|
||||
}
|
||||
|
||||
eventsRef.current.onNoActiveSession?.()
|
||||
})
|
||||
|
||||
|
|
@ -75,6 +106,15 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
|
|||
|
||||
socketInstance.on('move-rejected', (data) => {
|
||||
console.log(`[ArcadeSocket] Move rejected: ${data.error}`)
|
||||
|
||||
// Show error toast for move rejections unless suppressed or it's a version conflict
|
||||
if (!eventsRef.current.suppressErrorToasts && !data.versionConflict) {
|
||||
addError(
|
||||
'Move rejected',
|
||||
`Your move was not accepted: ${data.error}\n\nMove type: ${data.move.type}`
|
||||
)
|
||||
}
|
||||
|
||||
eventsRef.current.onMoveRejected?.(data)
|
||||
})
|
||||
|
||||
|
|
@ -85,6 +125,15 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
|
|||
|
||||
socketInstance.on('session-error', (data) => {
|
||||
console.error('[ArcadeSocket] Session error', data)
|
||||
|
||||
// Show error toast unless suppressed
|
||||
if (!eventsRef.current.suppressErrorToasts) {
|
||||
addError(
|
||||
'Game session error',
|
||||
`Error: ${data.error}\n\nThis usually means there was a problem loading or updating the game session. Please try refreshing the page or returning to the lobby.`
|
||||
)
|
||||
}
|
||||
|
||||
eventsRef.current.onError?.(data)
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue