feat(arcade): add Number Guesser demo game with plugin architecture
Implements the first registry-based game to demonstrate the modular plugin system: Game Features: - Turn-based number guessing with hot/cold feedback - 2-4 players with configurable settings - Round-based scoring system - Complete game phases: Setup → Choosing → Guessing → Results Technical Implementation: - Created Number Guesser game in src/arcade-games/number-guesser/ - Registered NumberGuesserValidator in validation system - Added 'number-guesser' to database schema enums (arcade_rooms, arcade_sessions, room_game_configs) - Created NumberGuesserGameConfig type with default configuration - Integrated game into GameSelector and room page - Added PageWithNav wrapper for consistent navigation - Fixed controlled input warnings with fallback values Registry Integration: - Game automatically appears in /arcade/room selection UI - Settings persist to room_game_configs table - Validator creates and manages server-side game state - Provider syncs client state via arcade session 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,7 @@ type RouteContext = {
|
||||
* Body:
|
||||
* - accessMode?: 'open' | 'locked' | 'retired' | 'password' | 'restricted' | 'approval-only'
|
||||
* - password?: string (plain text, will be hashed)
|
||||
* - gameName?: 'matching' | 'memory-quiz' | 'complement-race' | null (select game for room)
|
||||
* - gameName?: 'matching' | 'memory-quiz' | 'complement-race' | 'number-guesser' | null (select game for room)
|
||||
* - gameConfig?: object (game-specific settings)
|
||||
*/
|
||||
export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
@@ -94,7 +94,8 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
|
||||
// Validate gameName if provided
|
||||
if (body.gameName !== undefined && body.gameName !== null) {
|
||||
const validGames = ['matching', 'memory-quiz', 'complement-race']
|
||||
// Legacy games + registry games (TODO: make this dynamic when we refactor to lazy-load registry)
|
||||
const validGames = ['matching', 'memory-quiz', 'complement-race', 'number-guesser']
|
||||
if (!validGames.includes(body.gameName)) {
|
||||
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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
|
||||
const GAME_TYPE_TO_NAME: Record<GameType, string> = {
|
||||
@@ -89,13 +90,35 @@ export default function RoomPage() {
|
||||
const handleGameSelect = (gameType: GameType) => {
|
||||
console.log('[RoomPage] handleGameSelect called with gameType:', gameType)
|
||||
|
||||
const gameConfig = GAMES_CONFIG[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: gameConfig.available,
|
||||
available: 'available' in gameConfig ? gameConfig.available : true,
|
||||
})
|
||||
|
||||
if (gameConfig.available === false) {
|
||||
if ('available' in gameConfig && gameConfig.available === false) {
|
||||
console.log('[RoomPage] Game not available, blocking selection')
|
||||
return // Don't allow selecting unavailable games
|
||||
}
|
||||
@@ -160,64 +183,158 @@ export default function RoomPage() {
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{Object.entries(GAMES_CONFIG).map(([gameType, config]) => (
|
||||
<button
|
||||
key={gameType}
|
||||
onClick={() => handleGameSelect(gameType as GameType)}
|
||||
disabled={config.available === false}
|
||||
className={css({
|
||||
background: config.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: config.borderColor || 'blue.200',
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: config.available === false ? 'not-allowed' : 'pointer',
|
||||
opacity: config.available === false ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover:
|
||||
config.available === false
|
||||
{/* 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
|
||||
<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({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '2',
|
||||
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)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{config.name}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{config.description}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
// Render the appropriate game based on room's gameName
|
||||
// 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) {
|
||||
case 'matching':
|
||||
return (
|
||||
|
||||
210
apps/web/src/arcade-games/number-guesser/Provider.tsx
Normal file
210
apps/web/src/arcade-games/number-guesser/Provider.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Number Guesser Provider
|
||||
* Manages game state using the Arcade SDK
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
|
||||
import {
|
||||
type GameMove,
|
||||
buildPlayerMetadata,
|
||||
useArcadeSession,
|
||||
useGameMode,
|
||||
useRoomData,
|
||||
useUpdateGameConfig,
|
||||
useViewerId,
|
||||
} from '@/lib/arcade/game-sdk'
|
||||
import type { NumberGuesserState } from './types'
|
||||
|
||||
/**
|
||||
* Context value interface
|
||||
*/
|
||||
interface NumberGuesserContextValue {
|
||||
state: NumberGuesserState
|
||||
startGame: () => void
|
||||
chooseNumber: (number: number) => void
|
||||
makeGuess: (guess: number) => void
|
||||
nextRound: () => void
|
||||
goToSetup: () => void
|
||||
setConfig: (field: 'minNumber' | 'maxNumber' | 'roundsToWin', value: number) => void
|
||||
exitSession: () => void
|
||||
}
|
||||
|
||||
const NumberGuesserContext = createContext<NumberGuesserContextValue | null>(null)
|
||||
|
||||
/**
|
||||
* Hook to access Number Guesser context
|
||||
*/
|
||||
export function useNumberGuesser() {
|
||||
const context = useContext(NumberGuesserContext)
|
||||
if (!context) {
|
||||
throw new Error('useNumberGuesser must be used within NumberGuesserProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic move application
|
||||
*/
|
||||
function applyMoveOptimistically(state: NumberGuesserState, move: GameMove): NumberGuesserState {
|
||||
// For simplicity, just return current state
|
||||
// Server will send back the validated new state
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Number Guesser Provider Component
|
||||
*/
|
||||
export function NumberGuesserProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
const { activePlayers: activePlayerIds, players } = useGameMode()
|
||||
const { mutate: updateGameConfig } = useUpdateGameConfig()
|
||||
|
||||
// Get active players as array
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
// Merge saved config from room
|
||||
const initialState = useMemo(() => {
|
||||
const gameConfig = roomData?.gameConfig as Record<string, unknown> | null | undefined
|
||||
const savedConfig = gameConfig?.['number-guesser'] as Record<string, unknown> | undefined
|
||||
|
||||
return {
|
||||
minNumber: (savedConfig?.minNumber as number) || 1,
|
||||
maxNumber: (savedConfig?.maxNumber as number) || 100,
|
||||
roundsToWin: (savedConfig?.roundsToWin as number) || 3,
|
||||
gamePhase: 'setup' as const,
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
secretNumber: null,
|
||||
chooser: '',
|
||||
currentGuesser: '',
|
||||
guesses: [],
|
||||
roundNumber: 0,
|
||||
scores: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
winner: null,
|
||||
}
|
||||
}, [roomData?.gameConfig])
|
||||
|
||||
// Arcade session integration
|
||||
const { state, sendMove, exitSession } = useArcadeSession<NumberGuesserState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id,
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// Action creators
|
||||
const startGame = useCallback(() => {
|
||||
if (activePlayers.length < 2) {
|
||||
console.error('Need at least 2 players to start')
|
||||
return
|
||||
}
|
||||
|
||||
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined)
|
||||
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: activePlayers[0],
|
||||
userId: viewerId || '',
|
||||
data: {
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
},
|
||||
})
|
||||
}, [activePlayers, players, viewerId, sendMove])
|
||||
|
||||
const chooseNumber = useCallback(
|
||||
(secretNumber: number) => {
|
||||
sendMove({
|
||||
type: 'CHOOSE_NUMBER',
|
||||
playerId: state.chooser,
|
||||
userId: viewerId || '',
|
||||
data: { secretNumber },
|
||||
})
|
||||
},
|
||||
[state.chooser, viewerId, sendMove]
|
||||
)
|
||||
|
||||
const makeGuess = useCallback(
|
||||
(guess: number) => {
|
||||
const playerName = state.playerMetadata[state.currentGuesser]?.name || 'Unknown'
|
||||
|
||||
sendMove({
|
||||
type: 'MAKE_GUESS',
|
||||
playerId: state.currentGuesser,
|
||||
userId: viewerId || '',
|
||||
data: { guess, playerName },
|
||||
})
|
||||
},
|
||||
[state.currentGuesser, state.playerMetadata, viewerId, sendMove]
|
||||
)
|
||||
|
||||
const nextRound = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'NEXT_ROUND',
|
||||
playerId: activePlayers[0] || '',
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [activePlayers, viewerId, sendMove])
|
||||
|
||||
const goToSetup = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'GO_TO_SETUP',
|
||||
playerId: activePlayers[0] || state.chooser || '',
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [activePlayers, state.chooser, viewerId, sendMove])
|
||||
|
||||
const setConfig = useCallback(
|
||||
(field: 'minNumber' | 'maxNumber' | 'roundsToWin', value: number) => {
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId: activePlayers[0] || '',
|
||||
userId: viewerId || '',
|
||||
data: { field, value },
|
||||
})
|
||||
|
||||
// Persist to database
|
||||
if (roomData?.id) {
|
||||
const currentGameConfig = (roomData.gameConfig as Record<string, unknown>) || {}
|
||||
const currentNumberGuesserConfig =
|
||||
(currentGameConfig['number-guesser'] as Record<string, unknown>) || {}
|
||||
|
||||
const updatedConfig = {
|
||||
...currentGameConfig,
|
||||
'number-guesser': {
|
||||
...currentNumberGuesserConfig,
|
||||
[field]: value,
|
||||
},
|
||||
}
|
||||
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: updatedConfig,
|
||||
})
|
||||
}
|
||||
},
|
||||
[activePlayers, viewerId, sendMove, roomData?.id, roomData?.gameConfig, updateGameConfig]
|
||||
)
|
||||
|
||||
const contextValue: NumberGuesserContextValue = {
|
||||
state,
|
||||
startGame,
|
||||
chooseNumber,
|
||||
makeGuess,
|
||||
nextRound,
|
||||
goToSetup,
|
||||
setConfig,
|
||||
exitSession,
|
||||
}
|
||||
|
||||
return (
|
||||
<NumberGuesserContext.Provider value={contextValue}>{children}</NumberGuesserContext.Provider>
|
||||
)
|
||||
}
|
||||
283
apps/web/src/arcade-games/number-guesser/Validator.ts
Normal file
283
apps/web/src/arcade-games/number-guesser/Validator.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Server-side validator for Number Guesser game
|
||||
*/
|
||||
|
||||
import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk'
|
||||
import type { NumberGuesserConfig, NumberGuesserMove, NumberGuesserState } from './types'
|
||||
|
||||
export class NumberGuesserValidator
|
||||
implements GameValidator<NumberGuesserState, NumberGuesserMove>
|
||||
{
|
||||
validateMove(state: NumberGuesserState, move: NumberGuesserMove): ValidationResult {
|
||||
switch (move.type) {
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data.activePlayers, move.data.playerMetadata)
|
||||
|
||||
case 'CHOOSE_NUMBER':
|
||||
return this.validateChooseNumber(state, move.data.secretNumber, move.playerId)
|
||||
|
||||
case 'MAKE_GUESS':
|
||||
return this.validateMakeGuess(state, move.data.guess, move.playerId, move.data.playerName)
|
||||
|
||||
case 'NEXT_ROUND':
|
||||
return this.validateNextRound(state)
|
||||
|
||||
case 'GO_TO_SETUP':
|
||||
return this.validateGoToSetup(state)
|
||||
|
||||
case 'SET_CONFIG':
|
||||
return this.validateSetConfig(state, move.data.field, move.data.value)
|
||||
|
||||
default:
|
||||
return {
|
||||
valid: false,
|
||||
error: `Unknown move type: ${(move as { type: string }).type}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateStartGame(
|
||||
state: NumberGuesserState,
|
||||
activePlayers: string[],
|
||||
playerMetadata: Record<string, unknown>
|
||||
): ValidationResult {
|
||||
if (!activePlayers || activePlayers.length < 2) {
|
||||
return { valid: false, error: 'Need at least 2 players' }
|
||||
}
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
gamePhase: 'choosing',
|
||||
activePlayers,
|
||||
playerMetadata: playerMetadata as typeof state.playerMetadata,
|
||||
chooser: activePlayers[0],
|
||||
currentGuesser: '',
|
||||
secretNumber: null,
|
||||
guesses: [],
|
||||
roundNumber: 1,
|
||||
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
|
||||
gameStartTime: Date.now(),
|
||||
gameEndTime: null,
|
||||
winner: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateChooseNumber(
|
||||
state: NumberGuesserState,
|
||||
secretNumber: number,
|
||||
playerId: string
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'choosing') {
|
||||
return { valid: false, error: 'Not in choosing phase' }
|
||||
}
|
||||
|
||||
if (playerId !== state.chooser) {
|
||||
return { valid: false, error: 'Not your turn to choose' }
|
||||
}
|
||||
|
||||
if (
|
||||
secretNumber < state.minNumber ||
|
||||
secretNumber > state.maxNumber ||
|
||||
!Number.isInteger(secretNumber)
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Number must be between ${state.minNumber} and ${state.maxNumber}`,
|
||||
}
|
||||
}
|
||||
|
||||
// First guesser is the next player after chooser
|
||||
const chooserIndex = state.activePlayers.indexOf(state.chooser)
|
||||
const firstGuesserIndex = (chooserIndex + 1) % state.activePlayers.length
|
||||
const firstGuesser = state.activePlayers[firstGuesserIndex]
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
gamePhase: 'guessing',
|
||||
secretNumber,
|
||||
currentGuesser: firstGuesser,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateMakeGuess(
|
||||
state: NumberGuesserState,
|
||||
guess: number,
|
||||
playerId: string,
|
||||
playerName: string
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'guessing') {
|
||||
return { valid: false, error: 'Not in guessing phase' }
|
||||
}
|
||||
|
||||
if (playerId !== state.currentGuesser) {
|
||||
return { valid: false, error: 'Not your turn to guess' }
|
||||
}
|
||||
|
||||
if (guess < state.minNumber || guess > state.maxNumber || !Number.isInteger(guess)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Guess must be between ${state.minNumber} and ${state.maxNumber}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.secretNumber) {
|
||||
return { valid: false, error: 'No secret number set' }
|
||||
}
|
||||
|
||||
const distance = Math.abs(guess - state.secretNumber)
|
||||
const newGuess = {
|
||||
playerId,
|
||||
playerName,
|
||||
guess,
|
||||
distance,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
const guesses = [...state.guesses, newGuess]
|
||||
|
||||
// Check if guess is correct
|
||||
if (distance === 0) {
|
||||
// Correct guess! Award point and end round
|
||||
const newScores = {
|
||||
...state.scores,
|
||||
[playerId]: (state.scores[playerId] || 0) + 1,
|
||||
}
|
||||
|
||||
// Check if player won
|
||||
const winner = newScores[playerId] >= state.roundsToWin ? playerId : null
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
guesses,
|
||||
scores: newScores,
|
||||
gamePhase: winner ? 'results' : 'guessing',
|
||||
gameEndTime: winner ? Date.now() : null,
|
||||
winner,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
// Incorrect guess, move to next guesser
|
||||
const guesserIndex = state.activePlayers.indexOf(state.currentGuesser)
|
||||
let nextGuesserIndex = (guesserIndex + 1) % state.activePlayers.length
|
||||
|
||||
// Skip the chooser
|
||||
if (state.activePlayers[nextGuesserIndex] === state.chooser) {
|
||||
nextGuesserIndex = (nextGuesserIndex + 1) % state.activePlayers.length
|
||||
}
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
guesses,
|
||||
currentGuesser: state.activePlayers[nextGuesserIndex],
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateNextRound(state: NumberGuesserState): ValidationResult {
|
||||
if (state.gamePhase !== 'guessing' || !state.winner) {
|
||||
return { valid: false, error: 'Cannot start next round yet' }
|
||||
}
|
||||
|
||||
// Rotate chooser to next player
|
||||
const chooserIndex = state.activePlayers.indexOf(state.chooser)
|
||||
const nextChooserIndex = (chooserIndex + 1) % state.activePlayers.length
|
||||
const nextChooser = state.activePlayers[nextChooserIndex]
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
gamePhase: 'choosing',
|
||||
chooser: nextChooser,
|
||||
currentGuesser: '',
|
||||
secretNumber: null,
|
||||
guesses: [],
|
||||
roundNumber: state.roundNumber + 1,
|
||||
winner: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateGoToSetup(state: NumberGuesserState): ValidationResult {
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
gamePhase: 'setup',
|
||||
secretNumber: null,
|
||||
chooser: '',
|
||||
currentGuesser: '',
|
||||
guesses: [],
|
||||
roundNumber: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
winner: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateSetConfig(
|
||||
state: NumberGuesserState,
|
||||
field: 'minNumber' | 'maxNumber' | 'roundsToWin',
|
||||
value: number
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Can only change config in setup' }
|
||||
}
|
||||
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
return { valid: false, error: 'Value must be a positive integer' }
|
||||
}
|
||||
|
||||
if (field === 'minNumber' && value >= state.maxNumber) {
|
||||
return { valid: false, error: 'Min must be less than max' }
|
||||
}
|
||||
|
||||
if (field === 'maxNumber' && value <= state.minNumber) {
|
||||
return { valid: false, error: 'Max must be greater than min' }
|
||||
}
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
[field]: value,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
isGameComplete(state: NumberGuesserState): boolean {
|
||||
return state.gamePhase === 'results' && state.winner !== null
|
||||
}
|
||||
|
||||
getInitialState(config: unknown): NumberGuesserState {
|
||||
const { minNumber, maxNumber, roundsToWin } = config as NumberGuesserConfig
|
||||
|
||||
return {
|
||||
minNumber: minNumber || 1,
|
||||
maxNumber: maxNumber || 100,
|
||||
roundsToWin: roundsToWin || 3,
|
||||
gamePhase: 'setup',
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
secretNumber: null,
|
||||
chooser: '',
|
||||
currentGuesser: '',
|
||||
guesses: [],
|
||||
roundNumber: 0,
|
||||
scores: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
winner: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const numberGuesserValidator = new NumberGuesserValidator()
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Choosing Phase - Chooser picks a secret number
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useViewerId } from '@/lib/arcade/game-sdk'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useNumberGuesser } from '../Provider'
|
||||
|
||||
export function ChoosingPhase() {
|
||||
const { state, chooseNumber } = useNumberGuesser()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const chooserMetadata = state.playerMetadata[state.chooser]
|
||||
const isChooser = chooserMetadata?.userId === viewerId
|
||||
|
||||
const handleSubmit = () => {
|
||||
const number = Number.parseInt(inputValue, 10)
|
||||
if (Number.isNaN(number)) return
|
||||
|
||||
chooseNumber(number)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '32px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '64px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
{chooserMetadata?.emoji || '🤔'}
|
||||
</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
{isChooser ? "You're choosing!" : `${chooserMetadata?.name || 'Someone'} is choosing...`}
|
||||
</h2>
|
||||
<p
|
||||
className={css({
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
Round {state.roundNumber}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isChooser ? (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
})}
|
||||
>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'md',
|
||||
fontWeight: '600',
|
||||
marginBottom: '12px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Choose a secret number ({state.minNumber} - {state.maxNumber})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
min={state.minNumber}
|
||||
max={state.maxNumber}
|
||||
placeholder={`${state.minNumber} - ${state.maxNumber}`}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'xl',
|
||||
textAlign: 'center',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Confirm Choice
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
⏳
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
Waiting for {chooserMetadata?.name || 'player'} to choose a number...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scoreboard */}
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '32px',
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '12px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Scores
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
{state.activePlayers.map((playerId) => {
|
||||
const player = state.playerMetadata[playerId]
|
||||
return (
|
||||
<div
|
||||
key={playerId}
|
||||
className={css({
|
||||
padding: '8px 16px',
|
||||
background: 'gray.100',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
{player?.emoji} {player?.name}: {state.scores[playerId] || 0}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Number Guesser Game Component
|
||||
* Main component that switches between game phases
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useNumberGuesser } from '../Provider'
|
||||
import { ChoosingPhase } from './ChoosingPhase'
|
||||
import { GuessingPhase } from './GuessingPhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
|
||||
export function GameComponent() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession, goToSetup } = useNumberGuesser()
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Number Guesser"
|
||||
navEmoji="🎯"
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
onExitSession={() => {
|
||||
exitSession?.()
|
||||
router.push('/arcade')
|
||||
}}
|
||||
onNewGame={() => {
|
||||
goToSetup?.()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'auto',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #fff7ed, #ffedd5)',
|
||||
}}
|
||||
>
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'choosing' && <ChoosingPhase />}
|
||||
{state.gamePhase === 'guessing' && <GuessingPhase />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Guessing Phase - Players take turns guessing the secret number
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useViewerId } from '@/lib/arcade/game-sdk'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useNumberGuesser } from '../Provider'
|
||||
|
||||
export function GuessingPhase() {
|
||||
const { state, makeGuess, nextRound } = useNumberGuesser()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const currentGuesserMetadata = state.playerMetadata[state.currentGuesser]
|
||||
const isCurrentGuesser = currentGuesserMetadata?.userId === viewerId
|
||||
|
||||
// Check if someone just won the round
|
||||
const lastGuess = state.guesses[state.guesses.length - 1]
|
||||
const roundJustEnded = lastGuess?.distance === 0
|
||||
|
||||
const handleSubmit = () => {
|
||||
const guess = Number.parseInt(inputValue, 10)
|
||||
if (Number.isNaN(guess)) return
|
||||
|
||||
makeGuess(guess)
|
||||
setInputValue('')
|
||||
}
|
||||
|
||||
const getHotColdMessage = (distance: number) => {
|
||||
if (distance === 0) return '🎯 Correct!'
|
||||
if (distance <= 5) return '🔥 Very Hot!'
|
||||
if (distance <= 10) return '🌡️ Hot'
|
||||
if (distance <= 20) return '😊 Warm'
|
||||
if (distance <= 30) return '😐 Cool'
|
||||
if (distance <= 50) return '❄️ Cold'
|
||||
return '🧊 Very Cold'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
padding: '32px',
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '32px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '64px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
{roundJustEnded ? '🎉' : currentGuesserMetadata?.emoji || '🤔'}
|
||||
</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
{roundJustEnded
|
||||
? `${lastGuess.playerName} guessed it!`
|
||||
: isCurrentGuesser
|
||||
? 'Your turn to guess!'
|
||||
: `${currentGuesserMetadata?.name || 'Someone'} is guessing...`}
|
||||
</h2>
|
||||
<p
|
||||
className={css({
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
Round {state.roundNumber} • Range: {state.minNumber} - {state.maxNumber}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Round ended - show next round button */}
|
||||
{roundJustEnded && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'green.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
🎯
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
The secret number was <strong>{state.secretNumber}</strong>!
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={nextRound}
|
||||
className={css({
|
||||
padding: '12px 24px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Next Round
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Guessing input (only if round not ended) */}
|
||||
{!roundJustEnded && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
{isCurrentGuesser ? (
|
||||
<>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'md',
|
||||
fontWeight: '600',
|
||||
marginBottom: '12px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Make your guess ({state.minNumber} - {state.maxNumber})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && inputValue) {
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
min={state.minNumber}
|
||||
max={state.maxNumber}
|
||||
placeholder={`${state.minNumber} - ${state.maxNumber}`}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'xl',
|
||||
textAlign: 'center',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Submit Guess
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
⏳
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
Waiting for {currentGuesserMetadata?.name || 'player'} to guess...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Guess history */}
|
||||
{state.guesses.length > 0 && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '12px',
|
||||
})}
|
||||
>
|
||||
Guess History
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
})}
|
||||
>
|
||||
{state.guesses.map((guess, index) => {
|
||||
const player = state.playerMetadata[guess.playerId]
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={css({
|
||||
padding: '12px',
|
||||
background: guess.distance === 0 ? 'green.50' : 'gray.50',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
})}
|
||||
>
|
||||
<span>{player?.emoji || '🎮'}</span>
|
||||
<span className={css({ fontWeight: '600' })}>{guess.playerName}</span>
|
||||
<span className={css({ color: 'gray.600' })}>guessed</span>
|
||||
<span className={css({ fontWeight: 'bold', fontSize: 'lg' })}>
|
||||
{guess.guess}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
color: guess.distance === 0 ? 'green.700' : 'orange.700',
|
||||
})}
|
||||
>
|
||||
{getHotColdMessage(guess.distance)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scoreboard */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '12px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Scores (First to {state.roundsToWin} wins!)
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
{state.activePlayers.map((playerId) => {
|
||||
const player = state.playerMetadata[playerId]
|
||||
const score = state.scores[playerId] || 0
|
||||
return (
|
||||
<div
|
||||
key={playerId}
|
||||
className={css({
|
||||
padding: '8px 16px',
|
||||
background: 'gray.100',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
{player?.emoji} {player?.name}: {score}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Results Phase - Shows winner and final scores
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useNumberGuesser } from '../Provider'
|
||||
|
||||
export function ResultsPhase() {
|
||||
const { state, goToSetup } = useNumberGuesser()
|
||||
|
||||
const winnerMetadata = state.winner ? state.playerMetadata[state.winner] : null
|
||||
const winnerScore = state.winner ? state.scores[state.winner] : 0
|
||||
|
||||
// Sort players by score
|
||||
const sortedPlayers = [...state.activePlayers].sort((a, b) => {
|
||||
const scoreA = state.scores[a] || 0
|
||||
const scoreB = state.scores[b] || 0
|
||||
return scoreB - scoreA
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
{/* Winner Celebration */}
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '32px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '96px',
|
||||
marginBottom: '16px',
|
||||
animation: 'bounce 1s ease-in-out infinite',
|
||||
})}
|
||||
>
|
||||
{winnerMetadata?.emoji || '🏆'}
|
||||
</div>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '3xl',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
})}
|
||||
>
|
||||
{winnerMetadata?.name || 'Someone'} Wins!
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
with {winnerScore} {winnerScore === 1 ? 'round' : 'rounds'} won
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Final Standings */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Final Standings
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
})}
|
||||
>
|
||||
{sortedPlayers.map((playerId, index) => {
|
||||
const player = state.playerMetadata[playerId]
|
||||
const score = state.scores[playerId] || 0
|
||||
const isWinner = playerId === state.winner
|
||||
|
||||
return (
|
||||
<div
|
||||
key={playerId}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px',
|
||||
background: isWinner ? 'linear-gradient(135deg, #fed7aa, #fdba74)' : 'gray.100',
|
||||
borderRadius: '8px',
|
||||
border: isWinner ? '2px solid' : 'none',
|
||||
borderColor: isWinner ? 'orange.300' : undefined,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.400',
|
||||
width: '32px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className={css({ fontSize: '32px' })}>{player?.emoji || '🎮'}</span>
|
||||
<span className={css({ fontSize: 'lg', fontWeight: '600' })}>
|
||||
{player?.name || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: isWinner ? 'orange.700' : 'gray.700',
|
||||
})}
|
||||
>
|
||||
{score} {isWinner && '🏆'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Game Stats */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Game Stats
|
||||
</h3>
|
||||
<p className={css({ color: 'gray.600', fontSize: 'sm' })}>
|
||||
{state.roundNumber} {state.roundNumber === 1 ? 'round' : 'rounds'} played
|
||||
</p>
|
||||
<p className={css({ color: 'gray.600', fontSize: 'sm' })}>
|
||||
{state.guesses.length} {state.guesses.length === 1 ? 'guess' : 'guesses'} made
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToSetup}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 16px rgba(249, 115, 22, 0.3)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Play Again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Setup Phase - Game configuration
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useNumberGuesser } from '../Provider'
|
||||
|
||||
export function SetupPhase() {
|
||||
const { state, startGame, setConfig } = useNumberGuesser()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '24px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
🎯 Number Guesser Setup
|
||||
</h2>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
Game Rules
|
||||
</h3>
|
||||
<ul
|
||||
className={css({
|
||||
listStyle: 'disc',
|
||||
paddingLeft: '24px',
|
||||
lineHeight: '1.6',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
<li>One player chooses a secret number</li>
|
||||
<li>Other players take turns guessing</li>
|
||||
<li>Get feedback on how close your guess is</li>
|
||||
<li>First to guess correctly wins the round!</li>
|
||||
<li>First to {state.roundsToWin} rounds wins the game!</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
Configuration
|
||||
</h3>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Minimum Number
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={state.minNumber ?? 1}
|
||||
onChange={(e) => setConfig('minNumber', Number.parseInt(e.target.value, 10))}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '6px',
|
||||
fontSize: 'md',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Maximum Number
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={state.maxNumber ?? 100}
|
||||
onChange={(e) => setConfig('maxNumber', Number.parseInt(e.target.value, 10))}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '6px',
|
||||
fontSize: 'md',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Rounds to Win
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={state.roundsToWin ?? 3}
|
||||
onChange={(e) => setConfig('roundsToWin', Number.parseInt(e.target.value, 10))}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '6px',
|
||||
fontSize: 'md',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={startGame}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 16px rgba(249, 115, 22, 0.3)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Start Game
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
apps/web/src/arcade-games/number-guesser/game.yaml
Normal file
15
apps/web/src/arcade-games/number-guesser/game.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
name: number-guesser
|
||||
displayName: Number Guesser
|
||||
icon: 🎯
|
||||
description: Classic turn-based number guessing game
|
||||
longDescription: One player thinks of a number, others take turns guessing. Get hot/cold feedback as you try to find the secret number. Perfect for testing your deduction skills!
|
||||
maxPlayers: 4
|
||||
difficulty: Beginner
|
||||
chips:
|
||||
- 👥 Multiplayer
|
||||
- 🎲 Turn-Based
|
||||
- 🧠 Logic Puzzle
|
||||
color: orange
|
||||
gradient: linear-gradient(135deg, #fed7aa, #fdba74)
|
||||
borderColor: orange.200
|
||||
available: true
|
||||
48
apps/web/src/arcade-games/number-guesser/index.ts
Normal file
48
apps/web/src/arcade-games/number-guesser/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Number Guesser Game Definition
|
||||
* Exports the complete game using the Arcade SDK
|
||||
*/
|
||||
|
||||
import { defineGame } from '@/lib/arcade/game-sdk'
|
||||
import type { GameManifest } from '@/lib/arcade/game-sdk'
|
||||
import { GameComponent } from './components/GameComponent'
|
||||
import { NumberGuesserProvider } from './Provider'
|
||||
import type { NumberGuesserConfig, NumberGuesserMove, NumberGuesserState } from './types'
|
||||
import { numberGuesserValidator } from './Validator'
|
||||
|
||||
// Game manifest (matches game.yaml)
|
||||
const manifest: GameManifest = {
|
||||
name: 'number-guesser',
|
||||
displayName: 'Number Guesser',
|
||||
icon: '🎯',
|
||||
description: 'Classic turn-based number guessing game',
|
||||
longDescription:
|
||||
'One player thinks of a number, others take turns guessing. Get hot/cold feedback to narrow down your guesses. First to guess wins the round!',
|
||||
maxPlayers: 4,
|
||||
difficulty: 'Beginner',
|
||||
chips: ['👥 Multiplayer', '🎲 Turn-Based', '🧠 Logic Puzzle'],
|
||||
color: 'orange',
|
||||
gradient: 'linear-gradient(135deg, #fed7aa, #fdba74)',
|
||||
borderColor: 'orange.200',
|
||||
available: true,
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
const defaultConfig: NumberGuesserConfig = {
|
||||
minNumber: 1,
|
||||
maxNumber: 100,
|
||||
roundsToWin: 3,
|
||||
}
|
||||
|
||||
// Export game definition
|
||||
export const numberGuesserGame = defineGame<
|
||||
NumberGuesserConfig,
|
||||
NumberGuesserState,
|
||||
NumberGuesserMove
|
||||
>({
|
||||
manifest,
|
||||
Provider: NumberGuesserProvider,
|
||||
GameComponent,
|
||||
validator: numberGuesserValidator,
|
||||
defaultConfig,
|
||||
})
|
||||
116
apps/web/src/arcade-games/number-guesser/types.ts
Normal file
116
apps/web/src/arcade-games/number-guesser/types.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Type definitions for Number Guesser game
|
||||
*/
|
||||
|
||||
import type { GameMove } from '@/lib/arcade/game-sdk'
|
||||
|
||||
/**
|
||||
* Game configuration
|
||||
*/
|
||||
export type NumberGuesserConfig = {
|
||||
minNumber: number
|
||||
maxNumber: number
|
||||
roundsToWin: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A single guess attempt
|
||||
*/
|
||||
export interface Guess {
|
||||
playerId: string
|
||||
playerName: string
|
||||
guess: number
|
||||
distance: number // How far from the secret number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Game phases
|
||||
*/
|
||||
export type GamePhase = 'setup' | 'choosing' | 'guessing' | 'results'
|
||||
|
||||
/**
|
||||
* Game state
|
||||
*/
|
||||
export type NumberGuesserState = {
|
||||
// Configuration
|
||||
minNumber: number
|
||||
maxNumber: number
|
||||
roundsToWin: number
|
||||
|
||||
// Game phase
|
||||
gamePhase: GamePhase
|
||||
|
||||
// Players
|
||||
activePlayers: string[]
|
||||
playerMetadata: Record<string, { name: string; emoji: string; color: string; userId: string }>
|
||||
|
||||
// Current round
|
||||
secretNumber: number | null
|
||||
chooser: string // Player ID who chose the number
|
||||
currentGuesser: string // Player ID whose turn it is to guess
|
||||
|
||||
// Round history
|
||||
guesses: Guess[]
|
||||
roundNumber: number
|
||||
|
||||
// Scores
|
||||
scores: Record<string, number>
|
||||
|
||||
// Game state
|
||||
gameStartTime: number | null
|
||||
gameEndTime: number | null
|
||||
winner: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Game moves
|
||||
*/
|
||||
export interface StartGameMove extends GameMove {
|
||||
type: 'START_GAME'
|
||||
data: {
|
||||
activePlayers: string[]
|
||||
playerMetadata: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChooseNumberMove extends GameMove {
|
||||
type: 'CHOOSE_NUMBER'
|
||||
data: {
|
||||
secretNumber: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface MakeGuessMove extends GameMove {
|
||||
type: 'MAKE_GUESS'
|
||||
data: {
|
||||
guess: number
|
||||
playerName: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface NextRoundMove extends GameMove {
|
||||
type: 'NEXT_ROUND'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface GoToSetupMove extends GameMove {
|
||||
type: 'GO_TO_SETUP'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface SetConfigMove extends GameMove {
|
||||
type: 'SET_CONFIG'
|
||||
data: {
|
||||
field: 'minNumber' | 'maxNumber' | 'roundsToWin'
|
||||
value: number
|
||||
}
|
||||
}
|
||||
|
||||
export type NumberGuesserMove =
|
||||
| StartGameMove
|
||||
| ChooseNumberMove
|
||||
| MakeGuessMove
|
||||
| NextRoundMove
|
||||
| GoToSetupMove
|
||||
| SetConfigMove
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { useGameMode } from '../contexts/GameModeContext'
|
||||
import { getAllGames } from '../lib/arcade/game-registry'
|
||||
import { GameCard } from './GameCard'
|
||||
|
||||
// Game configuration defining player limits
|
||||
@@ -70,7 +72,39 @@ export const GAMES_CONFIG = {
|
||||
},
|
||||
} as const
|
||||
|
||||
export type GameType = keyof typeof GAMES_CONFIG
|
||||
export type GameType = keyof typeof GAMES_CONFIG | string
|
||||
|
||||
/**
|
||||
* Get all games from both legacy config and new registry
|
||||
*/
|
||||
function getAllGameConfigs() {
|
||||
const legacyGames = Object.entries(GAMES_CONFIG).map(([gameType, config]) => ({
|
||||
gameType,
|
||||
config,
|
||||
}))
|
||||
|
||||
// Get games from registry and transform to legacy format
|
||||
const registryGames = getAllGames().map((gameDef) => ({
|
||||
gameType: gameDef.manifest.name,
|
||||
config: {
|
||||
name: gameDef.manifest.displayName,
|
||||
fullName: gameDef.manifest.displayName,
|
||||
maxPlayers: gameDef.manifest.maxPlayers,
|
||||
description: gameDef.manifest.description,
|
||||
longDescription: gameDef.manifest.longDescription,
|
||||
url: `/arcade/room?game=${gameDef.manifest.name}`, // Registry games load in room
|
||||
icon: gameDef.manifest.icon,
|
||||
chips: gameDef.manifest.chips,
|
||||
color: gameDef.manifest.color,
|
||||
gradient: gameDef.manifest.gradient,
|
||||
borderColor: gameDef.manifest.borderColor,
|
||||
difficulty: gameDef.manifest.difficulty,
|
||||
available: gameDef.manifest.available,
|
||||
},
|
||||
}))
|
||||
|
||||
return [...legacyGames, ...registryGames]
|
||||
}
|
||||
|
||||
interface GameSelectorProps {
|
||||
variant?: 'compact' | 'detailed'
|
||||
@@ -87,17 +121,17 @@ export function GameSelector({
|
||||
}: GameSelectorProps) {
|
||||
const { activePlayerCount } = useGameMode()
|
||||
|
||||
// Memoize the combined games list
|
||||
const allGames = useMemo(() => getAllGameConfigs(), [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css(
|
||||
{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
className
|
||||
)}
|
||||
className={`${css({
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
})} ${className || ''}`}
|
||||
>
|
||||
{showHeader && (
|
||||
<h3
|
||||
@@ -125,7 +159,7 @@ export function GameSelector({
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
{Object.entries(GAMES_CONFIG).map(([gameType, config]) => (
|
||||
{allGames.map(({ gameType, config }) => (
|
||||
<GameCard
|
||||
key={gameType}
|
||||
gameType={gameType as GameType}
|
||||
|
||||
@@ -34,7 +34,7 @@ export const arcadeRooms = sqliteTable('arcade_rooms', {
|
||||
|
||||
// Game configuration (nullable to support game selection in room)
|
||||
gameName: text('game_name', {
|
||||
enum: ['matching', 'memory-quiz', 'complement-race'],
|
||||
enum: ['matching', 'memory-quiz', 'complement-race', 'number-guesser'],
|
||||
}),
|
||||
gameConfig: text('game_config', { mode: 'json' }), // Game-specific settings (nullable when no game selected)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export const arcadeSessions = sqliteTable('arcade_sessions', {
|
||||
|
||||
// Session metadata
|
||||
currentGame: text('current_game', {
|
||||
enum: ['matching', 'memory-quiz', 'complement-race'],
|
||||
enum: ['matching', 'memory-quiz', 'complement-race', 'number-guesser'],
|
||||
}).notNull(),
|
||||
|
||||
gameUrl: text('game_url').notNull(), // e.g., '/arcade/matching'
|
||||
|
||||
@@ -20,7 +20,7 @@ export const roomGameConfigs = sqliteTable(
|
||||
|
||||
// Game identifier
|
||||
gameName: text('game_name', {
|
||||
enum: ['matching', 'memory-quiz', 'complement-race'],
|
||||
enum: ['matching', 'memory-quiz', 'complement-race', 'number-guesser'],
|
||||
}).notNull(),
|
||||
|
||||
// Game-specific configuration JSON
|
||||
|
||||
@@ -40,6 +40,15 @@ export interface ComplementRaceGameConfig {
|
||||
placeholder?: never
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for number-guesser game
|
||||
*/
|
||||
export interface NumberGuesserGameConfig {
|
||||
minNumber: number
|
||||
maxNumber: number
|
||||
roundsToWin: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type of all game configs for type-safe access
|
||||
*/
|
||||
@@ -47,6 +56,7 @@ export type GameConfigByName = {
|
||||
matching: MatchingGameConfig
|
||||
'memory-quiz': MemoryQuizGameConfig
|
||||
'complement-race': ComplementRaceGameConfig
|
||||
'number-guesser': NumberGuesserGameConfig
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,6 +67,7 @@ export interface RoomGameConfig {
|
||||
matching?: MatchingGameConfig
|
||||
'memory-quiz'?: MemoryQuizGameConfig
|
||||
'complement-race'?: ComplementRaceGameConfig
|
||||
'number-guesser'?: NumberGuesserGameConfig
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,3 +89,9 @@ export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
|
||||
export const DEFAULT_COMPLEMENT_RACE_CONFIG: ComplementRaceGameConfig = {
|
||||
// Future defaults will go here
|
||||
}
|
||||
|
||||
export const DEFAULT_NUMBER_GUESSER_CONFIG: NumberGuesserGameConfig = {
|
||||
minNumber: 1,
|
||||
maxNumber: 100,
|
||||
roundsToWin: 3,
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
* Games are explicitly registered here after being defined.
|
||||
*/
|
||||
|
||||
import type { GameDefinition } from './game-sdk/types'
|
||||
import type { GameConfig, GameDefinition, GameMove, GameState } from './game-sdk/types'
|
||||
|
||||
/**
|
||||
* Global game registry
|
||||
* Maps game name to game definition
|
||||
* Using `any` for generics to allow different game types
|
||||
*/
|
||||
const registry = new Map<string, GameDefinition>()
|
||||
const registry = new Map<string, GameDefinition<any, any, any>>()
|
||||
|
||||
/**
|
||||
* Register a game in the registry
|
||||
@@ -19,7 +20,11 @@ const registry = new Map<string, GameDefinition>()
|
||||
* @param game - Game definition to register
|
||||
* @throws Error if game with same name already registered
|
||||
*/
|
||||
export function registerGame(game: GameDefinition): void {
|
||||
export function registerGame<
|
||||
TConfig extends GameConfig,
|
||||
TState extends GameState,
|
||||
TMove extends GameMove,
|
||||
>(game: GameDefinition<TConfig, TState, TMove>): void {
|
||||
const { name } = game.manifest
|
||||
|
||||
if (registry.has(name)) {
|
||||
@@ -36,7 +41,7 @@ export function registerGame(game: GameDefinition): void {
|
||||
* @param gameName - Internal game identifier
|
||||
* @returns Game definition or undefined if not found
|
||||
*/
|
||||
export function getGame(gameName: string): GameDefinition | undefined {
|
||||
export function getGame(gameName: string): GameDefinition<any, any, any> | undefined {
|
||||
return registry.get(gameName)
|
||||
}
|
||||
|
||||
@@ -45,7 +50,7 @@ export function getGame(gameName: string): GameDefinition | undefined {
|
||||
*
|
||||
* @returns Array of all game definitions
|
||||
*/
|
||||
export function getAllGames(): GameDefinition[] {
|
||||
export function getAllGames(): GameDefinition<any, any, any>[] {
|
||||
return Array.from(registry.values())
|
||||
}
|
||||
|
||||
@@ -54,7 +59,7 @@ export function getAllGames(): GameDefinition[] {
|
||||
*
|
||||
* @returns Array of available game definitions
|
||||
*/
|
||||
export function getAvailableGames(): GameDefinition[] {
|
||||
export function getAvailableGames(): GameDefinition<any, any, any>[] {
|
||||
return getAllGames().filter((game) => game.manifest.available)
|
||||
}
|
||||
|
||||
@@ -74,3 +79,11 @@ export function hasGame(gameName: string): boolean {
|
||||
export function clearRegistry(): void {
|
||||
registry.clear()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Game Registrations
|
||||
// ============================================================================
|
||||
|
||||
import { numberGuesserGame } from '@/arcade-games/number-guesser'
|
||||
|
||||
registerGame(numberGuesserGame)
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
|
||||
import { matchingGameValidator } from './MatchingGameValidator'
|
||||
import { memoryQuizGameValidator } from './MemoryQuizGameValidator'
|
||||
import { numberGuesserValidator } from '@/arcade-games/number-guesser/Validator'
|
||||
import type { GameName, GameValidator } from './types'
|
||||
|
||||
const validators = new Map<GameName, GameValidator>([
|
||||
['matching', matchingGameValidator],
|
||||
['memory-quiz', memoryQuizGameValidator],
|
||||
['number-guesser', numberGuesserValidator],
|
||||
// Add other game validators here as they're implemented
|
||||
])
|
||||
|
||||
@@ -23,4 +25,5 @@ export function getValidator(gameName: GameName): GameValidator {
|
||||
|
||||
export { matchingGameValidator } from './MatchingGameValidator'
|
||||
export { memoryQuizGameValidator } from './MemoryQuizGameValidator'
|
||||
export { numberGuesserValidator } from '@/arcade-games/number-guesser/Validator'
|
||||
export * from './types'
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import type { MemoryPairsState } from '@/app/games/matching/context/types'
|
||||
import type { SorobanQuizState } from '@/app/arcade/memory-quiz/types'
|
||||
|
||||
export type GameName = 'matching' | 'memory-quiz' | 'complement-race'
|
||||
export type GameName = 'matching' | 'memory-quiz' | 'complement-race' | 'number-guesser'
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean
|
||||
|
||||
Reference in New Issue
Block a user