Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3158addda1 | ||
|
|
71b0aac13c |
@@ -1,3 +1,10 @@
|
||||
## [2.10.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.10.0...v2.10.1) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enforce player ownership authorization for multiplayer games ([71b0aac](https://github.com/antialias/soroban-abacus-flashcards/commit/71b0aac13c970c03fe8d296d41e9472ad72a00fa))
|
||||
|
||||
## [2.10.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.9.0...v2.10.0) (2025-10-09)
|
||||
|
||||
|
||||
|
||||
@@ -26,8 +26,13 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
|
||||
displayEmoji: player.emoji,
|
||||
score: state.scores[player.id] || 0,
|
||||
consecutiveMatches: state.consecutiveMatches?.[player.id] || 0,
|
||||
isLocalPlayer: player.isLocal !== false, // Local if not explicitly marked as remote
|
||||
}))
|
||||
|
||||
// Check if current player is local (your turn) or remote (waiting)
|
||||
const currentPlayer = activePlayers.find((p) => p.id === state.currentPlayer)
|
||||
const isYourTurn = currentPlayer?.isLocalPlayer === true
|
||||
|
||||
// Get celebration level based on consecutive matches
|
||||
const getCelebrationLevel = (consecutiveMatches: number) => {
|
||||
if (consecutiveMatches >= 5) return 'legendary'
|
||||
@@ -250,14 +255,16 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
|
||||
{isCurrentPlayer && (
|
||||
<span
|
||||
className={css({
|
||||
color: 'red.600',
|
||||
color: player.isLocalPlayer ? 'red.600' : 'blue.600',
|
||||
fontWeight: 'black',
|
||||
fontSize: isCurrentPlayer ? { base: 'sm', md: 'lg' } : 'inherit',
|
||||
animation: 'none',
|
||||
textShadow: '0 0 15px currentColor',
|
||||
animation: player.isLocalPlayer
|
||||
? 'none'
|
||||
: 'gentle-pulse 2s ease-in-out infinite',
|
||||
textShadow: player.isLocalPlayer ? '0 0 15px currentColor' : 'none',
|
||||
})}
|
||||
>
|
||||
{' • Your turn'}
|
||||
{player.isLocalPlayer ? ' • Your turn' : ' • Their turn'}
|
||||
</span>
|
||||
)}
|
||||
{player.consecutiveMatches > 1 && (
|
||||
|
||||
@@ -146,6 +146,8 @@ export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
// Computed values
|
||||
const isGameActive = state.gamePhase === 'playing'
|
||||
|
||||
const { players } = useGameMode()
|
||||
|
||||
const canFlipCard = useCallback(
|
||||
(cardId: string): boolean => {
|
||||
if (!isGameActive || state.isProcessingMove) return false
|
||||
@@ -159,9 +161,27 @@ export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode })
|
||||
// Can't flip more than 2 cards
|
||||
if (state.flippedCards.length >= 2) return false
|
||||
|
||||
// Authorization check: Only allow flipping if it's your player's turn
|
||||
if (roomData && state.currentPlayer) {
|
||||
const currentPlayerData = players.get(state.currentPlayer)
|
||||
// If current player is not local (isLocal === false), prevent flipping
|
||||
if (currentPlayerData && currentPlayerData.isLocal === false) {
|
||||
console.log('[Client] Cannot flip - not your turn (current player is remote)')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
[isGameActive, state.isProcessingMove, state.gameCards, state.flippedCards]
|
||||
[
|
||||
isGameActive,
|
||||
state.isProcessingMove,
|
||||
state.gameCards,
|
||||
state.flippedCards,
|
||||
state.currentPlayer,
|
||||
roomData,
|
||||
players,
|
||||
]
|
||||
)
|
||||
|
||||
const currentGameStatistics: GameStatistics = useMemo(
|
||||
|
||||
@@ -159,8 +159,28 @@ export async function applyGameMove(userId: string, move: GameMove): Promise<Ses
|
||||
gameStatePhase: (session.gameState as any)?.gamePhase,
|
||||
})
|
||||
|
||||
// Validate the move
|
||||
const validationResult = validator.validateMove(session.gameState, move)
|
||||
// Fetch player ownership for authorization checks (room-based games)
|
||||
let playerOwnership: Record<string, string> | undefined
|
||||
if (session.roomId) {
|
||||
try {
|
||||
const players = await 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)
|
||||
} catch (error) {
|
||||
console.error('[SessionManager] Failed to fetch player ownership:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the move with authorization context
|
||||
const validationResult = validator.validateMove(session.gameState, move, {
|
||||
userId,
|
||||
playerOwnership,
|
||||
})
|
||||
|
||||
console.log('[SessionManager] Validation result:', {
|
||||
valid: validationResult.valid,
|
||||
|
||||
@@ -15,10 +15,14 @@ import { canFlipCard, validateMatch } from '@/app/games/matching/utils/matchVali
|
||||
import type { GameValidator, MatchingGameMove, ValidationResult } from './types'
|
||||
|
||||
export class MatchingGameValidator implements GameValidator<MemoryPairsState, MatchingGameMove> {
|
||||
validateMove(state: MemoryPairsState, move: MatchingGameMove): ValidationResult {
|
||||
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)
|
||||
return this.validateFlipCard(state, move.data.cardId, move.playerId, context)
|
||||
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data.activePlayers, move.data.cards)
|
||||
@@ -37,7 +41,8 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
private validateFlipCard(
|
||||
state: MemoryPairsState,
|
||||
cardId: string,
|
||||
playerId: string
|
||||
playerId: string,
|
||||
context?: { userId?: string; playerOwnership?: Record<string, string> }
|
||||
): ValidationResult {
|
||||
// Game must be in playing phase
|
||||
if (state.gamePhase !== 'playing') {
|
||||
@@ -63,6 +68,22 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -49,14 +49,25 @@ export type MatchingGameMove =
|
||||
// Generic game state union
|
||||
export type GameState = MemoryPairsState // Add other game states as union later
|
||||
|
||||
/**
|
||||
* Validation context for authorization checks
|
||||
*/
|
||||
export interface ValidationContext {
|
||||
userId?: string
|
||||
playerOwnership?: Record<string, string> // playerId -> userId mapping
|
||||
}
|
||||
|
||||
/**
|
||||
* Base validator interface that all games must implement
|
||||
*/
|
||||
export interface GameValidator<TState = unknown, TMove extends GameMove = GameMove> {
|
||||
/**
|
||||
* Validate a game move and return the new state if valid
|
||||
* @param state Current game state
|
||||
* @param move The move to validate
|
||||
* @param context Optional validation context for authorization checks
|
||||
*/
|
||||
validateMove(state: TState, move: TMove): ValidationResult
|
||||
validateMove(state: TState, move: TMove, context?: ValidationContext): ValidationResult
|
||||
|
||||
/**
|
||||
* Check if the game is in a terminal state (completed)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "2.10.0",
|
||||
"version": "2.10.1",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user