diff --git a/apps/web/src/lib/arcade/__tests__/player-ownership.test.ts b/apps/web/src/lib/arcade/__tests__/player-ownership.test.ts new file mode 100644 index 00000000..b5f0271a --- /dev/null +++ b/apps/web/src/lib/arcade/__tests__/player-ownership.test.ts @@ -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') + }) + }) +}) diff --git a/apps/web/src/lib/arcade/player-ownership.ts b/apps/web/src/lib/arcade/player-ownership.ts new file mode 100644 index 00000000..0831f65a --- /dev/null +++ b/apps/web/src/lib/arcade/player-ownership.ts @@ -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 + +/** + * 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 { + // 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 + // We need to invert it to Record + 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, + fallbackUserId?: string +): Record { + const metadata: Record = {} + + 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 { + const user = await db.query.users.findFirst({ + where: eq(schema.users.guestId, guestId), + columns: { id: true }, + }) + return user?.id +}