Compare commits

...

12 Commits

Author SHA1 Message Date
semantic-release-bot
41d3e08fc6 chore(release): 2.19.1 [skip ci]
## [2.19.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.19.0...v2.19.1) (2025-10-10)

### Bug Fixes

* prevent SSR hydration error in /arcade/room page ([cd38f42](cd38f42e9c))

### Code Refactoring

* migrate RoomMemoryPairsProvider to centralized ownership utilities ([10f4288](10f42887a3))
2025-10-10 14:09:22 +00:00
Thomas Hallock
cd38f42e9c fix: prevent SSR hydration error in /arcade/room page
Add mounted check to prevent QueryClientProvider errors during SSR.
React Query hooks cannot run during server-side rendering, so we
gate the hook calls until after the component has mounted on the client.

This fixes the "No QueryClient set" error that was appearing when
navigating to /arcade/room.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 09:08:26 -05:00
Thomas Hallock
10f42887a3 refactor: migrate RoomMemoryPairsProvider to centralized ownership utilities
Replace custom buildPlayerMetadata implementation with centralized
player-ownership utilities. This eliminates 33 lines of duplicate code
and ensures consistent player ownership logic across the codebase.

Changes:
- Replace inline playerOwnership map building with buildPlayerOwnershipFromRoomData()
- Use buildPlayerMetadata() from centralized module
- Remove unused useArcadeRedirect import
- Rename local callback to buildPlayerMetadataCallback for clarity

Benefits:
- Single source of truth for player ownership logic
- Reduced code duplication (33 lines eliminated)
- Consistent with session-manager and player-manager implementations
- Better maintainability and testability

Part of Phase 4 of the player ownership centralization plan.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 09:00:16 -05:00
semantic-release-bot
c7a660c153 chore(release): 2.19.0 [skip ci]
## [2.19.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.18.0...v2.19.0) (2025-10-10)

### Features

* add player ownership helper to player-manager API ([6b59a82](6b59a828aa))

### Code Refactoring

* migrate session-manager.ts to use centralized player ownership ([d3b7cc2](d3b7cc25ca))
2025-10-10 13:57:10 +00:00
Thomas Hallock
6b59a828aa feat: add player ownership helper to player-manager API
Added getPlayerOwnershipMap() as a convenience re-export of the
centralized buildPlayerOwnershipMap() utility.

This allows modules to access player ownership data through the
player-manager API, which is the natural home for player-related
server-side operations.

New function:
- getPlayerOwnershipMap(roomId?) - Returns PlayerOwnershipMap

Benefits:
- Consistent API: player data + ownership through same module
- Discovery: developers naturally look in player-manager for player ops
- Flexibility: can add room-filtering logic in future if needed
- Documentation: JSDoc example shows usage pattern

Example usage:
  const ownership = await getPlayerOwnershipMap()
  const isOwned = ownership[playerId] === userId

This is phase 3 of the player ownership centralization plan.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 08:56:15 -05:00
Thomas Hallock
d3b7cc25ca refactor: migrate session-manager.ts to use centralized player ownership
Replaced inline player ownership logic with centralized utilities from
player-ownership.ts module.

Changes:
- Import buildPlayerOwnershipMap() and getUserIdFromGuestId() from
  player-ownership module
- Remove duplicate getUserIdFromGuestId() function (now exported from module)
- Replace inline DB query with buildPlayerOwnershipMap() call
- Use PlayerOwnershipMap type for consistency

Benefits:
- Eliminates 10 lines of duplicated code
- Single source of truth for ownership logic
- Consistent with validator and other components
- Better type safety with shared types

Before: Lines 232-238 built ownership map inline
After: Line 226 calls centralized utility

This is phase 2 of the player ownership centralization plan.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 08:55:24 -05:00
semantic-release-bot
426973e3c4 chore(release): 2.18.0 [skip ci]
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.3...v2.18.0) (2025-10-10)

### Features

* add centralized player ownership utilities module ([52e0de0](52e0de022f))
2025-10-10 13:54:04 +00:00
Thomas Hallock
52e0de022f feat: add centralized player ownership utilities module
Created src/lib/arcade/player-ownership.ts to consolidate scattered
player ownership logic into a single, well-tested module.

New utilities:
- buildPlayerOwnershipMap() - server-side, DB-based (async)
- buildPlayerOwnershipFromRoomData() - client-side, from RoomData (sync)
- isPlayerOwnedByUser() - check if player belongs to user
- getPlayerOwner() - get owner userId for a player
- isUsersTurn() - check if it's a user's turn
- buildPlayerMetadata() - combine ownership + player data
- getUserIdFromGuestId() - convert guestId to internal userId

Benefits:
- Single source of truth for ownership logic
- Consistent behavior across server and client
- Comprehensive test coverage (19 unit tests)
- Type-safe PlayerOwnershipMap type
- Clear JSDoc documentation

This replaces duplicated logic previously found in:
- session-manager.ts (lines 232-239)
- RoomMemoryPairsProvider.tsx (lines 370-403)
- Multiple UI components
- Validators

Tests cover:
- Building ownership maps from different sources
- Ownership checking edge cases
- Real-world "Your turn" vs "Their turn" scenarios
- Empty/null/undefined handling

Next steps: Migrate existing code to use these utilities.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 08:53:11 -05:00
semantic-release-bot
eb56bc8b88 chore(release): 2.17.3 [skip ci]
## [2.17.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.17.3) (2025-10-10)

### Bug Fixes

* correct build-info.json import path in type declaration ([22f0be4](22f0be4d04))

### Documentation

* add plan for centralizing player ownership logic ([d3ff89a](d3ff89a0ee))
2025-10-10 13:51:00 +00:00
Thomas Hallock
9f90678151 chore: expand Claude Code auto-approved commands for tooling
Added additional tool commands to auto-approval list:
- npx tsc (TypeScript compiler direct invocation)
- npx @biomejs/biome format (direct Biome formatter)
- npx @biomejs/biome check (direct Biome checker)
- npm run lint:fix (linting with auto-fix)

This allows Claude Code to run these common development tools without
requiring manual approval for each invocation, improving workflow
efficiency during code quality checks.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 08:50:07 -05:00
Thomas Hallock
22f0be4d04 fix: correct build-info.json import path in type declaration
Changed from '@/generated/build-info.json' to '../generated/build-info.json'
to use correct relative path instead of alias.

This ensures the type declaration correctly resolves to the generated
build info file location.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 08:49:56 -05:00
Thomas Hallock
19bc0b65d9 chore: apply auto-formatting to LocalMemoryPairsProvider and tsconfig.server.json
Auto-formatter made minor formatting adjustments:
- Multi-line formatting for long function call arguments
- Single-line array formatting in tsconfig exclude

No functional changes.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 08:49:48 -05:00
12 changed files with 613 additions and 67 deletions

View File

@@ -1,3 +1,46 @@
## [2.19.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.19.0...v2.19.1) (2025-10-10)
### Bug Fixes
* prevent SSR hydration error in /arcade/room page ([cd38f42](https://github.com/antialias/soroban-abacus-flashcards/commit/cd38f42e9c90ff8bab28f7a677c1f9307406d013))
### Code Refactoring
* migrate RoomMemoryPairsProvider to centralized ownership utilities ([10f4288](https://github.com/antialias/soroban-abacus-flashcards/commit/10f42887a323a755e2741ee1b927a0bc16eb69fc))
## [2.19.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.18.0...v2.19.0) (2025-10-10)
### Features
* add player ownership helper to player-manager API ([6b59a82](https://github.com/antialias/soroban-abacus-flashcards/commit/6b59a828aabe687abb797c1f1d69d8a4d0abe49b))
### Code Refactoring
* migrate session-manager.ts to use centralized player ownership ([d3b7cc2](https://github.com/antialias/soroban-abacus-flashcards/commit/d3b7cc25caee7e005de046792202aa474edbc90f))
## [2.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.3...v2.18.0) (2025-10-10)
### Features
* add centralized player ownership utilities module ([52e0de0](https://github.com/antialias/soroban-abacus-flashcards/commit/52e0de022fcd332fc4cfffa7bfcfe6adc69cb3ff))
## [2.17.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v2.17.3) (2025-10-10)
### Bug Fixes
* correct build-info.json import path in type declaration ([22f0be4](https://github.com/antialias/soroban-abacus-flashcards/commit/22f0be4d045c6afdcb98b876f709d63a904f9449))
### Documentation
* add plan for centralizing player ownership logic ([d3ff89a](https://github.com/antialias/soroban-abacus-flashcards/commit/d3ff89a0ee53c32cc68ed01bf460919aa889d6a0))
## [2.17.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.1...v2.17.2) (2025-10-10)

View File

@@ -11,7 +11,11 @@
"Bash(git stash:*)",
"Bash(npm run format:*)",
"Bash(npm run pre-commit:*)",
"Bash(npm run type-check:*)"
"Bash(npm run type-check:*)",
"Bash(npx tsc:*)",
"Bash(npx @biomejs/biome format:*)",
"Bash(npx @biomejs/biome check:*)",
"Bash(npm run lint:fix:*)"
],
"deny": [],
"ask": []

View File

@@ -68,7 +68,10 @@ function localMemoryPairsReducer(state: MemoryPairsState, action: LocalAction):
matchedPairs: 0,
moves: 0,
scores: action.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
consecutiveMatches: action.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
consecutiveMatches: action.activePlayers.reduce(
(acc: any, p: string) => ({ ...acc, [p]: 0 }),
{}
),
activePlayers: action.activePlayers,
playerMetadata: action.playerMetadata,
currentPlayer: action.activePlayers[0] || '',
@@ -97,7 +100,8 @@ function localMemoryPairsReducer(state: MemoryPairsState, action: LocalAction):
return {
...state,
flippedCards: newFlippedCards,
currentMoveStartTime: state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
currentMoveStartTime:
state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
isProcessingMove: newFlippedCards.length === 2,
showMismatchFeedback: false,
}
@@ -386,7 +390,14 @@ export function LocalMemoryPairsProvider({ children }: { children: ReactNode })
return true
},
[isGameActive, state.isProcessingMove, state.gameCards, state.flippedCards, state.currentPlayer, players]
[
isGameActive,
state.isProcessingMove,
state.gameCards,
state.flippedCards,
state.currentPlayer,
players,
]
)
const currentGameStatistics: GameStatistics = useMemo(

View File

@@ -1,10 +1,13 @@
'use client'
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import {
buildPlayerMetadata,
buildPlayerOwnershipFromRoomData,
} from '@/lib/arcade/player-ownership'
import type { GameMove } from '@/lib/arcade/validation'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { generateGameCards } from '../utils/cardGeneration'
@@ -366,38 +369,14 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
}, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged])
// Helper to build player metadata with correct userId ownership
// This uses roomData.memberPlayers to determine which user owns which player
const buildPlayerMetadata = useCallback(
// Uses centralized utilities from player-ownership module
const buildPlayerMetadataCallback = useCallback(
(playerIds: string[]) => {
const playerMetadata: { [playerId: string]: any } = {}
// Build ownership map from roomData
const ownershipMap = buildPlayerOwnershipFromRoomData(roomData)
// Build reverse mapping: playerId -> userId from roomData.memberPlayers
const playerOwnership = new Map<string, string>()
if (roomData?.memberPlayers) {
for (const [userId, userPlayers] of Object.entries(roomData.memberPlayers)) {
for (const player of userPlayers) {
playerOwnership.set(player.id, userId)
}
}
}
for (const playerId of playerIds) {
const playerData = players.get(playerId)
if (playerData) {
// Get the actual owner userId from roomData, or use local viewerId as fallback
const ownerUserId = playerOwnership.get(playerId) || viewerId || ''
playerMetadata[playerId] = {
id: playerId,
name: playerData.name,
emoji: playerData.emoji,
userId: ownerUserId, // CORRECT: Use actual owner's userId
color: playerData.color,
}
}
}
return playerMetadata
// Build player metadata with correct ownership
return buildPlayerMetadata(playerIds, ownershipMap, players, viewerId)
},
[players, roomData, viewerId]
)
@@ -412,7 +391,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
// Capture player metadata from local players map
// This ensures all room members can display player info even if they don't own the players
const playerMetadata = buildPlayerMetadata(activePlayers)
const playerMetadata = buildPlayerMetadataCallback(activePlayers)
// Use current session state configuration (no local state!)
const cards = generateGameCards(state.gameType, state.difficulty)
@@ -427,7 +406,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
playerMetadata,
},
})
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove])
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadataCallback, sendMove])
const flipCard = useCallback(
(cardId: string) => {
@@ -464,7 +443,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
}
// Capture player metadata with correct userId ownership
const playerMetadata = buildPlayerMetadata(activePlayers)
const playerMetadata = buildPlayerMetadataCallback(activePlayers)
// Use current session state configuration (no local state!)
const cards = generateGameCards(state.gameType, state.difficulty)
@@ -479,7 +458,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
playerMetadata,
},
})
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove])
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadataCallback, sendMove])
const setGameType = useCallback(
(gameType: typeof state.gameType) => {

View File

@@ -1,5 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { useRoomData } from '@/hooks/useRoomData'
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProvider'
@@ -13,8 +14,31 @@ import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProv
* - useArcadeRedirect on /arcade page handles redirecting to active sessions
*/
export default function RoomPage() {
const [mounted, setMounted] = useState(false)
const { roomData, isLoading } = useRoomData()
// Prevent SSR hydration mismatch
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Loading...
</div>
)
}
// Show loading state
if (isLoading) {
return (

View File

@@ -0,0 +1,258 @@
/**
* Unit tests for player ownership utilities
*/
import { describe, expect, it } from 'vitest'
import type { RoomData } from '@/hooks/useRoomData'
import {
type PlayerOwnershipMap,
buildPlayerMetadata,
buildPlayerOwnershipFromRoomData,
getPlayerOwner,
isPlayerOwnedByUser,
isUsersTurn,
} from '../player-ownership'
describe('player-ownership utilities', () => {
describe('buildPlayerOwnershipFromRoomData', () => {
it('builds ownership map from roomData.memberPlayers', () => {
const roomData: RoomData = {
id: 'room-1',
name: 'Test Room',
code: 'ABC123',
gameName: 'matching',
members: [],
memberPlayers: {
'user-1': [
{ id: 'player-1', name: 'Player 1', emoji: '😀', color: '#3b82f6' },
{ id: 'player-2', name: 'Player 2', emoji: '😎', color: '#8b5cf6' },
],
'user-2': [{ id: 'player-3', name: 'Player 3', emoji: '🤠', color: '#10b981' }],
},
}
const ownershipMap = buildPlayerOwnershipFromRoomData(roomData)
expect(ownershipMap).toEqual({
'player-1': 'user-1',
'player-2': 'user-1',
'player-3': 'user-2',
})
})
it('returns empty object for null roomData', () => {
const ownershipMap = buildPlayerOwnershipFromRoomData(null)
expect(ownershipMap).toEqual({})
})
it('returns empty object for undefined roomData', () => {
const ownershipMap = buildPlayerOwnershipFromRoomData(undefined)
expect(ownershipMap).toEqual({})
})
it('returns empty object for roomData without memberPlayers', () => {
const roomData = {
id: 'room-1',
name: 'Test Room',
code: 'ABC123',
gameName: 'matching',
members: [],
memberPlayers: {},
} as RoomData
const ownershipMap = buildPlayerOwnershipFromRoomData(roomData)
expect(ownershipMap).toEqual({})
})
})
describe('isPlayerOwnedByUser', () => {
const ownershipMap: PlayerOwnershipMap = {
'player-1': 'user-1',
'player-2': 'user-1',
'player-3': 'user-2',
}
it('returns true when player is owned by user', () => {
expect(isPlayerOwnedByUser('player-1', 'user-1', ownershipMap)).toBe(true)
expect(isPlayerOwnedByUser('player-2', 'user-1', ownershipMap)).toBe(true)
expect(isPlayerOwnedByUser('player-3', 'user-2', ownershipMap)).toBe(true)
})
it('returns false when player is not owned by user', () => {
expect(isPlayerOwnedByUser('player-1', 'user-2', ownershipMap)).toBe(false)
expect(isPlayerOwnedByUser('player-3', 'user-1', ownershipMap)).toBe(false)
})
it('returns false for unknown player', () => {
expect(isPlayerOwnedByUser('player-unknown', 'user-1', ownershipMap)).toBe(false)
})
})
describe('getPlayerOwner', () => {
const ownershipMap: PlayerOwnershipMap = {
'player-1': 'user-1',
'player-2': 'user-1',
'player-3': 'user-2',
}
it('returns correct owner userId for player', () => {
expect(getPlayerOwner('player-1', ownershipMap)).toBe('user-1')
expect(getPlayerOwner('player-2', ownershipMap)).toBe('user-1')
expect(getPlayerOwner('player-3', ownershipMap)).toBe('user-2')
})
it('returns undefined for unknown player', () => {
expect(getPlayerOwner('player-unknown', ownershipMap)).toBeUndefined()
})
})
describe('isUsersTurn', () => {
const ownershipMap: PlayerOwnershipMap = {
'player-1': 'user-1',
'player-2': 'user-1',
'player-3': 'user-2',
}
it('returns true when current player belongs to user', () => {
expect(isUsersTurn('player-1', 'user-1', ownershipMap)).toBe(true)
expect(isUsersTurn('player-3', 'user-2', ownershipMap)).toBe(true)
})
it('returns false when current player belongs to different user', () => {
expect(isUsersTurn('player-1', 'user-2', ownershipMap)).toBe(false)
expect(isUsersTurn('player-3', 'user-1', ownershipMap)).toBe(false)
})
it('returns false for unknown player', () => {
expect(isUsersTurn('player-unknown', 'user-1', ownershipMap)).toBe(false)
})
})
describe('buildPlayerMetadata', () => {
const ownershipMap: PlayerOwnershipMap = {
'player-1': 'user-1',
'player-2': 'user-1',
'player-3': 'user-2',
}
const playersMap = new Map([
['player-1', { name: 'Player 1', emoji: '😀', color: '#3b82f6' }],
['player-2', { name: 'Player 2', emoji: '😎', color: '#8b5cf6' }],
['player-3', { name: 'Player 3', emoji: '🤠', color: '#10b981' }],
])
it('builds metadata with correct ownership', () => {
const metadata = buildPlayerMetadata(
['player-1', 'player-2', 'player-3'],
ownershipMap,
playersMap
)
expect(metadata).toEqual({
'player-1': {
id: 'player-1',
name: 'Player 1',
emoji: '😀',
userId: 'user-1',
color: '#3b82f6',
},
'player-2': {
id: 'player-2',
name: 'Player 2',
emoji: '😎',
userId: 'user-1',
color: '#8b5cf6',
},
'player-3': {
id: 'player-3',
name: 'Player 3',
emoji: '🤠',
userId: 'user-2',
color: '#10b981',
},
})
})
it('uses fallback userId when player not in ownership map', () => {
const metadata = buildPlayerMetadata(
['player-1', 'player-4'],
ownershipMap,
playersMap,
'fallback-user'
)
// player-1 has ownership, but player-4 is not in playersMap
// so it won't be in metadata at all
expect(metadata['player-1']?.userId).toBe('user-1')
expect(metadata['player-4']).toBeUndefined()
})
it('skips players not in playersMap', () => {
const metadata = buildPlayerMetadata(['player-1', 'player-unknown'], ownershipMap, playersMap)
expect(metadata['player-1']).toBeDefined()
expect(metadata['player-unknown']).toBeUndefined()
})
it('handles empty playerIds array', () => {
const metadata = buildPlayerMetadata([], ownershipMap, playersMap)
expect(metadata).toEqual({})
})
})
describe('edge cases', () => {
it('handles empty ownership map', () => {
const emptyMap: PlayerOwnershipMap = {}
expect(isPlayerOwnedByUser('player-1', 'user-1', emptyMap)).toBe(false)
expect(getPlayerOwner('player-1', emptyMap)).toBeUndefined()
expect(isUsersTurn('player-1', 'user-1', emptyMap)).toBe(false)
})
it('handles empty strings', () => {
const ownershipMap: PlayerOwnershipMap = {
'player-1': 'user-1',
}
expect(isPlayerOwnedByUser('', 'user-1', ownershipMap)).toBe(false)
expect(getPlayerOwner('', ownershipMap)).toBeUndefined()
expect(isUsersTurn('', 'user-1', ownershipMap)).toBe(false)
})
})
describe('real-world scenario: turn indicator logic', () => {
it('reproduces the "Your turn" vs "Their turn" bug and fix', () => {
const roomData: RoomData = {
id: 'room-1',
name: 'Game Room',
code: 'ABC123',
gameName: 'matching',
members: [],
memberPlayers: {
'local-user-id': [
{ id: 'local-player-1', name: 'My Player 1', emoji: '😀', color: '#3b82f6' },
{ id: 'local-player-2', name: 'My Player 2', emoji: '😎', color: '#8b5cf6' },
],
'remote-user-id': [
{ id: 'remote-player-1', name: 'Their Player', emoji: '🤠', color: '#10b981' },
],
},
}
const ownershipMap = buildPlayerOwnershipFromRoomData(roomData)
const viewerId = 'local-user-id'
// Scenario 1: It's my turn (local player is current)
const currentPlayer1 = 'local-player-1'
const isMyTurn1 = isUsersTurn(currentPlayer1, viewerId, ownershipMap)
expect(isMyTurn1).toBe(true)
expect(isMyTurn1 ? 'Your turn' : 'Their turn').toBe('Your turn')
// Scenario 2: It's their turn (remote player is current)
const currentPlayer2 = 'remote-player-1'
const isMyTurn2 = isUsersTurn(currentPlayer2, viewerId, ownershipMap)
expect(isMyTurn2).toBe(false)
expect(isMyTurn2 ? 'Your turn' : 'Their turn').toBe('Their turn')
})
})
})

View File

@@ -6,6 +6,7 @@
import { and, eq } from 'drizzle-orm'
import { db, schema } from '@/db'
import type { Player } from '@/db/schema/players'
import { type PlayerOwnershipMap, buildPlayerOwnershipMap } from './player-ownership'
/**
* Get all players for a user (regardless of isActive status)
@@ -128,3 +129,21 @@ export async function getPlayers(playerIds: string[]): Promise<Player[]> {
return players
}
/**
* Get player ownership map for a room
*
* Convenience re-export of the centralized player ownership utility.
* This allows other modules to get player ownership data through
* the player-manager API.
*
* @param roomId - Optional room ID (currently unused by underlying utility)
* @returns Promise resolving to playerOwnership map (playerId -> userId)
*
* @example
* const ownership = await getPlayerOwnershipMap()
* const isOwned = ownership[playerId] === userId
*/
export async function getPlayerOwnershipMap(roomId?: string): Promise<PlayerOwnershipMap> {
return buildPlayerOwnershipMap(roomId)
}

View File

@@ -0,0 +1,225 @@
/**
* Player Ownership Utilities
*
* Centralized module for determining player ownership across the codebase.
* Provides consistent utilities for both server-side (DB-based) and client-side
* (RoomData-based) player ownership checking.
*
* This module solves the problem of scattered player ownership logic that
* previously existed in 4+ locations with different implementations.
*/
import { eq } from 'drizzle-orm'
import { db, schema } from '@/db'
import type { RoomData } from '@/hooks/useRoomData'
/**
* Player ownership mapping: playerId -> userId
*
* This is the canonical representation of player ownership used throughout
* the application. Both server-side and client-side utilities return this type.
*/
export type PlayerOwnershipMap = Record<string, string>
/**
* Player metadata with ownership information
*
* Used when building player metadata for game state that needs to be
* shared across room members.
*/
export interface PlayerMetadata {
id: string
name: string
emoji: string
userId: string // Owner's user ID
color: string
}
/**
* SERVER-SIDE: Build player ownership map from database
*
* Queries the database to get all players and their owner userIds.
* Used by session-manager and validators for authorization checks.
*
* @param roomId - Optional room ID to filter players (currently unused, fetches all)
* @returns Promise resolving to playerOwnership map
*
* @example
* const ownership = await buildPlayerOwnershipMap()
* // { "player-uuid-1": "user-uuid-1", "player-uuid-2": "user-uuid-2" }
*/
export async function buildPlayerOwnershipMap(roomId?: string): Promise<PlayerOwnershipMap> {
// Fetch all players with their userId ownership
const players = await db.query.players.findMany({
columns: {
id: true,
userId: true,
},
})
// Convert to ownership map: playerId -> userId
return Object.fromEntries(players.map((p) => [p.id, p.userId]))
}
/**
* CLIENT-SIDE: Build player ownership map from RoomData
*
* Constructs ownership map from the memberPlayers structure in RoomData.
* Used by React components and providers for client-side ownership checks.
*
* @param roomData - Room data containing memberPlayers mapping
* @returns PlayerOwnershipMap
*
* @example
* const ownership = buildPlayerOwnershipFromRoomData(roomData)
* // { "player-uuid-1": "user-uuid-1", "player-uuid-2": "user-uuid-2" }
*/
export function buildPlayerOwnershipFromRoomData(
roomData: RoomData | null | undefined
): PlayerOwnershipMap {
if (!roomData?.memberPlayers) {
return {}
}
const ownershipMap: PlayerOwnershipMap = {}
// memberPlayers is Record<userId, RoomPlayer[]>
// We need to invert it to Record<playerId, userId>
for (const [userId, userPlayers] of Object.entries(roomData.memberPlayers)) {
for (const player of userPlayers) {
ownershipMap[player.id] = userId
}
}
return ownershipMap
}
/**
* Check if a player is owned by a specific user
*
* @param playerId - The player ID to check
* @param userId - The user ID to check ownership against
* @param ownershipMap - Player ownership mapping
* @returns true if the player belongs to the user
*
* @example
* const isOwned = isPlayerOwnedByUser(playerId, currentUserId, ownershipMap)
* if (!isOwned) {
* return { valid: false, error: 'Not your player' }
* }
*/
export function isPlayerOwnedByUser(
playerId: string,
userId: string,
ownershipMap: PlayerOwnershipMap
): boolean {
return ownershipMap[playerId] === userId
}
/**
* Get the owner userId for a player
*
* @param playerId - The player ID to look up
* @param ownershipMap - Player ownership mapping
* @returns The owner's userId, or undefined if not found
*
* @example
* const owner = getPlayerOwner(playerId, ownershipMap)
* if (owner !== currentUserId) {
* console.log('This player belongs to another user')
* }
*/
export function getPlayerOwner(
playerId: string,
ownershipMap: PlayerOwnershipMap
): string | undefined {
return ownershipMap[playerId]
}
/**
* Build player metadata with correct ownership information
*
* Combines player data with ownership information to create complete
* metadata objects. This is used when starting games or sending player
* info across the network.
*
* @param playerIds - Array of player IDs to include
* @param ownershipMap - Player ownership mapping
* @param playersMap - Map of player ID to player data (from GameModeContext)
* @param fallbackUserId - UserId to use if player not found in ownership map
* @returns Record of playerId to PlayerMetadata
*
* @example
* const metadata = buildPlayerMetadata(
* activePlayers,
* ownershipMap,
* players,
* viewerId
* )
* // Send metadata with game state
*/
export function buildPlayerMetadata(
playerIds: string[],
ownershipMap: PlayerOwnershipMap,
playersMap: Map<string, { name: string; emoji: string; color: string }>,
fallbackUserId?: string
): Record<string, PlayerMetadata> {
const metadata: Record<string, PlayerMetadata> = {}
for (const playerId of playerIds) {
const playerData = playersMap.get(playerId)
if (!playerData) continue
// Get the actual owner userId from ownership map, or use fallback
const ownerUserId = ownershipMap[playerId] || fallbackUserId || ''
metadata[playerId] = {
id: playerId,
name: playerData.name,
emoji: playerData.emoji,
userId: ownerUserId,
color: playerData.color,
}
}
return metadata
}
/**
* Check if it's a specific user's turn in a game
*
* Convenience function that combines current player check with ownership check.
*
* @param currentPlayerId - The ID of the player whose turn it is
* @param userId - The user ID to check
* @param ownershipMap - Player ownership mapping
* @returns true if it's this user's turn
*
* @example
* const isMyTurn = isUsersTurn(state.currentPlayer, viewerId, ownershipMap)
* const label = isMyTurn ? 'Your turn' : 'Their turn'
*/
export function isUsersTurn(
currentPlayerId: string,
userId: string,
ownershipMap: PlayerOwnershipMap
): boolean {
return isPlayerOwnedByUser(currentPlayerId, userId, ownershipMap)
}
/**
* SERVER-SIDE: Convert guestId to internal userId
*
* Helper to convert the guestId (from cookies) to the internal database userId.
* This is needed because the database uses internal user.id as foreign keys.
*
* @param guestId - The guest ID from the cookie
* @returns The internal user ID, or undefined if not found
*/
export async function getUserIdFromGuestId(guestId: string): Promise<string | undefined> {
const user = await db.query.users.findFirst({
where: eq(schema.users.guestId, guestId),
columns: { id: true },
})
return user?.id
}

View File

@@ -5,6 +5,11 @@
import { eq } from 'drizzle-orm'
import { db, schema } from '@/db'
import {
buildPlayerOwnershipMap,
getUserIdFromGuestId,
type PlayerOwnershipMap,
} from './player-ownership'
import { type GameMove, type GameName, getValidator } from './validation'
export interface CreateSessionOptions {
@@ -25,18 +30,6 @@ export interface SessionUpdateResult {
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: string): Promise<string | undefined> {
const user = await db.query.users.findFirst({
where: eq(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
@@ -215,7 +208,7 @@ export async function applyGameMove(
})
// Fetch player ownership for authorization checks (room-based games)
let playerOwnership: Record<string, string> | undefined
let playerOwnership: PlayerOwnershipMap | undefined
let internalUserId: string | undefined
if (session.roomId) {
try {
@@ -229,13 +222,8 @@ export async function applyGameMove(
}
}
const players = await db.query.players.findMany({
columns: {
id: true,
userId: true,
},
})
playerOwnership = Object.fromEntries(players.map((p) => [p.id, p.userId]))
// Use centralized player ownership utility
playerOwnership = await buildPlayerOwnershipMap(session.roomId)
console.log('[SessionManager] Player ownership map:', playerOwnership)
console.log('[SessionManager] Internal userId for authorization:', internalUserId)
} catch (error) {

View File

@@ -1,4 +1,4 @@
declare module '@/generated/build-info.json' {
declare module '../generated/build-info.json' {
interface BuildInfo {
version: string
buildTime: string

View File

@@ -24,10 +24,5 @@
"src/app/games/matching/utils/matchValidation.ts",
"socket-server.ts"
],
"exclude": [
"node_modules",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.ts"
]
"exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts"]
}

View File

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