Compare commits

...

6 Commits

Author SHA1 Message Date
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
8 changed files with 524 additions and 12 deletions

View File

@@ -1,3 +1,22 @@
## [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

@@ -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

@@ -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

@@ -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.18.0",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [