Compare commits

..

4 Commits

Author SHA1 Message Date
semantic-release-bot
6db2740b79 chore(release): 4.2.1 [skip ci]
## [4.2.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.2.0...v4.2.1) (2025-10-16)

### Bug Fixes

* **socket-io:** update import path for socket-server module ([1a64dec](1a64decf5a))

### Code Refactoring

* **matching:** complete validator migration to modular location ([f2958cd](f2958cd8c4))
2025-10-16 05:49:29 +00:00
Thomas Hallock
1a64decf5a fix(socket-io): update import path for socket-server module
Fix import path from '../../socket-server' to '../socket-server'
to point to the TypeScript source file instead of the deleted
compiled file in the root.

Path resolution:
- From: src/lib/socket-io.ts
- Import: '../socket-server'
- Resolves to: src/socket-server.ts

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 00:48:38 -05:00
Thomas Hallock
75c8ec27b7 build: remove obsolete root socket-server.js file
This compiled file was outdated with old validator imports.
Build system now correctly generates it in dist/socket-server.js
during the TypeScript compilation step (tsc + tsc-alias).

server.js correctly imports from dist/socket-server.js.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 00:48:38 -05:00
Thomas Hallock
f2958cd8c4 refactor(matching): complete validator migration to modular location
Complete Phase 3 of matching migration plan:
- Update validators.ts to import from @/arcade-games/matching/Validator
- Delete old validator from /lib/arcade/validation/
- Now consistent with other modular games (memory-quiz, number-guesser, math-sprint)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-16 00:48:38 -05:00
6 changed files with 15 additions and 916 deletions

View File

@@ -1,3 +1,15 @@
## [4.2.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.2.0...v4.2.1) (2025-10-16)
### Bug Fixes
* **socket-io:** update import path for socket-server module ([1a64dec](https://github.com/antialias/soroban-abacus-flashcards/commit/1a64decf5afe67c16e1aec283262ffa6132dcd83))
### Code Refactoring
* **matching:** complete validator migration to modular location ([f2958cd](https://github.com/antialias/soroban-abacus-flashcards/commit/f2958cd8c424989b8651ea666ce9843e97e75929))
## [4.2.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.1.0...v4.2.0) (2025-10-16)

View File

@@ -1,343 +0,0 @@
"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;
}

View File

@@ -1,570 +0,0 @@
/**
* Server-side validator for matching game
* Validates all game moves and state transitions
*/
import type { GameCard, MemoryPairsState, Player } from '@/arcade-games/matching/types'
import { generateGameCards } from '@/arcade-games/matching/utils/cardGeneration'
import { canFlipCard, validateMatch } from '@/arcade-games/matching/utils/matchValidation'
import type { MatchingGameConfig } from '@/lib/arcade/game-configs'
import type { GameValidator, MatchingGameMove, ValidationResult } from './types'
export class MatchingGameValidator implements GameValidator<MemoryPairsState, MatchingGameMove> {
validateMove(
state: MemoryPairsState,
move: MatchingGameMove,
context?: { userId?: string; playerOwnership?: Record<string, string> }
): ValidationResult {
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 as any).type}`,
}
}
}
private validateFlipCard(
state: MemoryPairsState,
cardId: string,
playerId: string,
context?: { userId?: string; playerOwnership?: Record<string, string> }
): ValidationResult {
// 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 (!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 = 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,
// Clear hover state for the player whose turn is ending
playerHovers: {
...state.playerHovers,
[state.currentPlayer]: null,
},
}
}
}
return {
valid: true,
newState,
}
}
private validateStartGame(
state: MemoryPairsState,
activePlayers: Player[],
cards?: GameCard[],
playerMetadata?: { [playerId: string]: any }
): ValidationResult {
// 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 || generateGameCards(state.gameType, state.difficulty)
const newState: MemoryPairsState = {
...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,
// Clear hover state when starting new game
playerHovers: {},
}
return {
valid: true,
newState,
}
}
private validateClearMismatch(state: MemoryPairsState): ValidationResult {
// 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,
}
}
// Get the list of all non-current players whose hovers should be cleared
// (They're not playing this turn, so their hovers from previous turns should not show)
const clearedHovers = { ...state.playerHovers }
for (const playerId of state.activePlayers) {
// Clear hover for all players except the current player
// This ensures only the current player's active hover shows
if (playerId !== state.currentPlayer) {
clearedHovers[playerId] = null
}
}
// Clear mismatched cards and feedback
return {
valid: true,
newState: {
...state,
flippedCards: [],
showMismatchFeedback: false,
isProcessingMove: false,
// Clear hovers for non-current players when cards are cleared
playerHovers: clearedHovers,
},
}
}
/**
* 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.)
*/
private validateGoToSetup(state: MemoryPairsState): ValidationResult {
// 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,
playerHovers: {}, // Clear hover state when returning to setup
// 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
*/
private validateSetConfig(
state: MemoryPairsState,
field: 'gameType' | 'difficulty' | 'turnTimer',
value: any
): ValidationResult {
// 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
*/
private validateResumeGame(state: MemoryPairsState): ValidationResult {
// 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.
*/
private validateHoverCard(
state: MemoryPairsState,
cardId: string | null,
playerId: string
): ValidationResult {
// 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: MemoryPairsState): boolean {
return state.gamePhase === 'results' || state.matchedPairs === state.totalPairs
}
getInitialState(config: MatchingGameConfig): MemoryPairsState {
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: {},
}
}
}
// Singleton instance
export const matchingGameValidator = new MatchingGameValidator()

View File

@@ -10,7 +10,7 @@
* 3. GameName type will auto-update
*/
import { matchingGameValidator } from './validation/MatchingGameValidator'
import { matchingGameValidator } from '@/arcade-games/matching/Validator'
import { memoryQuizGameValidator } from '@/arcade-games/memory-quiz/Validator'
import { numberGuesserValidator } from '@/arcade-games/number-guesser/Validator'
import { mathSprintValidator } from '@/arcade-games/math-sprint/Validator'

View File

@@ -23,7 +23,7 @@ export async function getSocketIO(): Promise<SocketIOServerType | null> {
if (!socketServerModule) {
try {
// Dynamic import to avoid bundling issues
socketServerModule = await import('../../socket-server')
socketServerModule = await import('../socket-server')
} catch (error) {
console.error('[Socket IO] Failed to load socket server:', error)
return null

View File

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