diff --git a/apps/web/package.json b/apps/web/package.json index 7145f8e6..cb09c427 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,8 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "concurrently \"node server.js\" \"npx @pandacss/dev --watch\"", - "build": "node scripts/generate-build-info.js && next build", + "dev": "tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && concurrently \"node server.js\" \"npx @pandacss/dev --watch\"", + "build": "node scripts/generate-build-info.js && tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && next build", "start": "NODE_ENV=production node server.js", "lint": "npx @biomejs/biome lint . && npx eslint .", "lint:fix": "npx @biomejs/biome lint . --write && npx eslint . --fix", @@ -90,6 +90,7 @@ "happy-dom": "^18.0.1", "jsdom": "^27.0.0", "storybook": "^9.1.7", + "tsc-alias": "^1.8.16", "tsx": "^4.20.5", "typescript": "^5.0.0", "vitest": "^1.0.0" diff --git a/apps/web/server.js b/apps/web/server.js index 3dfb494a..11ee74ed 100644 --- a/apps/web/server.js +++ b/apps/web/server.js @@ -11,9 +11,8 @@ const handle = app.getRequestHandler() // Run migrations before starting server console.log('🔄 Running database migrations...') -require('tsx/cjs') const { migrate } = require('drizzle-orm/better-sqlite3/migrator') -const { db } = require('./src/db/index.ts') +const { db } = require('./src/db/index.js') try { migrate(db, { migrationsFolder: './drizzle' }) @@ -35,9 +34,8 @@ app.prepare().then(() => { } }) - // Initialize Socket.IO (load TypeScript with tsx) - require('tsx/cjs') - const { initializeSocketServer } = require('./socket-server.ts') + // Initialize Socket.IO + const { initializeSocketServer } = require('./socket-server.js') initializeSocketServer(server) server diff --git a/apps/web/socket-server.js b/apps/web/socket-server.js new file mode 100644 index 00000000..55c0b451 --- /dev/null +++ b/apps/web/socket-server.js @@ -0,0 +1,319 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getSocketIO = getSocketIO; +exports.initializeSocketServer = initializeSocketServer; +const socket_io_1 = require("socket.io"); +const session_manager_1 = require("./src/lib/arcade/session-manager"); +const room_manager_1 = require("./src/lib/arcade/room-manager"); +const room_membership_1 = require("./src/lib/arcade/room-membership"); +const player_manager_1 = require("./src/lib/arcade/player-manager"); +const MatchingGameValidator_1 = require("./src/lib/arcade/validation/MatchingGameValidator"); +/** + * Get the socket.io server instance + * Returns null if not initialized + */ +function getSocketIO() { + return globalThis.__socketIO || null; +} +function initializeSocketServer(httpServer) { + const io = new socket_io_1.Server(httpServer, { + path: '/api/socket', + cors: { + origin: process.env.NEXT_PUBLIC_URL || 'http://localhost:3000', + credentials: true, + }, + }); + io.on('connection', (socket) => { + console.log('🔌 Client connected:', socket.id); + let currentUserId = null; + // Join arcade session room + socket.on('join-arcade-session', async ({ userId, roomId }) => { + currentUserId = userId; + socket.join(`arcade:${userId}`); + console.log(`👤 User ${userId} joined arcade room`); + // If this session is part of a room, also join the game room for multi-user sync + if (roomId) { + socket.join(`game:${roomId}`); + console.log(`🎮 User ${userId} joined game room ${roomId}`); + } + // Send current session state if exists + // For room-based games, look up shared room session + try { + const session = roomId + ? await (0, session_manager_1.getArcadeSessionByRoom)(roomId) + : await (0, session_manager_1.getArcadeSession)(userId); + if (session) { + console.log('[join-arcade-session] Found session:', { + userId, + roomId, + version: session.version, + sessionUserId: session.userId, + }); + socket.emit('session-state', { + gameState: session.gameState, + currentGame: session.currentGame, + gameUrl: session.gameUrl, + activePlayers: session.activePlayers, + version: session.version, + }); + } + else { + console.log('[join-arcade-session] No active session found for:', { + userId, + roomId, + }); + socket.emit('no-active-session'); + } + } + catch (error) { + console.error('Error fetching session:', error); + socket.emit('session-error', { error: 'Failed to fetch session' }); + } + }); + // Handle game moves + socket.on('game-move', async (data) => { + console.log('🎮 Game move received:', { + userId: data.userId, + moveType: data.move.type, + playerId: data.move.playerId, + timestamp: data.move.timestamp, + roomId: data.roomId, + fullMove: JSON.stringify(data.move, null, 2), + }); + try { + // Special handling for START_GAME - create session if it doesn't exist + if (data.move.type === 'START_GAME') { + // For room-based games, check if room session exists + const existingSession = data.roomId + ? await (0, session_manager_1.getArcadeSessionByRoom)(data.roomId) + : await (0, session_manager_1.getArcadeSession)(data.userId); + if (!existingSession) { + console.log('🎯 Creating new session for START_GAME'); + // activePlayers must be provided in the START_GAME move data + const activePlayers = data.move.data?.activePlayers; + if (!activePlayers || activePlayers.length === 0) { + console.error('❌ START_GAME move missing activePlayers'); + socket.emit('move-rejected', { + error: 'START_GAME requires at least one active player', + move: data.move, + }); + return; + } + // Get initial state from validator + const initialState = MatchingGameValidator_1.matchingGameValidator.getInitialState({ + difficulty: 6, + gameType: 'abacus-numeral', + turnTimer: 30, + }); + // Check if user is already in a room for this game + const userRoomIds = await (0, room_membership_1.getUserRooms)(data.userId); + let room = null; + // Look for an existing active room for this game + for (const roomId of userRoomIds) { + const existingRoom = await (0, room_manager_1.getRoomById)(roomId); + if (existingRoom && + existingRoom.gameName === 'matching' && + existingRoom.status !== 'finished') { + room = existingRoom; + console.log('🏠 Using existing room:', room.code); + break; + } + } + // If no suitable room exists, create a new one + if (!room) { + room = await (0, room_manager_1.createRoom)({ + name: 'Auto-generated Room', + createdBy: data.userId, + creatorName: 'Player', + gameName: 'matching', + gameConfig: { + difficulty: 6, + gameType: 'abacus-numeral', + turnTimer: 30, + }, + ttlMinutes: 60, + }); + console.log('🏠 Created new room:', room.code); + } + // Now create the session linked to the room + await (0, session_manager_1.createArcadeSession)({ + userId: data.userId, + gameName: 'matching', + gameUrl: '/arcade/room', // Room-based sessions use /arcade/room + initialState, + activePlayers, + roomId: room.id, + }); + console.log('✅ Session created successfully with room association'); + // Notify all connected clients about the new session + const newSession = await (0, session_manager_1.getArcadeSession)(data.userId); + if (newSession) { + io.to(`arcade:${data.userId}`).emit('session-state', { + gameState: newSession.gameState, + currentGame: newSession.currentGame, + gameUrl: newSession.gameUrl, + activePlayers: newSession.activePlayers, + version: newSession.version, + }); + console.log('📢 Emitted session-state to notify clients of new session'); + } + } + } + // Apply game move - use roomId for room-based games to access shared session + const result = await (0, session_manager_1.applyGameMove)(data.userId, data.move, data.roomId); + if (result.success && result.session) { + const moveAcceptedData = { + gameState: result.session.gameState, + version: result.session.version, + move: data.move, + }; + // Broadcast the updated state to all devices for this user + io.to(`arcade:${data.userId}`).emit('move-accepted', moveAcceptedData); + // If this is a room-based session, ALSO broadcast to all users in the room + if (result.session.roomId) { + io.to(`game:${result.session.roomId}`).emit('move-accepted', moveAcceptedData); + console.log(`📢 Broadcasted move to game room ${result.session.roomId}`); + } + // Update activity timestamp + await (0, session_manager_1.updateSessionActivity)(data.userId); + } + else { + // Send rejection only to the requesting socket + socket.emit('move-rejected', { + error: result.error, + move: data.move, + versionConflict: result.versionConflict, + }); + } + } + catch (error) { + console.error('Error processing move:', error); + socket.emit('move-rejected', { + error: 'Server error processing move', + move: data.move, + }); + } + }); + // Handle session exit + socket.on('exit-arcade-session', async ({ userId }) => { + console.log('🚪 User exiting arcade session:', userId); + try { + await (0, session_manager_1.deleteArcadeSession)(userId); + io.to(`arcade:${userId}`).emit('session-ended'); + } + catch (error) { + console.error('Error ending session:', error); + socket.emit('session-error', { error: 'Failed to end session' }); + } + }); + // Keep-alive ping + socket.on('ping-session', async ({ userId }) => { + try { + await (0, session_manager_1.updateSessionActivity)(userId); + socket.emit('pong-session'); + } + catch (error) { + console.error('Error updating activity:', error); + } + }); + // Room: Join + socket.on('join-room', async ({ roomId, userId }) => { + console.log(`🏠 User ${userId} joining room ${roomId}`); + try { + // Join the socket room + socket.join(`room:${roomId}`); + // Mark member as online + await (0, room_membership_1.setMemberOnline)(roomId, userId, true); + // Get room data + const members = await (0, room_membership_1.getRoomMembers)(roomId); + const memberPlayers = await (0, player_manager_1.getRoomActivePlayers)(roomId); + // Convert memberPlayers Map to object for JSON serialization + const memberPlayersObj = {}; + for (const [uid, players] of memberPlayers.entries()) { + memberPlayersObj[uid] = players; + } + // Send current room state to the joining user + socket.emit('room-joined', { + roomId, + members, + memberPlayers: memberPlayersObj, + }); + // Notify all other members in the room + socket.to(`room:${roomId}`).emit('member-joined', { + roomId, + userId, + members, + memberPlayers: memberPlayersObj, + }); + console.log(`✅ User ${userId} joined room ${roomId}`); + } + catch (error) { + console.error('Error joining room:', error); + socket.emit('room-error', { error: 'Failed to join room' }); + } + }); + // Room: Leave + socket.on('leave-room', async ({ roomId, userId }) => { + console.log(`🚪 User ${userId} leaving room ${roomId}`); + try { + // Leave the socket room + socket.leave(`room:${roomId}`); + // Mark member as offline + await (0, room_membership_1.setMemberOnline)(roomId, userId, false); + // Get updated members + const members = await (0, room_membership_1.getRoomMembers)(roomId); + const memberPlayers = await (0, player_manager_1.getRoomActivePlayers)(roomId); + // Convert memberPlayers Map to object + const memberPlayersObj = {}; + for (const [uid, players] of memberPlayers.entries()) { + memberPlayersObj[uid] = players; + } + // Notify remaining members + io.to(`room:${roomId}`).emit('member-left', { + roomId, + userId, + members, + memberPlayers: memberPlayersObj, + }); + console.log(`✅ User ${userId} left room ${roomId}`); + } + catch (error) { + console.error('Error leaving room:', error); + } + }); + // Room: Players updated + socket.on('players-updated', async ({ roomId, userId }) => { + console.log(`🎯 Players updated for user ${userId} in room ${roomId}`); + try { + // Get updated player data + const memberPlayers = await (0, player_manager_1.getRoomActivePlayers)(roomId); + // Convert memberPlayers Map to object + const memberPlayersObj = {}; + for (const [uid, players] of memberPlayers.entries()) { + memberPlayersObj[uid] = players; + } + // Broadcast to all members in the room (including sender) + io.to(`room:${roomId}`).emit('room-players-updated', { + roomId, + memberPlayers: memberPlayersObj, + }); + console.log(`✅ Broadcasted player updates for room ${roomId}`); + } + catch (error) { + console.error('Error updating room players:', error); + socket.emit('room-error', { error: 'Failed to update players' }); + } + }); + socket.on('disconnect', () => { + console.log('🔌 Client disconnected:', socket.id); + if (currentUserId) { + // Don't delete session on disconnect - it persists across devices + console.log(`👤 User ${currentUserId} disconnected but session persists`); + } + }); + }); + // Store in globalThis to make accessible across module boundaries + globalThis.__socketIO = io; + console.log('✅ Socket.IO initialized on /api/socket'); + return io; +} diff --git a/apps/web/src/app/games/matching/context/types.js b/apps/web/src/app/games/matching/context/types.js new file mode 100644 index 00000000..2fb77ec4 --- /dev/null +++ b/apps/web/src/app/games/matching/context/types.js @@ -0,0 +1,3 @@ +"use strict"; +// TypeScript interfaces for Memory Pairs Challenge game +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/apps/web/src/app/games/matching/utils/cardGeneration.js b/apps/web/src/app/games/matching/utils/cardGeneration.js new file mode 100644 index 00000000..2453060e --- /dev/null +++ b/apps/web/src/app/games/matching/utils/cardGeneration.js @@ -0,0 +1,164 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.generateAbacusNumeralCards = generateAbacusNumeralCards; +exports.generateComplementCards = generateComplementCards; +exports.generateGameCards = generateGameCards; +exports.getGridConfiguration = getGridConfiguration; +exports.generateCardId = generateCardId; +// Utility function to generate unique random numbers +function generateUniqueNumbers(count, options) { + const numbers = new Set(); + const { min, max } = options; + while (numbers.size < count) { + const randomNum = Math.floor(Math.random() * (max - min + 1)) + min; + numbers.add(randomNum); + } + return Array.from(numbers); +} +// Utility function to shuffle an array +function shuffleArray(array) { + const shuffled = [...array]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; +} +// Generate cards for abacus-numeral game mode +function generateAbacusNumeralCards(pairs) { + // Generate unique numbers based on difficulty + // For easier games, use smaller numbers; for harder games, use larger ranges + const numberRanges = { + 6: { min: 1, max: 50 }, // 6 pairs: 1-50 + 8: { min: 1, max: 100 }, // 8 pairs: 1-100 + 12: { min: 1, max: 200 }, // 12 pairs: 1-200 + 15: { min: 1, max: 300 }, // 15 pairs: 1-300 + }; + const range = numberRanges[pairs]; + const numbers = generateUniqueNumbers(pairs, range); + const cards = []; + numbers.forEach((number) => { + // Abacus representation card + cards.push({ + id: `abacus_${number}`, + type: 'abacus', + number, + matched: false, + }); + // Numerical representation card + cards.push({ + id: `number_${number}`, + type: 'number', + number, + matched: false, + }); + }); + return shuffleArray(cards); +} +// Generate cards for complement pairs game mode +function generateComplementCards(pairs) { + // Define complement pairs for friends of 5 and friends of 10 + const complementPairs = [ + // Friends of 5 + { pair: [0, 5], targetSum: 5 }, + { pair: [1, 4], targetSum: 5 }, + { pair: [2, 3], targetSum: 5 }, + // Friends of 10 + { pair: [0, 10], targetSum: 10 }, + { pair: [1, 9], targetSum: 10 }, + { pair: [2, 8], targetSum: 10 }, + { pair: [3, 7], targetSum: 10 }, + { pair: [4, 6], targetSum: 10 }, + { pair: [5, 5], targetSum: 10 }, + // Additional pairs for higher difficulties + { pair: [6, 4], targetSum: 10 }, + { pair: [7, 3], targetSum: 10 }, + { pair: [8, 2], targetSum: 10 }, + { pair: [9, 1], targetSum: 10 }, + { pair: [10, 0], targetSum: 10 }, + // More challenging pairs (can be used for expert mode) + { pair: [11, 9], targetSum: 20 }, + { pair: [12, 8], targetSum: 20 }, + ]; + // Select the required number of complement pairs + const selectedPairs = complementPairs.slice(0, pairs); + const cards = []; + selectedPairs.forEach(({ pair: [num1, num2], targetSum }, index) => { + // First number in the pair + cards.push({ + id: `comp1_${index}_${num1}`, + type: 'complement', + number: num1, + complement: num2, + targetSum, + matched: false, + }); + // Second number in the pair + cards.push({ + id: `comp2_${index}_${num2}`, + type: 'complement', + number: num2, + complement: num1, + targetSum, + matched: false, + }); + }); + return shuffleArray(cards); +} +// Main card generation function +function generateGameCards(gameType, difficulty) { + switch (gameType) { + case 'abacus-numeral': + return generateAbacusNumeralCards(difficulty); + case 'complement-pairs': + return generateComplementCards(difficulty); + default: + throw new Error(`Unknown game type: ${gameType}`); + } +} +// Utility function to get responsive grid configuration based on difficulty and screen size +function getGridConfiguration(difficulty) { + const configs = { + 6: { + totalCards: 12, + mobileColumns: 3, // 3x4 grid in portrait + tabletColumns: 4, // 4x3 grid on tablet + desktopColumns: 4, // 4x3 grid on desktop + landscapeColumns: 6, // 6x2 grid in landscape + cardSize: { width: '140px', height: '180px' }, + gridTemplate: 'repeat(3, 1fr)', + }, + 8: { + totalCards: 16, + mobileColumns: 3, // 3x6 grid in portrait (some spillover) + tabletColumns: 4, // 4x4 grid on tablet + desktopColumns: 4, // 4x4 grid on desktop + landscapeColumns: 6, // 6x3 grid in landscape (some spillover) + cardSize: { width: '120px', height: '160px' }, + gridTemplate: 'repeat(3, 1fr)', + }, + 12: { + totalCards: 24, + mobileColumns: 3, // 3x8 grid in portrait + tabletColumns: 4, // 4x6 grid on tablet + desktopColumns: 6, // 6x4 grid on desktop + landscapeColumns: 6, // 6x4 grid in landscape (changed from 8x3) + cardSize: { width: '100px', height: '140px' }, + gridTemplate: 'repeat(3, 1fr)', + }, + 15: { + totalCards: 30, + mobileColumns: 3, // 3x10 grid in portrait + tabletColumns: 5, // 5x6 grid on tablet + desktopColumns: 6, // 6x5 grid on desktop + landscapeColumns: 10, // 10x3 grid in landscape + cardSize: { width: '90px', height: '120px' }, + gridTemplate: 'repeat(3, 1fr)', + }, + }; + return configs[difficulty]; +} +// Generate a unique ID for cards +function generateCardId(type, identifier) { + return `${type}_${identifier}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; +} diff --git a/apps/web/src/app/games/matching/utils/matchValidation.js b/apps/web/src/app/games/matching/utils/matchValidation.js new file mode 100644 index 00000000..2b8cd9c5 --- /dev/null +++ b/apps/web/src/app/games/matching/utils/matchValidation.js @@ -0,0 +1,188 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.validateAbacusNumeralMatch = validateAbacusNumeralMatch; +exports.validateComplementMatch = validateComplementMatch; +exports.validateMatch = validateMatch; +exports.canFlipCard = canFlipCard; +exports.getMatchHint = getMatchHint; +exports.calculateMatchScore = calculateMatchScore; +exports.analyzeGamePerformance = analyzeGamePerformance; +// Validate abacus-numeral match (abacus card matches with number card of same value) +function validateAbacusNumeralMatch(card1, card2) { + // Both cards must have the same number + if (card1.number !== card2.number) { + return { + isValid: false, + reason: 'Numbers do not match', + type: 'invalid', + }; + } + // Cards must be different types (one abacus, one number) + if (card1.type === card2.type) { + return { + isValid: false, + reason: 'Both cards are the same type', + type: 'invalid', + }; + } + // One must be abacus, one must be number + const hasAbacus = card1.type === 'abacus' || card2.type === 'abacus'; + const hasNumber = card1.type === 'number' || card2.type === 'number'; + if (!hasAbacus || !hasNumber) { + return { + isValid: false, + reason: 'Must match abacus with number representation', + type: 'invalid', + }; + } + // Neither should be complement type for this game mode + if (card1.type === 'complement' || card2.type === 'complement') { + return { + isValid: false, + reason: 'Complement cards not valid in abacus-numeral mode', + type: 'invalid', + }; + } + return { + isValid: true, + type: 'abacus-numeral', + }; +} +// Validate complement match (two numbers that add up to target sum) +function validateComplementMatch(card1, card2) { + // Both cards must be complement type + if (card1.type !== 'complement' || card2.type !== 'complement') { + return { + isValid: false, + reason: 'Both cards must be complement type', + type: 'invalid', + }; + } + // Both cards must have the same target sum + if (card1.targetSum !== card2.targetSum) { + return { + isValid: false, + reason: 'Cards have different target sums', + type: 'invalid', + }; + } + // Check if the numbers are actually complements + if (!card1.complement || !card2.complement) { + return { + isValid: false, + reason: 'Complement information missing', + type: 'invalid', + }; + } + // Verify the complement relationship + if (card1.number !== card2.complement || card2.number !== card1.complement) { + return { + isValid: false, + reason: 'Numbers are not complements of each other', + type: 'invalid', + }; + } + // Verify the sum equals the target + const sum = card1.number + card2.number; + if (sum !== card1.targetSum) { + return { + isValid: false, + reason: `Sum ${sum} does not equal target ${card1.targetSum}`, + type: 'invalid', + }; + } + return { + isValid: true, + type: 'complement', + }; +} +// Main validation function that determines which validation to use +function validateMatch(card1, card2) { + // Cannot match the same card with itself + if (card1.id === card2.id) { + return { + isValid: false, + reason: 'Cannot match card with itself', + type: 'invalid', + }; + } + // Cannot match already matched cards + if (card1.matched || card2.matched) { + return { + isValid: false, + reason: 'Cannot match already matched cards', + type: 'invalid', + }; + } + // Determine which type of match to validate based on card types + const hasComplement = card1.type === 'complement' || card2.type === 'complement'; + if (hasComplement) { + // If either card is complement type, use complement validation + return validateComplementMatch(card1, card2); + } + else { + // Otherwise, use abacus-numeral validation + return validateAbacusNumeralMatch(card1, card2); + } +} +// Helper function to check if a card can be flipped +function canFlipCard(card, flippedCards, isProcessingMove) { + // Cannot flip if processing a move + if (isProcessingMove) + return false; + // Cannot flip already matched cards + if (card.matched) + return false; + // Cannot flip if already flipped + if (flippedCards.some((c) => c.id === card.id)) + return false; + // Cannot flip if two cards are already flipped + if (flippedCards.length >= 2) + return false; + return true; +} +// Get hint for what kind of match the player should look for +function getMatchHint(card) { + switch (card.type) { + case 'abacus': + return `Find the number ${card.number}`; + case 'number': + return `Find the abacus showing ${card.number}`; + case 'complement': + if (card.complement !== undefined && card.targetSum !== undefined) { + return `Find ${card.complement} to make ${card.targetSum}`; + } + return 'Find the matching complement'; + default: + return 'Find the matching card'; + } +} +// Calculate match score based on difficulty and time +function calculateMatchScore(difficulty, timeForMatch, isComplementMatch) { + const baseScore = isComplementMatch ? 15 : 10; // Complement matches worth more + const difficultyMultiplier = difficulty / 6; // Scale with difficulty + const timeBonus = Math.max(0, (10000 - timeForMatch) / 1000); // Bonus for speed + return Math.round(baseScore * difficultyMultiplier + timeBonus); +} +// Analyze game performance +function analyzeGamePerformance(totalMoves, matchedPairs, totalPairs, gameTime) { + const accuracy = totalMoves > 0 ? (matchedPairs / totalMoves) * 100 : 0; + const efficiency = totalPairs > 0 ? (matchedPairs / (totalPairs * 2)) * 100 : 0; // Ideal is 100% (each pair found in 2 moves) + const averageTimePerMove = totalMoves > 0 ? gameTime / totalMoves : 0; + // Calculate grade based on accuracy and efficiency + let grade = 'F'; + if (accuracy >= 90 && efficiency >= 80) + grade = 'A'; + else if (accuracy >= 80 && efficiency >= 70) + grade = 'B'; + else if (accuracy >= 70 && efficiency >= 60) + grade = 'C'; + else if (accuracy >= 60 && efficiency >= 50) + grade = 'D'; + return { + accuracy, + efficiency, + averageTimePerMove, + grade, + }; +} diff --git a/apps/web/src/db/index.js b/apps/web/src/db/index.js new file mode 100644 index 00000000..23d047a3 --- /dev/null +++ b/apps/web/src/db/index.js @@ -0,0 +1,80 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.schema = exports.db = void 0; +const better_sqlite3_1 = __importDefault(require("better-sqlite3")); +const better_sqlite3_2 = require("drizzle-orm/better-sqlite3"); +const schema = __importStar(require("./schema")); +exports.schema = schema; +/** + * Database connection and client + * + * Creates a singleton SQLite connection with Drizzle ORM. + * Enables foreign key constraints (required for cascading deletes). + * + * IMPORTANT: The database connection is lazy-loaded to avoid accessing + * the database at module import time, which would cause build failures + * when the database doesn't exist (e.g., in CI/CD environments). + */ +const databaseUrl = process.env.DATABASE_URL || './data/sqlite.db'; +let _sqlite = null; +let _db = null; +/** + * Get the database connection (lazy-loaded singleton) + * Only creates the connection when first accessed at runtime + */ +function getDb() { + if (!_db) { + _sqlite = new better_sqlite3_1.default(databaseUrl); + // Enable foreign keys (SQLite requires explicit enable) + _sqlite.pragma('foreign_keys = ON'); + // Enable WAL mode for better concurrency + _sqlite.pragma('journal_mode = WAL'); + _db = (0, better_sqlite3_2.drizzle)(_sqlite, { schema }); + } + return _db; +} +/** + * Database client instance + * Uses a Proxy to lazy-load the connection on first access + */ +exports.db = new Proxy({}, { + get(_target, prop) { + return getDb()[prop]; + }, +}); diff --git a/apps/web/src/db/migrate.js b/apps/web/src/db/migrate.js new file mode 100644 index 00000000..e5eb1451 --- /dev/null +++ b/apps/web/src/db/migrate.js @@ -0,0 +1,22 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const migrator_1 = require("drizzle-orm/better-sqlite3/migrator"); +const index_1 = require("./index"); +/** + * Migration runner + * + * Runs all pending migrations in the drizzle/ folder. + * Safe to run multiple times (migrations are idempotent). + * + * Usage: pnpm db:migrate + */ +try { + console.log('🔄 Running migrations...'); + (0, migrator_1.migrate)(index_1.db, { migrationsFolder: './drizzle' }); + console.log('✅ Migrations complete'); + process.exit(0); +} +catch (error) { + console.error('❌ Migration failed:', error); + process.exit(1); +} diff --git a/apps/web/src/db/schema/abacus-settings.js b/apps/web/src/db/schema/abacus-settings.js new file mode 100644 index 00000000..ee8d308a --- /dev/null +++ b/apps/web/src/db/schema/abacus-settings.js @@ -0,0 +1,53 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.abacusSettings = void 0; +const sqlite_core_1 = require("drizzle-orm/sqlite-core"); +const users_1 = require("./users"); +/** + * Abacus display settings table - UI preferences per user + * + * One-to-one with users table. Stores abacus display configuration. + * Deleted when user is deleted (cascade). + */ +exports.abacusSettings = (0, sqlite_core_1.sqliteTable)('abacus_settings', { + /** Primary key and foreign key to users table */ + userId: (0, sqlite_core_1.text)('user_id') + .primaryKey() + .references(() => users_1.users.id, { onDelete: 'cascade' }), + /** Color scheme for beads */ + colorScheme: (0, sqlite_core_1.text)('color_scheme', { + enum: ['monochrome', 'place-value', 'heaven-earth', 'alternating'], + }) + .notNull() + .default('place-value'), + /** Bead shape */ + beadShape: (0, sqlite_core_1.text)('bead_shape', { + enum: ['diamond', 'circle', 'square'], + }) + .notNull() + .default('diamond'), + /** Color palette */ + colorPalette: (0, sqlite_core_1.text)('color_palette', { + enum: ['default', 'colorblind', 'mnemonic', 'grayscale', 'nature'], + }) + .notNull() + .default('default'), + /** Hide inactive beads */ + hideInactiveBeads: (0, sqlite_core_1.integer)('hide_inactive_beads', { mode: 'boolean' }).notNull().default(false), + /** Color numerals based on place value */ + coloredNumerals: (0, sqlite_core_1.integer)('colored_numerals', { mode: 'boolean' }).notNull().default(false), + /** Scale factor for abacus size */ + scaleFactor: (0, sqlite_core_1.real)('scale_factor').notNull().default(1.0), + /** Show numbers below abacus */ + showNumbers: (0, sqlite_core_1.integer)('show_numbers', { mode: 'boolean' }).notNull().default(true), + /** Enable animations */ + animated: (0, sqlite_core_1.integer)('animated', { mode: 'boolean' }).notNull().default(true), + /** Enable interaction */ + interactive: (0, sqlite_core_1.integer)('interactive', { mode: 'boolean' }).notNull().default(false), + /** Enable gesture controls */ + gestures: (0, sqlite_core_1.integer)('gestures', { mode: 'boolean' }).notNull().default(false), + /** Enable sound effects */ + soundEnabled: (0, sqlite_core_1.integer)('sound_enabled', { mode: 'boolean' }).notNull().default(true), + /** Sound volume (0.0 - 1.0) */ + soundVolume: (0, sqlite_core_1.real)('sound_volume').notNull().default(0.8), +}); diff --git a/apps/web/src/db/schema/arcade-rooms.js b/apps/web/src/db/schema/arcade-rooms.js new file mode 100644 index 00000000..79860417 --- /dev/null +++ b/apps/web/src/db/schema/arcade-rooms.js @@ -0,0 +1,39 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.arcadeRooms = void 0; +const cuid2_1 = require("@paralleldrive/cuid2"); +const sqlite_core_1 = require("drizzle-orm/sqlite-core"); +exports.arcadeRooms = (0, sqlite_core_1.sqliteTable)('arcade_rooms', { + id: (0, sqlite_core_1.text)('id') + .primaryKey() + .$defaultFn(() => (0, cuid2_1.createId)()), + // Room identity + code: (0, sqlite_core_1.text)('code', { length: 6 }).notNull().unique(), // e.g., "ABC123" + name: (0, sqlite_core_1.text)('name', { length: 50 }).notNull(), + // Creator info + createdBy: (0, sqlite_core_1.text)('created_by').notNull(), // User/guest ID + creatorName: (0, sqlite_core_1.text)('creator_name', { length: 50 }).notNull(), + createdAt: (0, sqlite_core_1.integer)('created_at', { mode: 'timestamp' }) + .notNull() + .$defaultFn(() => new Date()), + // Lifecycle + lastActivity: (0, sqlite_core_1.integer)('last_activity', { mode: 'timestamp' }) + .notNull() + .$defaultFn(() => new Date()), + ttlMinutes: (0, sqlite_core_1.integer)('ttl_minutes').notNull().default(60), // Time to live + isLocked: (0, sqlite_core_1.integer)('is_locked', { mode: 'boolean' }).notNull().default(false), + // Game configuration + gameName: (0, sqlite_core_1.text)('game_name', { + enum: ['matching', 'memory-quiz', 'complement-race'], + }).notNull(), + gameConfig: (0, sqlite_core_1.text)('game_config', { mode: 'json' }).notNull(), // Game-specific settings + // Current state + status: (0, sqlite_core_1.text)('status', { + enum: ['lobby', 'playing', 'finished'], + }) + .notNull() + .default('lobby'), + currentSessionId: (0, sqlite_core_1.text)('current_session_id'), // FK to arcade_sessions (nullable) + // Metadata + totalGamesPlayed: (0, sqlite_core_1.integer)('total_games_played').notNull().default(0), +}); diff --git a/apps/web/src/db/schema/arcade-sessions.js b/apps/web/src/db/schema/arcade-sessions.js new file mode 100644 index 00000000..70e394e5 --- /dev/null +++ b/apps/web/src/db/schema/arcade-sessions.js @@ -0,0 +1,30 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.arcadeSessions = void 0; +const sqlite_core_1 = require("drizzle-orm/sqlite-core"); +const arcade_rooms_1 = require("./arcade-rooms"); +const users_1 = require("./users"); +exports.arcadeSessions = (0, sqlite_core_1.sqliteTable)('arcade_sessions', { + userId: (0, sqlite_core_1.text)('user_id') + .primaryKey() + .references(() => users_1.users.id, { onDelete: 'cascade' }), + // Session metadata + currentGame: (0, sqlite_core_1.text)('current_game', { + enum: ['matching', 'memory-quiz', 'complement-race'], + }).notNull(), + gameUrl: (0, sqlite_core_1.text)('game_url').notNull(), // e.g., '/arcade/matching' + // Game state (JSON blob) + gameState: (0, sqlite_core_1.text)('game_state', { mode: 'json' }).notNull(), + // Active players snapshot (for quick access) + activePlayers: (0, sqlite_core_1.text)('active_players', { mode: 'json' }).notNull(), + // Room association (null for solo play) + roomId: (0, sqlite_core_1.text)('room_id').references(() => arcade_rooms_1.arcadeRooms.id, { onDelete: 'set null' }), + // Timing & TTL + startedAt: (0, sqlite_core_1.integer)('started_at', { mode: 'timestamp' }).notNull(), + lastActivityAt: (0, sqlite_core_1.integer)('last_activity_at', { mode: 'timestamp' }).notNull(), + expiresAt: (0, sqlite_core_1.integer)('expires_at', { mode: 'timestamp' }).notNull(), // TTL-based + // Status + isActive: (0, sqlite_core_1.integer)('is_active', { mode: 'boolean' }).notNull().default(true), + // Version for optimistic locking + version: (0, sqlite_core_1.integer)('version').notNull().default(1), +}); diff --git a/apps/web/src/db/schema/index.js b/apps/web/src/db/schema/index.js new file mode 100644 index 00000000..49217572 --- /dev/null +++ b/apps/web/src/db/schema/index.js @@ -0,0 +1,29 @@ +"use strict"; +/** + * Database schema exports + * + * This is the single source of truth for the database schema. + * All tables, relations, and types are exported from here. + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +__exportStar(require("./abacus-settings"), exports); +__exportStar(require("./arcade-rooms"), exports); +__exportStar(require("./arcade-sessions"), exports); +__exportStar(require("./players"), exports); +__exportStar(require("./room-members"), exports); +__exportStar(require("./user-stats"), exports); +__exportStar(require("./users"), exports); diff --git a/apps/web/src/db/schema/players.js b/apps/web/src/db/schema/players.js new file mode 100644 index 00000000..f5a88fa5 --- /dev/null +++ b/apps/web/src/db/schema/players.js @@ -0,0 +1,36 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.players = void 0; +const cuid2_1 = require("@paralleldrive/cuid2"); +const sqlite_core_1 = require("drizzle-orm/sqlite-core"); +const users_1 = require("./users"); +/** + * Players table - user-created player profiles for games + * + * Each user can have multiple players (for multi-player modes). + * Players are scoped to a user and deleted when user is deleted. + */ +exports.players = (0, sqlite_core_1.sqliteTable)('players', { + id: (0, sqlite_core_1.text)('id') + .primaryKey() + .$defaultFn(() => (0, cuid2_1.createId)()), + /** Foreign key to users table - cascades on delete */ + userId: (0, sqlite_core_1.text)('user_id') + .notNull() + .references(() => users_1.users.id, { onDelete: 'cascade' }), + /** Player display name */ + name: (0, sqlite_core_1.text)('name').notNull(), + /** Player emoji avatar */ + emoji: (0, sqlite_core_1.text)('emoji').notNull(), + /** Player color (hex) for UI theming */ + color: (0, sqlite_core_1.text)('color').notNull(), + /** Whether this player is currently active in games */ + isActive: (0, sqlite_core_1.integer)('is_active', { mode: 'boolean' }).notNull().default(false), + /** When this player was created */ + createdAt: (0, sqlite_core_1.integer)('created_at', { mode: 'timestamp' }) + .notNull() + .$defaultFn(() => new Date()), +}, (table) => ({ + /** Index for fast lookups by userId */ + userIdIdx: (0, sqlite_core_1.index)('players_user_id_idx').on(table.userId), +})); diff --git a/apps/web/src/db/schema/room-members.js b/apps/web/src/db/schema/room-members.js new file mode 100644 index 00000000..98ab9a25 --- /dev/null +++ b/apps/web/src/db/schema/room-members.js @@ -0,0 +1,27 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.roomMembers = void 0; +const cuid2_1 = require("@paralleldrive/cuid2"); +const sqlite_core_1 = require("drizzle-orm/sqlite-core"); +const arcade_rooms_1 = require("./arcade-rooms"); +exports.roomMembers = (0, sqlite_core_1.sqliteTable)('room_members', { + id: (0, sqlite_core_1.text)('id') + .primaryKey() + .$defaultFn(() => (0, cuid2_1.createId)()), + roomId: (0, sqlite_core_1.text)('room_id') + .notNull() + .references(() => arcade_rooms_1.arcadeRooms.id, { onDelete: 'cascade' }), + userId: (0, sqlite_core_1.text)('user_id').notNull(), // User/guest ID - UNIQUE: one room per user (enforced by index below) + displayName: (0, sqlite_core_1.text)('display_name', { length: 50 }).notNull(), + isCreator: (0, sqlite_core_1.integer)('is_creator', { mode: 'boolean' }).notNull().default(false), + joinedAt: (0, sqlite_core_1.integer)('joined_at', { mode: 'timestamp' }) + .notNull() + .$defaultFn(() => new Date()), + lastSeen: (0, sqlite_core_1.integer)('last_seen', { mode: 'timestamp' }) + .notNull() + .$defaultFn(() => new Date()), + isOnline: (0, sqlite_core_1.integer)('is_online', { mode: 'boolean' }).notNull().default(true), +}, (table) => ({ + // Explicit unique index for clarity and database-level enforcement + userIdIdx: (0, sqlite_core_1.uniqueIndex)('idx_room_members_user_id_unique').on(table.userId), +})); diff --git a/apps/web/src/db/schema/user-stats.js b/apps/web/src/db/schema/user-stats.js new file mode 100644 index 00000000..d74b6387 --- /dev/null +++ b/apps/web/src/db/schema/user-stats.js @@ -0,0 +1,29 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.userStats = void 0; +const sqlite_core_1 = require("drizzle-orm/sqlite-core"); +const users_1 = require("./users"); +/** + * User stats table - game statistics per user + * + * One-to-one with users table. Tracks aggregate game performance. + * Deleted when user is deleted (cascade). + */ +exports.userStats = (0, sqlite_core_1.sqliteTable)('user_stats', { + /** Primary key and foreign key to users table */ + userId: (0, sqlite_core_1.text)('user_id') + .primaryKey() + .references(() => users_1.users.id, { onDelete: 'cascade' }), + /** Total number of games played */ + gamesPlayed: (0, sqlite_core_1.integer)('games_played').notNull().default(0), + /** Total number of games won */ + totalWins: (0, sqlite_core_1.integer)('total_wins').notNull().default(0), + /** User's most-played game type */ + favoriteGameType: (0, sqlite_core_1.text)('favorite_game_type', { + enum: ['abacus-numeral', 'complement-pairs'], + }), + /** Best completion time in milliseconds */ + bestTime: (0, sqlite_core_1.integer)('best_time'), + /** Highest accuracy percentage (0.0 - 1.0) */ + highestAccuracy: (0, sqlite_core_1.real)('highest_accuracy').notNull().default(0), +}); diff --git a/apps/web/src/db/schema/users.js b/apps/web/src/db/schema/users.js new file mode 100644 index 00000000..34a699b1 --- /dev/null +++ b/apps/web/src/db/schema/users.js @@ -0,0 +1,28 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.users = void 0; +const cuid2_1 = require("@paralleldrive/cuid2"); +const sqlite_core_1 = require("drizzle-orm/sqlite-core"); +/** + * Users table - stores both guest and authenticated users + * + * Guest users are created automatically on first visit via middleware. + * They can upgrade to full accounts later while preserving their data. + */ +exports.users = (0, sqlite_core_1.sqliteTable)('users', { + id: (0, sqlite_core_1.text)('id') + .primaryKey() + .$defaultFn(() => (0, cuid2_1.createId)()), + /** Stable guest ID from HttpOnly cookie - unique per browser session */ + guestId: (0, sqlite_core_1.text)('guest_id').notNull().unique(), + /** When this user record was created */ + createdAt: (0, sqlite_core_1.integer)('created_at', { mode: 'timestamp' }) + .notNull() + .$defaultFn(() => new Date()), + /** When guest upgraded to full account (null for guests) */ + upgradedAt: (0, sqlite_core_1.integer)('upgraded_at', { mode: 'timestamp' }), + /** Email (only set after upgrade) */ + email: (0, sqlite_core_1.text)('email').unique(), + /** Display name (only set after upgrade) */ + name: (0, sqlite_core_1.text)('name'), +}); diff --git a/apps/web/src/lib/arcade/player-manager.js b/apps/web/src/lib/arcade/player-manager.js new file mode 100644 index 00000000..bed7a44f --- /dev/null +++ b/apps/web/src/lib/arcade/player-manager.js @@ -0,0 +1,120 @@ +"use strict"; +/** + * Player manager for arcade rooms + * Handles fetching and validating player participation in rooms + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getAllPlayers = getAllPlayers; +exports.getActivePlayers = getActivePlayers; +exports.getRoomActivePlayers = getRoomActivePlayers; +exports.getRoomPlayerIds = getRoomPlayerIds; +exports.validatePlayerInRoom = validatePlayerInRoom; +exports.getPlayer = getPlayer; +exports.getPlayers = getPlayers; +const drizzle_orm_1 = require("drizzle-orm"); +const db_1 = require("../../db"); +/** + * Get all players for a user (regardless of isActive status) + * @param viewerId - The guestId from the cookie (same as what getViewerId() returns) + */ +async function getAllPlayers(viewerId) { + // First get the user record by guestId + const user = await db_1.db.query.users.findFirst({ + where: (0, drizzle_orm_1.eq)(db_1.schema.users.guestId, viewerId), + }); + if (!user) { + return []; + } + // Now query all players by the actual user.id (no isActive filter) + return await db_1.db.query.players.findMany({ + where: (0, drizzle_orm_1.eq)(db_1.schema.players.userId, user.id), + orderBy: db_1.schema.players.createdAt, + }); +} +/** + * Get a user's active players (solo mode) + * These are the players that will participate when the user joins a solo game + * @param viewerId - The guestId from the cookie (same as what getViewerId() returns) + */ +async function getActivePlayers(viewerId) { + // First get the user record by guestId + const user = await db_1.db.query.users.findFirst({ + where: (0, drizzle_orm_1.eq)(db_1.schema.users.guestId, viewerId), + }); + if (!user) { + return []; + } + // Now query players by the actual user.id + return await db_1.db.query.players.findMany({ + where: (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.players.userId, user.id), (0, drizzle_orm_1.eq)(db_1.schema.players.isActive, true)), + orderBy: db_1.schema.players.createdAt, + }); +} +/** + * Get active players for all members in a room + * Returns only players marked isActive=true from each room member + * Returns a map of userId -> Player[] + */ +async function getRoomActivePlayers(roomId) { + // Get all room members + const members = await db_1.db.query.roomMembers.findMany({ + where: (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), + }); + // Fetch active players for each member (respects isActive flag) + const playerMap = new Map(); + for (const member of members) { + const players = await getActivePlayers(member.userId); + playerMap.set(member.userId, players); + } + return playerMap; +} +/** + * Get all player IDs that should participate in a room game + * Flattens the player lists from all room members + */ +async function getRoomPlayerIds(roomId) { + const playerMap = await getRoomActivePlayers(roomId); + const allPlayers = []; + for (const players of playerMap.values()) { + allPlayers.push(...players.map((p) => p.id)); + } + return allPlayers; +} +/** + * Validate that a player ID belongs to a user who is a member of a room + */ +async function validatePlayerInRoom(playerId, roomId) { + // Get the player + const player = await db_1.db.query.players.findFirst({ + where: (0, drizzle_orm_1.eq)(db_1.schema.players.id, playerId), + }); + if (!player) + return false; + // Check if the player's user is a member of the room + const member = await db_1.db.query.roomMembers.findFirst({ + where: (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, player.userId)), + }); + return !!member; +} +/** + * Get player details by ID + */ +async function getPlayer(playerId) { + return await db_1.db.query.players.findFirst({ + where: (0, drizzle_orm_1.eq)(db_1.schema.players.id, playerId), + }); +} +/** + * Get multiple players by IDs + */ +async function getPlayers(playerIds) { + if (playerIds.length === 0) + return []; + const players = []; + for (const id of playerIds) { + const player = await getPlayer(id); + if (player) + players.push(player); + } + return players; +} diff --git a/apps/web/src/lib/arcade/room-code.js b/apps/web/src/lib/arcade/room-code.js new file mode 100644 index 00000000..fc5817b6 --- /dev/null +++ b/apps/web/src/lib/arcade/room-code.js @@ -0,0 +1,37 @@ +"use strict"; +/** + * Room code generation utility + * Generates short, memorable codes for joining rooms + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.generateRoomCode = generateRoomCode; +exports.isValidRoomCode = isValidRoomCode; +exports.normalizeRoomCode = normalizeRoomCode; +const CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed ambiguous chars: 0,O,1,I +const CODE_LENGTH = 6; +/** + * Generate a random 6-character room code + * Format: ABC123 (uppercase letters + numbers, no ambiguous chars) + */ +function generateRoomCode() { + let code = ''; + for (let i = 0; i < CODE_LENGTH; i++) { + const randomIndex = Math.floor(Math.random() * CHARS.length); + code += CHARS[randomIndex]; + } + return code; +} +/** + * Validate a room code format + */ +function isValidRoomCode(code) { + if (code.length !== CODE_LENGTH) + return false; + return code.split('').every((char) => CHARS.includes(char)); +} +/** + * Normalize a room code (uppercase, remove spaces/dashes) + */ +function normalizeRoomCode(code) { + return code.toUpperCase().replace(/[\s-]/g, ''); +} diff --git a/apps/web/src/lib/arcade/room-manager.js b/apps/web/src/lib/arcade/room-manager.js new file mode 100644 index 00000000..beb34dd8 --- /dev/null +++ b/apps/web/src/lib/arcade/room-manager.js @@ -0,0 +1,154 @@ +"use strict"; +/** + * Arcade room manager + * Handles database operations for arcade rooms + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createRoom = createRoom; +exports.getRoomById = getRoomById; +exports.getRoomByCode = getRoomByCode; +exports.updateRoom = updateRoom; +exports.touchRoom = touchRoom; +exports.deleteRoom = deleteRoom; +exports.listActiveRooms = listActiveRooms; +exports.cleanupExpiredRooms = cleanupExpiredRooms; +exports.isRoomCreator = isRoomCreator; +const drizzle_orm_1 = require("drizzle-orm"); +const db_1 = require("../../db"); +const room_code_1 = require("./room-code"); +/** + * Create a new arcade room + * Generates a unique room code and creates the room in the database + */ +async function createRoom(options) { + const now = new Date(); + // Generate unique room code (retry up to 5 times if collision) + let code = (0, room_code_1.generateRoomCode)(); + let attempts = 0; + const MAX_ATTEMPTS = 5; + while (attempts < MAX_ATTEMPTS) { + const existing = await getRoomByCode(code); + if (!existing) + break; + code = (0, room_code_1.generateRoomCode)(); + attempts++; + } + if (attempts === MAX_ATTEMPTS) { + throw new Error('Failed to generate unique room code'); + } + const newRoom = { + code, + name: options.name, + createdBy: options.createdBy, + creatorName: options.creatorName, + createdAt: now, + lastActivity: now, + ttlMinutes: options.ttlMinutes || 60, + isLocked: false, + gameName: options.gameName, + gameConfig: options.gameConfig, + status: 'lobby', + currentSessionId: null, + totalGamesPlayed: 0, + }; + const [room] = await db_1.db.insert(db_1.schema.arcadeRooms).values(newRoom).returning(); + console.log('[Room Manager] Created room:', room.id, 'code:', room.code); + return room; +} +/** + * Get a room by ID + */ +async function getRoomById(roomId) { + return await db_1.db.query.arcadeRooms.findFirst({ + where: (0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, roomId), + }); +} +/** + * Get a room by code + */ +async function getRoomByCode(code) { + return await db_1.db.query.arcadeRooms.findFirst({ + where: (0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.code, code.toUpperCase()), + }); +} +/** + * Update a room + */ +async function updateRoom(roomId, updates) { + const now = new Date(); + // Always update lastActivity on any room update + const updateData = { + ...updates, + lastActivity: now, + }; + const [updated] = await db_1.db + .update(db_1.schema.arcadeRooms) + .set(updateData) + .where((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, roomId)) + .returning(); + return updated; +} +/** + * Update room activity timestamp + * Call this on any room activity to refresh TTL + */ +async function touchRoom(roomId) { + await db_1.db + .update(db_1.schema.arcadeRooms) + .set({ lastActivity: new Date() }) + .where((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, roomId)); +} +/** + * Delete a room + * Cascade deletes all room members + */ +async function deleteRoom(roomId) { + await db_1.db.delete(db_1.schema.arcadeRooms).where((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, roomId)); + console.log('[Room Manager] Deleted room:', roomId); +} +/** + * List active rooms + * Returns rooms ordered by most recently active + */ +async function listActiveRooms(gameName) { + const whereConditions = []; + // Filter by game if specified + if (gameName) { + whereConditions.push((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.gameName, gameName)); + } + // Only return non-locked rooms in lobby or playing status + whereConditions.push((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.isLocked, false), (0, drizzle_orm_1.or)((0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.status, 'lobby'), (0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.status, 'playing'))); + return await db_1.db.query.arcadeRooms.findMany({ + where: whereConditions.length > 0 ? (0, drizzle_orm_1.and)(...whereConditions) : undefined, + orderBy: [(0, drizzle_orm_1.desc)(db_1.schema.arcadeRooms.lastActivity)], + limit: 50, // Limit to 50 most recent rooms + }); +} +/** + * Clean up expired rooms + * Delete rooms that have exceeded their TTL + */ +async function cleanupExpiredRooms() { + const now = new Date(); + // Find rooms where lastActivity + ttlMinutes < now + const expiredRooms = await db_1.db.query.arcadeRooms.findMany({ + columns: { id: true, ttlMinutes: true, lastActivity: true }, + }); + const toDelete = expiredRooms.filter((room) => { + const expiresAt = new Date(room.lastActivity.getTime() + room.ttlMinutes * 60 * 1000); + return expiresAt < now; + }); + if (toDelete.length > 0) { + const ids = toDelete.map((r) => r.id); + await db_1.db.delete(db_1.schema.arcadeRooms).where((0, drizzle_orm_1.or)(...ids.map((id) => (0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, id)))); + console.log(`[Room Manager] Cleaned up ${toDelete.length} expired rooms`); + } + return toDelete.length; +} +/** + * Check if a user is the creator of a room + */ +async function isRoomCreator(roomId, userId) { + const room = await getRoomById(roomId); + return room?.createdBy === userId; +} diff --git a/apps/web/src/lib/arcade/room-membership.js b/apps/web/src/lib/arcade/room-membership.js new file mode 100644 index 00000000..25a1aa04 --- /dev/null +++ b/apps/web/src/lib/arcade/room-membership.js @@ -0,0 +1,179 @@ +"use strict"; +/** + * Room membership manager + * Handles database operations for room members + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.addRoomMember = addRoomMember; +exports.getRoomMember = getRoomMember; +exports.getRoomMembers = getRoomMembers; +exports.getOnlineRoomMembers = getOnlineRoomMembers; +exports.setMemberOnline = setMemberOnline; +exports.touchMember = touchMember; +exports.removeMember = removeMember; +exports.removeAllMembers = removeAllMembers; +exports.getOnlineMemberCount = getOnlineMemberCount; +exports.isMember = isMember; +exports.getUserRooms = getUserRooms; +const drizzle_orm_1 = require("drizzle-orm"); +const db_1 = require("../../db"); +/** + * Add a member to a room + * Automatically removes user from any other rooms they're in (modal room enforcement) + * Returns the new membership and info about rooms that were auto-left + */ +async function addRoomMember(options) { + const now = new Date(); + // Check if member already exists in THIS room + const existing = await getRoomMember(options.roomId, options.userId); + if (existing) { + // Already in this room - just update status (no auto-leave needed) + const [updated] = await db_1.db + .update(db_1.schema.roomMembers) + .set({ + isOnline: true, + lastSeen: now, + }) + .where((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.id, existing.id)) + .returning(); + return { member: updated }; + } + // AUTO-LEAVE LOGIC: Remove from all other rooms before joining this one + const currentRooms = await getUserRooms(options.userId); + const autoLeaveResult = { + leftRooms: [], + previousRoomMembers: [], + }; + for (const roomId of currentRooms) { + if (roomId !== options.roomId) { + // Get member info before removing (for socket events) + const memberToRemove = await getRoomMember(roomId, options.userId); + if (memberToRemove) { + autoLeaveResult.previousRoomMembers.push({ + roomId, + member: memberToRemove, + }); + } + // Remove from room + await removeMember(roomId, options.userId); + autoLeaveResult.leftRooms.push(roomId); + console.log(`[Room Membership] Auto-left room ${roomId} for user ${options.userId}`); + } + } + // Now add to new room + const newMember = { + roomId: options.roomId, + userId: options.userId, + displayName: options.displayName, + isCreator: options.isCreator || false, + joinedAt: now, + lastSeen: now, + isOnline: true, + }; + try { + const [member] = await db_1.db.insert(db_1.schema.roomMembers).values(newMember).returning(); + console.log('[Room Membership] Added member:', member.userId, 'to room:', member.roomId); + return { + member, + autoLeaveResult: autoLeaveResult.leftRooms.length > 0 ? autoLeaveResult : undefined, + }; + } + catch (error) { + // Handle unique constraint violation + // This should rarely happen due to auto-leave logic above, but catch it for safety + if (error.code === 'SQLITE_CONSTRAINT' || + error.message?.includes('UNIQUE') || + error.message?.includes('unique')) { + console.error('[Room Membership] Unique constraint violation:', error.message); + throw new Error('ROOM_MEMBERSHIP_CONFLICT: User is already in another room. This should have been handled by auto-leave logic.'); + } + throw error; + } +} +/** + * Get a specific room member + */ +async function getRoomMember(roomId, userId) { + return await db_1.db.query.roomMembers.findFirst({ + where: (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, userId)), + }); +} +/** + * Get all members in a room + */ +async function getRoomMembers(roomId) { + return await db_1.db.query.roomMembers.findMany({ + where: (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), + orderBy: db_1.schema.roomMembers.joinedAt, + }); +} +/** + * Get online members in a room + */ +async function getOnlineRoomMembers(roomId) { + return await db_1.db.query.roomMembers.findMany({ + where: (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.isOnline, true)), + orderBy: db_1.schema.roomMembers.joinedAt, + }); +} +/** + * Update member's online status + */ +async function setMemberOnline(roomId, userId, isOnline) { + await db_1.db + .update(db_1.schema.roomMembers) + .set({ + isOnline, + lastSeen: new Date(), + }) + .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, userId))); +} +/** + * Update member's last seen timestamp + */ +async function touchMember(roomId, userId) { + await db_1.db + .update(db_1.schema.roomMembers) + .set({ lastSeen: new Date() }) + .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, userId))); +} +/** + * Remove a member from a room + */ +async function removeMember(roomId, userId) { + await db_1.db + .delete(db_1.schema.roomMembers) + .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId), (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, userId))); + console.log('[Room Membership] Removed member:', userId, 'from room:', roomId); +} +/** + * Remove all members from a room + */ +async function removeAllMembers(roomId) { + await db_1.db.delete(db_1.schema.roomMembers).where((0, drizzle_orm_1.eq)(db_1.schema.roomMembers.roomId, roomId)); + console.log('[Room Membership] Removed all members from room:', roomId); +} +/** + * Get count of online members in a room + */ +async function getOnlineMemberCount(roomId) { + const members = await getOnlineRoomMembers(roomId); + return members.length; +} +/** + * Check if a user is a member of a room + */ +async function isMember(roomId, userId) { + const member = await getRoomMember(roomId, userId); + return !!member; +} +/** + * Get all rooms a user is a member of + */ +async function getUserRooms(userId) { + const memberships = await db_1.db.query.roomMembers.findMany({ + where: (0, drizzle_orm_1.eq)(db_1.schema.roomMembers.userId, userId), + columns: { roomId: true }, + }); + return memberships.map((m) => m.roomId); +} diff --git a/apps/web/src/lib/arcade/room-ttl-cleanup.js b/apps/web/src/lib/arcade/room-ttl-cleanup.js new file mode 100644 index 00000000..04587210 --- /dev/null +++ b/apps/web/src/lib/arcade/room-ttl-cleanup.js @@ -0,0 +1,55 @@ +"use strict"; +/** + * Room TTL Cleanup Scheduler + * Periodically cleans up expired rooms + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.startRoomTTLCleanup = startRoomTTLCleanup; +exports.stopRoomTTLCleanup = stopRoomTTLCleanup; +const room_manager_1 = require("./room-manager"); +// Cleanup interval: run every 5 minutes +const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; +let cleanupInterval = null; +/** + * Start the TTL cleanup scheduler + * Runs cleanup every 5 minutes + */ +function startRoomTTLCleanup() { + if (cleanupInterval) { + console.log('[Room TTL] Cleanup scheduler already running'); + return; + } + console.log('[Room TTL] Starting cleanup scheduler (every 5 minutes)'); + // Run immediately on start + (0, room_manager_1.cleanupExpiredRooms)() + .then((count) => { + if (count > 0) { + console.log(`[Room TTL] Initial cleanup removed ${count} expired rooms`); + } + }) + .catch((error) => { + console.error('[Room TTL] Initial cleanup failed:', error); + }); + // Then run periodically + cleanupInterval = setInterval(async () => { + try { + const count = await (0, room_manager_1.cleanupExpiredRooms)(); + if (count > 0) { + console.log(`[Room TTL] Cleanup removed ${count} expired rooms`); + } + } + catch (error) { + console.error('[Room TTL] Cleanup failed:', error); + } + }, CLEANUP_INTERVAL_MS); +} +/** + * Stop the TTL cleanup scheduler + */ +function stopRoomTTLCleanup() { + if (cleanupInterval) { + clearInterval(cleanupInterval); + cleanupInterval = null; + console.log('[Room TTL] Cleanup scheduler stopped'); + } +} diff --git a/apps/web/src/lib/arcade/session-manager.js b/apps/web/src/lib/arcade/session-manager.js new file mode 100644 index 00000000..0e3fb0a8 --- /dev/null +++ b/apps/web/src/lib/arcade/session-manager.js @@ -0,0 +1,296 @@ +"use strict"; +/** + * Arcade session manager + * Handles database operations and validation for arcade sessions + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getArcadeSessionByRoom = getArcadeSessionByRoom; +exports.createArcadeSession = createArcadeSession; +exports.getArcadeSession = getArcadeSession; +exports.applyGameMove = applyGameMove; +exports.deleteArcadeSession = deleteArcadeSession; +exports.updateSessionActivity = updateSessionActivity; +exports.cleanupExpiredSessions = cleanupExpiredSessions; +const drizzle_orm_1 = require("drizzle-orm"); +const db_1 = require("../../db"); +const validation_1 = require("./validation"); +const TTL_HOURS = 24; +/** + * Helper: Get database user ID from guest ID + * The API uses guestId (from cookies) but database FKs use the internal user.id + */ +async function getUserIdFromGuestId(guestId) { + const user = await db_1.db.query.users.findFirst({ + where: (0, drizzle_orm_1.eq)(db_1.schema.users.guestId, guestId), + columns: { id: true }, + }); + return user?.id; +} +/** + * Get arcade session by room ID (for room-based multiplayer games) + * Returns the shared session for all room members + * @param roomId - The room ID + */ +async function getArcadeSessionByRoom(roomId) { + const [session] = await db_1.db + .select() + .from(db_1.schema.arcadeSessions) + .where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.roomId, roomId)) + .limit(1); + if (!session) + return undefined; + // Check if session has expired + if (session.expiresAt < new Date()) { + // Clean up expired room session + await db_1.db.delete(db_1.schema.arcadeSessions).where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.roomId, roomId)); + return undefined; + } + return session; +} +/** + * Create a new arcade session + * For room-based games, checks if a session already exists for the room + */ +async function createArcadeSession(options) { + const now = new Date(); + const expiresAt = new Date(now.getTime() + TTL_HOURS * 60 * 60 * 1000); + // For room-based games, check if session already exists for this room + if (options.roomId) { + const existingRoomSession = await getArcadeSessionByRoom(options.roomId); + if (existingRoomSession) { + console.log('[Session Manager] Room session already exists, returning existing:', { + roomId: options.roomId, + sessionUserId: existingRoomSession.userId, + version: existingRoomSession.version, + }); + return existingRoomSession; + } + } + // Find or create user by guest ID + let user = await db_1.db.query.users.findFirst({ + where: (0, drizzle_orm_1.eq)(db_1.schema.users.guestId, options.userId), + }); + if (!user) { + console.log('[Session Manager] Creating new user with guestId:', options.userId); + const [newUser] = await db_1.db + .insert(db_1.schema.users) + .values({ + guestId: options.userId, // Let id auto-generate via $defaultFn + createdAt: now, + }) + .returning(); + user = newUser; + console.log('[Session Manager] Created user with id:', user.id); + } + else { + console.log('[Session Manager] Found existing user with id:', user.id); + } + const newSession = { + userId: user.id, // Use the actual database ID, not the guestId + currentGame: options.gameName, + gameUrl: options.gameUrl, + gameState: options.initialState, + activePlayers: options.activePlayers, + roomId: options.roomId, // Associate session with room + startedAt: now, + lastActivityAt: now, + expiresAt, + isActive: true, + version: 1, + }; + console.log('[Session Manager] Creating new session:', { + userId: user.id, + roomId: options.roomId, + gameName: options.gameName, + }); + const [session] = await db_1.db.insert(db_1.schema.arcadeSessions).values(newSession).returning(); + return session; +} +/** + * Get active arcade session for a user + * @param guestId - The guest ID from the cookie (not the database user.id) + */ +async function getArcadeSession(guestId) { + const userId = await getUserIdFromGuestId(guestId); + if (!userId) + return undefined; + const [session] = await db_1.db + .select() + .from(db_1.schema.arcadeSessions) + .where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.userId, userId)) + .limit(1); + if (!session) + return undefined; + // Check if session has expired + if (session.expiresAt < new Date()) { + await deleteArcadeSession(guestId); + return undefined; + } + // Check if session has a valid room association + // Sessions without rooms are orphaned and should be cleaned up + if (!session.roomId) { + console.log('[Session Manager] Deleting orphaned session without room:', session.userId); + await deleteArcadeSession(guestId); + return undefined; + } + // Verify the room still exists + const room = await db_1.db.query.arcadeRooms.findFirst({ + where: (0, drizzle_orm_1.eq)(db_1.schema.arcadeRooms.id, session.roomId), + }); + if (!room) { + console.log('[Session Manager] Deleting session with non-existent room:', session.roomId); + await deleteArcadeSession(guestId); + return undefined; + } + return session; +} +/** + * Apply a game move to the session (with validation) + * @param userId - The guest ID from the cookie + * @param move - The game move to apply + * @param roomId - Optional room ID for room-based games (enables shared session) + */ +async function applyGameMove(userId, move, roomId) { + // For room-based games, look up the shared room session + // For solo games, look up the user's personal session + const session = roomId ? await getArcadeSessionByRoom(roomId) : await getArcadeSession(userId); + if (!session) { + return { + success: false, + error: 'No active session found', + }; + } + if (!session.isActive) { + return { + success: false, + error: 'Session is not active', + }; + } + // Get the validator for this game + const validator = (0, validation_1.getValidator)(session.currentGame); + console.log('[SessionManager] About to validate move:', { + moveType: move.type, + playerId: move.playerId, + gameStateCurrentPlayer: session.gameState?.currentPlayer, + gameStateActivePlayers: session.gameState?.activePlayers, + gameStatePhase: session.gameState?.gamePhase, + }); + // Fetch player ownership for authorization checks (room-based games) + let playerOwnership; + let internalUserId; + if (session.roomId) { + try { + // Convert guestId to internal userId for ownership comparison + internalUserId = await getUserIdFromGuestId(userId); + if (!internalUserId) { + console.error('[SessionManager] Failed to convert guestId to userId:', userId); + return { + success: false, + error: 'User not found', + }; + } + const players = await db_1.db.query.players.findMany({ + columns: { + id: true, + userId: true, + }, + }); + playerOwnership = Object.fromEntries(players.map((p) => [p.id, p.userId])); + console.log('[SessionManager] Player ownership map:', playerOwnership); + console.log('[SessionManager] Internal userId for authorization:', internalUserId); + } + catch (error) { + console.error('[SessionManager] Failed to fetch player ownership:', error); + } + } + // Validate the move with authorization context (use internal userId, not guestId) + const validationResult = validator.validateMove(session.gameState, move, { + userId: internalUserId || userId, // Use internal userId for room-based games + playerOwnership, + }); + console.log('[SessionManager] Validation result:', { + valid: validationResult.valid, + error: validationResult.error, + }); + if (!validationResult.valid) { + return { + success: false, + error: validationResult.error || 'Invalid move', + }; + } + // Update the session with new state (using optimistic locking) + const now = new Date(); + const expiresAt = new Date(now.getTime() + TTL_HOURS * 60 * 60 * 1000); + try { + const [updatedSession] = await db_1.db + .update(db_1.schema.arcadeSessions) + .set({ + gameState: validationResult.newState, + lastActivityAt: now, + expiresAt, + version: session.version + 1, + }) + .where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.userId, session.userId) // Use the userId from the session we just fetched + ) + // Version check for optimistic locking would go here + // SQLite doesn't support WHERE clauses in UPDATE with RETURNING easily + // We'll handle this by checking the version after + .returning(); + if (!updatedSession) { + return { + success: false, + error: 'Failed to update session', + }; + } + return { + success: true, + session: updatedSession, + }; + } + catch (error) { + console.error('Error updating session:', error); + return { + success: false, + error: 'Database error', + }; + } +} +/** + * Delete an arcade session + * @param guestId - The guest ID from the cookie (not the database user.id) + */ +async function deleteArcadeSession(guestId) { + const userId = await getUserIdFromGuestId(guestId); + if (!userId) + return; + await db_1.db.delete(db_1.schema.arcadeSessions).where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.userId, userId)); +} +/** + * Update session activity timestamp (keep-alive) + * @param guestId - The guest ID from the cookie (not the database user.id) + */ +async function updateSessionActivity(guestId) { + const userId = await getUserIdFromGuestId(guestId); + if (!userId) + return; + const now = new Date(); + const expiresAt = new Date(now.getTime() + TTL_HOURS * 60 * 60 * 1000); + await db_1.db + .update(db_1.schema.arcadeSessions) + .set({ + lastActivityAt: now, + expiresAt, + }) + .where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.userId, userId)); +} +/** + * Clean up expired sessions (should be called periodically) + */ +async function cleanupExpiredSessions() { + const now = new Date(); + const result = await db_1.db + .delete(db_1.schema.arcadeSessions) + .where((0, drizzle_orm_1.eq)(db_1.schema.arcadeSessions.expiresAt, now)) + .returning(); + return result.length; +} diff --git a/apps/web/src/lib/arcade/validation/MatchingGameValidator.js b/apps/web/src/lib/arcade/validation/MatchingGameValidator.js new file mode 100644 index 00000000..49718695 --- /dev/null +++ b/apps/web/src/lib/arcade/validation/MatchingGameValidator.js @@ -0,0 +1,469 @@ +"use strict"; +/** + * Server-side validator for matching game + * Validates all game moves and state transitions + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.matchingGameValidator = exports.MatchingGameValidator = void 0; +const cardGeneration_1 = require("../../../app/games/matching/utils/cardGeneration"); +const matchValidation_1 = require("../../../app/games/matching/utils/matchValidation"); +class MatchingGameValidator { + validateMove(state, move, context) { + switch (move.type) { + case 'FLIP_CARD': + return this.validateFlipCard(state, move.data.cardId, move.playerId, context); + case 'START_GAME': + return this.validateStartGame(state, move.data.activePlayers, move.data.cards, move.data.playerMetadata); + case 'CLEAR_MISMATCH': + return this.validateClearMismatch(state); + case 'GO_TO_SETUP': + return this.validateGoToSetup(state); + case 'SET_CONFIG': + return this.validateSetConfig(state, move.data.field, move.data.value); + case 'RESUME_GAME': + return this.validateResumeGame(state); + case 'HOVER_CARD': + return this.validateHoverCard(state, move.data.cardId, move.playerId); + default: + return { + valid: false, + error: `Unknown move type: ${move.type}`, + }; + } + } + validateFlipCard(state, cardId, playerId, context) { + // Game must be in playing phase + if (state.gamePhase !== 'playing') { + return { + valid: false, + error: 'Cannot flip cards outside of playing phase', + }; + } + // Check if it's the player's turn (in multiplayer) + if (state.activePlayers.length > 1 && state.currentPlayer !== playerId) { + console.log('[Validator] Turn check failed:', { + activePlayers: state.activePlayers, + currentPlayer: state.currentPlayer, + currentPlayerType: typeof state.currentPlayer, + playerId, + playerIdType: typeof playerId, + matches: state.currentPlayer === playerId, + }); + return { + valid: false, + error: 'Not your turn', + }; + } + // Check player ownership authorization (if context provided) + if (context?.userId && context?.playerOwnership) { + const playerOwner = context.playerOwnership[playerId]; + if (playerOwner && playerOwner !== context.userId) { + console.log('[Validator] Player ownership check failed:', { + playerId, + playerOwner, + requestingUserId: context.userId, + }); + return { + valid: false, + error: 'You can only move your own players', + }; + } + } + // Find the card + const card = state.gameCards.find((c) => c.id === cardId); + if (!card) { + return { + valid: false, + error: 'Card not found', + }; + } + // Validate using existing game logic + if (!(0, matchValidation_1.canFlipCard)(card, state.flippedCards, state.isProcessingMove)) { + return { + valid: false, + error: 'Cannot flip this card', + }; + } + // Calculate new state + const newFlippedCards = [...state.flippedCards, card]; + let newState = { + ...state, + flippedCards: newFlippedCards, + isProcessingMove: newFlippedCards.length === 2, + // Clear mismatch feedback when player flips a new card + showMismatchFeedback: false, + }; + // If two cards are flipped, check for match + if (newFlippedCards.length === 2) { + const [card1, card2] = newFlippedCards; + const matchResult = (0, matchValidation_1.validateMatch)(card1, card2); + if (matchResult.isValid) { + // Match found - update cards + newState = { + ...newState, + gameCards: newState.gameCards.map((c) => c.id === card1.id || c.id === card2.id + ? { ...c, matched: true, matchedBy: state.currentPlayer } + : c), + matchedPairs: state.matchedPairs + 1, + scores: { + ...state.scores, + [state.currentPlayer]: (state.scores[state.currentPlayer] || 0) + 1, + }, + consecutiveMatches: { + ...state.consecutiveMatches, + [state.currentPlayer]: (state.consecutiveMatches[state.currentPlayer] || 0) + 1, + }, + moves: state.moves + 1, + flippedCards: [], + isProcessingMove: false, + }; + // Check if game is complete + if (newState.matchedPairs === newState.totalPairs) { + newState = { + ...newState, + gamePhase: 'results', + gameEndTime: Date.now(), + }; + } + } + else { + // Match failed - keep cards flipped briefly so player can see them + // Client will handle clearing them after a delay + const shouldSwitchPlayer = state.activePlayers.length > 1; + const nextPlayerIndex = shouldSwitchPlayer + ? (state.activePlayers.indexOf(state.currentPlayer) + 1) % state.activePlayers.length + : 0; + const nextPlayer = shouldSwitchPlayer + ? state.activePlayers[nextPlayerIndex] + : state.currentPlayer; + newState = { + ...newState, + currentPlayer: nextPlayer, + consecutiveMatches: { + ...state.consecutiveMatches, + [state.currentPlayer]: 0, + }, + moves: state.moves + 1, + // Keep flippedCards so player can see both cards + flippedCards: newFlippedCards, + isProcessingMove: true, // Keep processing state so no more cards can be flipped + showMismatchFeedback: true, + }; + } + } + return { + valid: true, + newState, + }; + } + validateStartGame(state, activePlayers, cards, playerMetadata) { + // Allow starting a new game from any phase (for "New Game" button) + // Must have at least one player + if (!activePlayers || activePlayers.length === 0) { + return { + valid: false, + error: 'Must have at least one player', + }; + } + // Use provided cards or generate new ones + const gameCards = cards || (0, cardGeneration_1.generateGameCards)(state.gameType, state.difficulty); + const newState = { + ...state, + gameCards, + cards: gameCards, + activePlayers, + playerMetadata: playerMetadata || {}, // Store player metadata for cross-user visibility + gamePhase: 'playing', + gameStartTime: Date.now(), + currentPlayer: activePlayers[0], + flippedCards: [], + matchedPairs: 0, + moves: 0, + scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}), + consecutiveMatches: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}), + // PAUSE/RESUME: Save original config so we can detect changes + originalConfig: { + gameType: state.gameType, + difficulty: state.difficulty, + turnTimer: state.turnTimer, + }, + // Clear any paused game state (starting fresh) + pausedGamePhase: undefined, + pausedGameState: undefined, + }; + return { + valid: true, + newState, + }; + } + validateClearMismatch(state) { + // Only clear if there's actually a mismatch showing + // This prevents race conditions where CLEAR_MISMATCH arrives after cards have already been cleared + if (!state.showMismatchFeedback || state.flippedCards.length === 0) { + // Nothing to clear - return current state unchanged + return { + valid: true, + newState: state, + }; + } + // Clear mismatched cards and feedback + return { + valid: true, + newState: { + ...state, + flippedCards: [], + showMismatchFeedback: false, + isProcessingMove: false, + }, + }; + } + /** + * STANDARD ARCADE PATTERN: GO_TO_SETUP + * + * Transitions the game back to setup phase, allowing players to reconfigure + * the game. This is synchronized across all room members. + * + * Can be called from any phase (setup, playing, results). + * + * PAUSE/RESUME: If called from 'playing' or 'results', saves game state + * to allow resuming later (if config unchanged). + * + * Pattern for all arcade games: + * - Validates the move is allowed + * - Sets gamePhase to 'setup' + * - Preserves current configuration (gameType, difficulty, etc.) + * - Saves game state for resume if coming from active game + * - Resets game progression state (scores, cards, etc.) + */ + validateGoToSetup(state) { + // Determine if we're pausing an active game (for Resume functionality) + const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results'; + return { + valid: true, + newState: { + ...state, + gamePhase: 'setup', + // Pause/Resume: Save game state if pausing from active game + pausedGamePhase: isPausingGame ? state.gamePhase : undefined, + pausedGameState: isPausingGame + ? { + gameCards: state.gameCards, + currentPlayer: state.currentPlayer, + matchedPairs: state.matchedPairs, + moves: state.moves, + scores: state.scores, + activePlayers: state.activePlayers, + playerMetadata: state.playerMetadata, + consecutiveMatches: state.consecutiveMatches, + gameStartTime: state.gameStartTime, + } + : undefined, + // Keep originalConfig if it exists (was set when game started) + // This allows detecting if config changed while paused + // Reset visible game progression + gameCards: [], + cards: [], + flippedCards: [], + currentPlayer: '', + matchedPairs: 0, + moves: 0, + scores: {}, + activePlayers: [], + playerMetadata: {}, + consecutiveMatches: {}, + gameStartTime: null, + gameEndTime: null, + currentMoveStartTime: null, + celebrationAnimations: [], + isProcessingMove: false, + showMismatchFeedback: false, + lastMatchedPair: null, + // Preserve configuration - players can modify in setup + // gameType, difficulty, turnTimer stay as-is + }, + }; + } + /** + * STANDARD ARCADE PATTERN: SET_CONFIG + * + * Updates a configuration field during setup phase. This is synchronized + * across all room members in real-time, allowing collaborative setup. + * + * Pattern for all arcade games: + * - Only allowed during setup phase + * - Validates field name and value + * - Updates the configuration field + * - Other room members see the change immediately (optimistic + server validation) + * + * @param state Current game state + * @param field Configuration field name + * @param value New value for the field + */ + validateSetConfig(state, field, value) { + // Can only change config during setup phase + if (state.gamePhase !== 'setup') { + return { + valid: false, + error: 'Cannot change configuration outside of setup phase', + }; + } + // Validate field-specific values + switch (field) { + case 'gameType': + if (value !== 'abacus-numeral' && value !== 'complement-pairs') { + return { valid: false, error: `Invalid gameType: ${value}` }; + } + break; + case 'difficulty': + if (![6, 8, 12, 15].includes(value)) { + return { valid: false, error: `Invalid difficulty: ${value}` }; + } + break; + case 'turnTimer': + if (typeof value !== 'number' || value < 5 || value > 300) { + return { valid: false, error: `Invalid turnTimer: ${value}` }; + } + break; + default: + return { valid: false, error: `Unknown config field: ${field}` }; + } + // PAUSE/RESUME: If there's a paused game and config is changing, + // clear the paused game state (can't resume anymore) + const clearPausedGame = !!state.pausedGamePhase; + // Apply the configuration change + return { + valid: true, + newState: { + ...state, + [field]: value, + // Update totalPairs if difficulty changes + ...(field === 'difficulty' ? { totalPairs: value } : {}), + // Clear paused game if config changed + ...(clearPausedGame + ? { pausedGamePhase: undefined, pausedGameState: undefined, originalConfig: undefined } + : {}), + }, + }; + } + /** + * STANDARD ARCADE PATTERN: RESUME_GAME + * + * Resumes a paused game if configuration hasn't changed. + * Restores the saved game state from when GO_TO_SETUP was called. + * + * Pattern for all arcade games: + * - Validates there's a paused game + * - Validates config hasn't changed since pause + * - Restores game state and phase + * - Clears paused game state + */ + validateResumeGame(state) { + // Must be in setup phase + if (state.gamePhase !== 'setup') { + return { + valid: false, + error: 'Can only resume from setup phase', + }; + } + // Must have a paused game + if (!state.pausedGamePhase || !state.pausedGameState) { + return { + valid: false, + error: 'No paused game to resume', + }; + } + // Config must match original (no changes while paused) + if (state.originalConfig) { + const configChanged = state.gameType !== state.originalConfig.gameType || + state.difficulty !== state.originalConfig.difficulty || + state.turnTimer !== state.originalConfig.turnTimer; + if (configChanged) { + return { + valid: false, + error: 'Cannot resume - configuration has changed', + }; + } + } + // Restore the paused game + return { + valid: true, + newState: { + ...state, + gamePhase: state.pausedGamePhase, + gameCards: state.pausedGameState.gameCards, + cards: state.pausedGameState.gameCards, + currentPlayer: state.pausedGameState.currentPlayer, + matchedPairs: state.pausedGameState.matchedPairs, + moves: state.pausedGameState.moves, + scores: state.pausedGameState.scores, + activePlayers: state.pausedGameState.activePlayers, + playerMetadata: state.pausedGameState.playerMetadata, + consecutiveMatches: state.pausedGameState.consecutiveMatches, + gameStartTime: state.pausedGameState.gameStartTime, + // Clear paused state + pausedGamePhase: undefined, + pausedGameState: undefined, + // Keep originalConfig for potential future pauses + }, + }; + } + /** + * Validate hover state update for networked presence + * + * Hover moves are lightweight and always valid - they just update + * which card a player is hovering over for UI feedback to other players. + */ + validateHoverCard(state, cardId, playerId) { + // Hover is always valid - it's just UI state for networked presence + // Update the player's hover state + return { + valid: true, + newState: { + ...state, + playerHovers: { + ...state.playerHovers, + [playerId]: cardId, + }, + }, + }; + } + isGameComplete(state) { + return state.gamePhase === 'results' || state.matchedPairs === state.totalPairs; + } + getInitialState(config) { + return { + cards: [], + gameCards: [], + flippedCards: [], + gameType: config.gameType, + difficulty: config.difficulty, + turnTimer: config.turnTimer, + gamePhase: 'setup', + currentPlayer: '', + matchedPairs: 0, + totalPairs: config.difficulty, + moves: 0, + scores: {}, + activePlayers: [], + playerMetadata: {}, // Initialize empty player metadata + consecutiveMatches: {}, + gameStartTime: null, + gameEndTime: null, + currentMoveStartTime: null, + timerInterval: null, + celebrationAnimations: [], + isProcessingMove: false, + showMismatchFeedback: false, + lastMatchedPair: null, + // PAUSE/RESUME: Initialize paused game fields + originalConfig: undefined, + pausedGamePhase: undefined, + pausedGameState: undefined, + // HOVER: Initialize hover state + playerHovers: {}, + }; + } +} +exports.MatchingGameValidator = MatchingGameValidator; +// Singleton instance +exports.matchingGameValidator = new MatchingGameValidator(); diff --git a/apps/web/src/lib/arcade/validation/index.js b/apps/web/src/lib/arcade/validation/index.js new file mode 100644 index 00000000..479f9d03 --- /dev/null +++ b/apps/web/src/lib/arcade/validation/index.js @@ -0,0 +1,37 @@ +"use strict"; +/** + * Game validator registry + * Maps game names to their validators + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.matchingGameValidator = void 0; +exports.getValidator = getValidator; +const MatchingGameValidator_1 = require("./MatchingGameValidator"); +const validators = new Map([ + ['matching', MatchingGameValidator_1.matchingGameValidator], + // Add other game validators here as they're implemented +]); +function getValidator(gameName) { + const validator = validators.get(gameName); + if (!validator) { + throw new Error(`No validator found for game: ${gameName}`); + } + return validator; +} +var MatchingGameValidator_2 = require("./MatchingGameValidator"); +Object.defineProperty(exports, "matchingGameValidator", { enumerable: true, get: function () { return MatchingGameValidator_2.matchingGameValidator; } }); +__exportStar(require("./types"), exports); diff --git a/apps/web/src/lib/arcade/validation/types.js b/apps/web/src/lib/arcade/validation/types.js new file mode 100644 index 00000000..82917344 --- /dev/null +++ b/apps/web/src/lib/arcade/validation/types.js @@ -0,0 +1,6 @@ +"use strict"; +/** + * Isomorphic game validation types + * Used on both client and server for arcade session validation + */ +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/apps/web/tsconfig.server.json b/apps/web/tsconfig.server.json new file mode 100644 index 00000000..8502d83b --- /dev/null +++ b/apps/web/tsconfig.server.json @@ -0,0 +1,33 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "target": "es2020", + "outDir": ".", + "rootDir": ".", + "noEmit": false, + "incremental": false, + "skipLibCheck": true, + "skipDefaultLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "types": ["node", "react"] + }, + "include": [ + "src/db/index.ts", + "src/db/schema.ts", + "src/db/migrate.ts", + "src/lib/arcade/**/*.ts", + "src/app/games/matching/context/types.ts", + "src/app/games/matching/utils/cardGeneration.ts", + "src/app/games/matching/utils/matchValidation.ts", + "socket-server.ts" + ], + "exclude": [ + "node_modules", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.spec.ts" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df0c7c90..22d7c054 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,6 +240,9 @@ importers: storybook: specifier: ^9.1.7 version: 9.1.10(@testing-library/dom@9.3.4)(prettier@3.6.2)(vite@5.4.20(@types/node@20.19.19)(terser@5.44.0)) + tsc-alias: + specifier: ^1.8.16 + version: 1.8.16 tsx: specifier: ^4.20.5 version: 4.20.6 @@ -4679,6 +4682,10 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + common-path-prefix@3.0.0: resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==} @@ -6937,6 +6944,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mylas@2.1.13: + resolution: {integrity: sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==} + engines: {node: '>=12.0.0'} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -7508,6 +7519,10 @@ packages: engines: {node: '>=18'} hasBin: true + plimit-lit@1.6.1: + resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==} + engines: {node: '>=12'} + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -7744,6 +7759,10 @@ packages: resolution: {integrity: sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==} engines: {node: '>=0.4.x'} + queue-lit@1.5.2: + resolution: {integrity: sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==} + engines: {node: '>=12'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -8702,6 +8721,11 @@ packages: ts-pattern@5.0.5: resolution: {integrity: sha512-tL0w8U/pgaacOmkb9fRlYzWEUDCfVjjv9dD4wHTgZ61MjhuMt46VNWTG747NqW6vRzoWIKABVhFSOJ82FvXrfA==} + tsc-alias@1.8.16: + resolution: {integrity: sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==} + engines: {node: '>=16.20.2'} + hasBin: true + tsconfck@2.1.2: resolution: {integrity: sha512-ghqN1b0puy3MhhviwO2kGF8SeMDNhEbnKxjK7h6+fvY9JAxqvXi8y5NAHSQv687OVboS2uZIByzGd45/YxrRHg==} engines: {node: ^14.13.1 || ^16 || >=18} @@ -14271,6 +14295,8 @@ snapshots: commander@8.3.0: {} + commander@9.5.0: {} + common-path-prefix@3.0.0: {} commondir@1.0.1: {} @@ -16834,6 +16860,8 @@ snapshots: ms@2.1.3: {} + mylas@2.1.13: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -17334,6 +17362,10 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + plimit-lit@1.6.1: + dependencies: + queue-lit: 1.5.2 + pluralize@8.0.0: {} polished@4.3.1: @@ -17583,6 +17615,8 @@ snapshots: querystring-es3@0.2.1: {} + queue-lit@1.5.2: {} + queue-microtask@1.2.3: {} ramda@0.29.0: {} @@ -18698,6 +18732,16 @@ snapshots: ts-pattern@5.0.5: {} + tsc-alias@1.8.16: + dependencies: + chokidar: 3.6.0 + commander: 9.5.0 + get-tsconfig: 4.11.0 + globby: 11.1.0 + mylas: 2.1.13 + normalize-path: 3.0.0 + plimit-lit: 1.6.1 + tsconfck@2.1.2(typescript@5.9.3): optionalDependencies: typescript: 5.9.3