diff --git a/apps/web/src/arcade-games/know-your-world/Provider.tsx b/apps/web/src/arcade-games/know-your-world/Provider.tsx index 7026740f..c9dbb6f9 100644 --- a/apps/web/src/arcade-games/know-your-world/Provider.tsx +++ b/apps/web/src/arcade-games/know-your-world/Provider.tsx @@ -35,8 +35,15 @@ interface KnowYourWorldContextValue { setContinent: (continent: import('./continents').ContinentId | 'all') => void // Cursor position sharing (for multiplayer) - otherPlayerCursors: Record - sendCursorUpdate: (playerId: string, cursorPosition: { x: number; y: number } | null) => void + otherPlayerCursors: Record + sendCursorUpdate: ( + playerId: string, + userId: string, + cursorPosition: { x: number; y: number } | null + ) => void + + // Member players mapping (userId -> players) for cursor emoji display + memberPlayers: Record> } const KnowYourWorldContext = createContext(null) @@ -102,8 +109,10 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode guessHistory: [], startTime: 0, activePlayers: [], + activeUserIds: [], playerMetadata: {}, giveUpReveal: null, + giveUpVotes: [], } }, [roomData]) @@ -122,11 +131,11 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode applyMove: (state) => state, // Server handles all state updates }) - // Pass through cursor updates with the provided player ID + // Pass through cursor updates with the provided player ID and userId const sendCursorUpdate = useCallback( - (playerId: string, cursorPosition: { x: number; y: number } | null) => { - if (playerId) { - sessionSendCursorUpdate(playerId, cursorPosition) + (playerId: string, sessionUserId: string, cursorPosition: { x: number; y: number } | null) => { + if (playerId && sessionUserId) { + sessionSendCursorUpdate(playerId, sessionUserId, cursorPosition) } }, [sessionSendCursorUpdate] @@ -134,7 +143,14 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode // Action: Start Game const startGame = useCallback(() => { - const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined) + // Build ownership map from roomData to correctly map players to their owners + const ownershipMap = buildPlayerOwnershipFromRoomData(roomData) + const playerMetadata = buildPlayerMetadata( + activePlayers, + ownershipMap, + players, + viewerId || undefined + ) sendMove({ type: 'START_GAME', @@ -152,6 +168,7 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode activePlayers, players, viewerId, + roomData, sendMove, state.selectedMap, state.gameMode, @@ -385,6 +402,14 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode }) }, [viewerId, sendMove, state.currentPlayer, activePlayers]) + // Memoize memberPlayers from roomData for cursor emoji display + const memberPlayers = useMemo(() => { + return (roomData?.memberPlayers ?? {}) as Record< + string, + Array<{ id: string; name: string; emoji: string; color: string }> + > + }, [roomData?.memberPlayers]) + return ( {children} diff --git a/apps/web/src/arcade-games/know-your-world/Validator.test.ts b/apps/web/src/arcade-games/know-your-world/Validator.test.ts index 9b226cf2..fdc67a35 100644 --- a/apps/web/src/arcade-games/know-your-world/Validator.test.ts +++ b/apps/web/src/arcade-games/know-your-world/Validator.test.ts @@ -47,8 +47,10 @@ describe('KnowYourWorldValidator', () => { guessHistory: [], startTime: Date.now(), activePlayers: ['player-1'], + activeUserIds: ['user-1'], playerMetadata: { 'player-1': { name: 'Player 1' } }, giveUpReveal: null, + giveUpVotes: [], ...overrides, }) diff --git a/apps/web/src/arcade-games/know-your-world/Validator.ts b/apps/web/src/arcade-games/know-your-world/Validator.ts index e48e0c94..b090bf3c 100644 --- a/apps/web/src/arcade-games/know-your-world/Validator.ts +++ b/apps/web/src/arcade-games/know-your-world/Validator.ts @@ -26,9 +26,9 @@ export class KnowYourWorldValidator ): Promise { switch (move.type) { case 'START_GAME': - return await this.validateStartGame(state, move.data) + return await this.validateStartGame(state, move.userId, move.data) case 'CLICK_REGION': - return this.validateClickRegion(state, move.playerId, move.data) + return this.validateClickRegion(state, move.playerId, move.userId, move.data) case 'NEXT_ROUND': return await this.validateNextRound(state) case 'END_GAME': @@ -48,13 +48,17 @@ export class KnowYourWorldValidator case 'SET_CONTINENT': return this.validateSetContinent(state, move.data.selectedContinent) case 'GIVE_UP': - return await this.validateGiveUp(state, move.playerId) + return await this.validateGiveUp(state, move.playerId, move.userId) default: return { valid: false, error: 'Unknown move type' } } } - private async validateStartGame(state: KnowYourWorldState, data: any): Promise { + private async validateStartGame( + state: KnowYourWorldState, + userId: string, + data: any + ): Promise { if (state.gamePhase !== 'setup') { return { valid: false, error: 'Can only start from setup phase' } } @@ -81,10 +85,14 @@ export class KnowYourWorldValidator // Check if we should go to study phase or directly to playing const shouldStudy = state.studyDuration > 0 + // Track the initial userId (session) - other sessions will be added as they make moves + const activeUserIds = userId ? [userId] : [] + const newState: KnowYourWorldState = { ...state, gamePhase: shouldStudy ? 'studying' : 'playing', activePlayers, + activeUserIds, playerMetadata, selectedMap, gameMode, @@ -101,6 +109,7 @@ export class KnowYourWorldValidator guessHistory: [], startTime: Date.now(), giveUpReveal: null, + giveUpVotes: [], } return { valid: true, newState } @@ -109,6 +118,7 @@ export class KnowYourWorldValidator private validateClickRegion( state: KnowYourWorldState, playerId: string, + userId: string, data: any ): ValidationResult { if (state.gamePhase !== 'playing') { @@ -126,6 +136,9 @@ export class KnowYourWorldValidator return { valid: false, error: 'Not your turn' } } + // Track this session if not already known + const activeUserIds = this.addUserIdIfNew(state.activeUserIds, userId) + const isCorrect = regionId === state.currentPrompt const guessRecord: GuessRecord = { playerId, @@ -165,6 +178,8 @@ export class KnowYourWorldValidator guessHistory, endTime: Date.now(), giveUpReveal: null, + giveUpVotes: [], // Clear votes when game ends + activeUserIds, } return { valid: true, newState } } @@ -190,6 +205,8 @@ export class KnowYourWorldValidator scores: newScores, guessHistory, giveUpReveal: null, + giveUpVotes: [], // Clear votes when moving to next region + activeUserIds, } return { valid: true, newState } @@ -213,6 +230,7 @@ export class KnowYourWorldValidator attempts: newAttempts, guessHistory, currentPlayer: nextPlayer, + activeUserIds, } return { @@ -223,6 +241,15 @@ export class KnowYourWorldValidator } } + // Helper: Add userId to activeUserIds if not already present + private addUserIdIfNew(activeUserIds: string[] | undefined, userId: string): string[] { + const existing = activeUserIds ?? [] + if (!userId || existing.includes(userId)) { + return existing + } + return [...existing, userId] + } + private async validateNextRound(state: KnowYourWorldState): Promise { if (state.gamePhase !== 'results') { return { valid: false, error: 'Can only start next round from results' } @@ -264,6 +291,7 @@ export class KnowYourWorldValidator startTime: Date.now(), endTime: undefined, giveUpReveal: null, + giveUpVotes: [], } return { valid: true, newState } @@ -407,7 +435,8 @@ export class KnowYourWorldValidator private async validateGiveUp( state: KnowYourWorldState, - playerId: string + playerId: string, + userId: string ): Promise { if (state.gamePhase !== 'playing') { return { valid: false, error: 'Can only give up during playing phase' } @@ -422,6 +451,47 @@ export class KnowYourWorldValidator return { valid: false, error: 'Not your turn' } } + // Track this session if not already known + const activeUserIds = this.addUserIdIfNew(state.activeUserIds, userId) + + // For cooperative mode with multiple sessions: require unanimous vote by session + // (All local players on the same session count as one vote since they can discuss together) + const isCooperativeMultiplayer = state.gameMode === 'cooperative' && activeUserIds.length > 1 + + if (isCooperativeMultiplayer) { + // Check if this session has already voted + const existingVotes = state.giveUpVotes ?? [] + if (existingVotes.includes(userId)) { + return { valid: false, error: 'Your session has already voted to give up' } + } + + // Add this session's vote + const newVotes = [...existingVotes, userId] + + // Check if unanimous (all sessions have voted) + const isUnanimous = activeUserIds.every((uid) => newVotes.includes(uid)) + + if (!isUnanimous) { + // Not unanimous yet - just record the vote + const newState: KnowYourWorldState = { + ...state, + giveUpVotes: newVotes, + activeUserIds, + } + return { valid: true, newState } + } + + // Unanimous! Fall through to execute the give up + } + + // Execute the actual give up (single session, turn-based, or unanimous cooperative) + return this.executeGiveUp(state, activeUserIds) + } + + private async executeGiveUp( + state: KnowYourWorldState, + activeUserIds: string[] + ): Promise { // Get region info for the reveal const mapData = await getFilteredMapDataLazy( state.selectedMap, @@ -476,6 +546,8 @@ export class KnowYourWorldValidator regionName: region.name, timestamp: Date.now(), }, + giveUpVotes: [], // Clear votes after give up is executed + activeUserIds, } return { valid: true, newState } @@ -507,8 +579,10 @@ export class KnowYourWorldValidator guessHistory: [], startTime: 0, activePlayers: [], + activeUserIds: [], playerMetadata: {}, giveUpReveal: null, + giveUpVotes: [], } } diff --git a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx index f82b1018..e78e23a8 100644 --- a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx @@ -113,6 +113,7 @@ interface MapRendererProps { name: string emoji: string color: string + userId?: string // Session ID that owns this player } > // Give up reveal animation @@ -138,8 +139,14 @@ interface MapRendererProps { gameMode?: 'cooperative' | 'race' | 'turn-based' currentPlayer?: string // The player whose turn it is (for turn-based mode) localPlayerId?: string // The local player's ID (to filter out our own cursor from others) - otherPlayerCursors?: Record + otherPlayerCursors?: Record onCursorUpdate?: (cursorPosition: { x: number; y: number } | null) => void + // Unanimous give-up voting (for cooperative multiplayer) + giveUpVotes?: string[] // Session/viewer IDs (userIds) who have voted to give up + activeUserIds?: string[] // All unique session IDs participating (to show "1/2 sessions voted") + viewerId?: string // This viewer's userId (to check if local session has voted) + // Member players mapping (userId -> players) for cursor emoji display + memberPlayers?: Record> } /** @@ -200,6 +207,10 @@ export function MapRenderer({ localPlayerId, otherPlayerCursors = {}, onCursorUpdate, + giveUpVotes = [], + activeUserIds = [], + viewerId, + memberPlayers = {}, }: MapRendererProps) { // Extract force tuning parameters with defaults const { @@ -1210,7 +1221,15 @@ export function MapRenderer({ return () => { clearTimeout(timeoutId) } - }, [mapData, regionsFound, guessHistory, svgDimensions, excludedRegions, excludedRegionIds, displayViewBox]) + }, [ + mapData, + regionsFound, + guessHistory, + svgDimensions, + excludedRegions, + excludedRegionIds, + displayViewBox, + ]) // Calculate viewBox dimensions for label offset calculations and sea background const viewBoxParts = displayViewBox.split(' ').map(Number) @@ -1974,7 +1993,6 @@ export function MapRenderer({ opacity={0.8} /> )} - {/* HTML labels positioned absolutely over the SVG */} @@ -3208,6 +3226,14 @@ export function MapRenderer({ const player = playerMetadata[playerId] if (!player) return null + // In collaborative mode, find all players from the same session and show all their emojis + // Use memberPlayers (from roomData) which is the canonical source of player ownership + const cursorUserId = position.userId + const sessionPlayers = + gameMode === 'cooperative' && cursorUserId && memberPlayers[cursorUserId] + ? memberPlayers[cursorUserId] + : [player] + // Convert SVG coordinates to screen coordinates const svgRect = svgRef.current!.getBoundingClientRect() const containerRect = containerRef.current!.getBoundingClientRect() @@ -3308,7 +3334,8 @@ export function MapRenderer({ {/* Center dot */} - {/* Player emoji label - positioned below crosshair */} + {/* Player emoji label(s) - positioned below crosshair */} + {/* In collaborative mode, show all emojis from the same session */}
- {player.emoji} + {sessionPlayers.map((p) => p.emoji).join('')}
) @@ -3418,11 +3446,79 @@ export function MapRenderer({ }, })} > - Give Up (G) + {(() => { + // Determine button text based on game mode and voting state + // Voting is per-session (userId), not per-player + const isCooperativeMultiplayer = + gameMode === 'cooperative' && activeUserIds.length > 1 + const hasLocalSessionVoted = viewerId && giveUpVotes.includes(viewerId) + const voteCount = giveUpVotes.length + const totalSessions = activeUserIds.length + + if (isCooperativeMultiplayer) { + if (hasLocalSessionVoted) { + return ( + + ✓ Voted ({voteCount}/{totalSessions}) + + ) + } + if (voteCount > 0) { + return ( + + Give Up ({voteCount}/{totalSessions}) (G) + + ) + } + } + return 'Give Up (G)' + })()} ) })()} + {/* Show waiting message for give up voting (cooperative multiplayer with multiple sessions) */} + {gameMode === 'cooperative' && + activeUserIds.length > 1 && + giveUpVotes.length > 0 && + giveUpVotes.length < activeUserIds.length && + viewerId && + giveUpVotes.includes(viewerId) && + (() => { + if (!svgRef.current || !containerRef.current || svgDimensions.width === 0) return null + + const svgRect = svgRef.current.getBoundingClientRect() + const containerRect = containerRef.current.getBoundingClientRect() + const svgOffsetY = svgRect.top - containerRect.top + const buttonRight = + containerRect.width - (svgRect.left - containerRect.left + svgRect.width) + 8 + + const remaining = activeUserIds.length - giveUpVotes.length + + return ( +
+ Waiting for {remaining} other {remaining === 1 ? 'player' : 'players'}... +
+ ) + })()} + {/* Dev-only crop tool for getting custom viewBox coordinates */} { - sendCursorUpdate(localPlayerId, cursorPosition) + if (viewerId) { + sendCursorUpdate(localPlayerId, viewerId, cursorPosition) + } }, - [localPlayerId, sendCursorUpdate] + [localPlayerId, viewerId, sendCursorUpdate] ) const mapData = getFilteredMapDataSync( @@ -145,6 +148,10 @@ export function PlayingPhase() { localPlayerId={localPlayerId} otherPlayerCursors={otherPlayerCursors} onCursorUpdate={handleCursorUpdate} + giveUpVotes={state.giveUpVotes} + activeUserIds={state.activeUserIds} + viewerId={viewerId ?? undefined} + memberPlayers={memberPlayers} /> diff --git a/apps/web/src/arcade-games/know-your-world/customCrops.ts b/apps/web/src/arcade-games/know-your-world/customCrops.ts index 92052017..7b6f9d48 100644 --- a/apps/web/src/arcade-games/know-your-world/customCrops.ts +++ b/apps/web/src/arcade-games/know-your-world/customCrops.ts @@ -17,7 +17,7 @@ export const customCrops: CropOverrides = { europe: '399.10 106.44 200.47 263.75', africa: '472.47 346.18 95.84 227.49', oceania: '775.56 437.22 233.73 161.35', - } + }, } /** diff --git a/apps/web/src/arcade-games/know-your-world/types.ts b/apps/web/src/arcade-games/know-your-world/types.ts index 47c4c821..d7dc7967 100644 --- a/apps/web/src/arcade-games/know-your-world/types.ts +++ b/apps/web/src/arcade-games/know-your-world/types.ts @@ -72,6 +72,7 @@ export interface KnowYourWorldState extends GameState { // Multiplayer activePlayers: string[] + activeUserIds: string[] // Unique session/viewer IDs participating in the game playerMetadata: Record // Give up reveal state (for animation) @@ -80,6 +81,9 @@ export interface KnowYourWorldState extends GameState { regionName: string timestamp: number // For animation timing key } | null + + // Unanimous give-up voting (for cooperative multiplayer) + giveUpVotes: string[] // Session/viewer IDs (userIds) who have voted to give up on current prompt } // Move types diff --git a/apps/web/src/hooks/useArcadeSession.ts b/apps/web/src/hooks/useArcadeSession.ts index f94747a7..09a26589 100644 --- a/apps/web/src/hooks/useArcadeSession.ts +++ b/apps/web/src/hooks/useArcadeSession.ts @@ -81,17 +81,19 @@ export interface UseArcadeSessionReturn { /** * Other players' cursor positions (ephemeral, real-time) - * Map of playerId -> { x, y } in SVG coordinates, or null if cursor left + * Map of playerId -> { x, y, userId } in SVG coordinates, or null if cursor left */ - otherPlayerCursors: Record + otherPlayerCursors: Record /** * Send cursor position update to other players (ephemeral, real-time) * @param playerId - The player ID sending the cursor update + * @param userId - The session/viewer ID that owns this cursor * @param cursorPosition - SVG coordinates, or null when cursor leaves the map */ sendCursorUpdate: ( playerId: string, + userId: string, cursorPosition: { x: number; y: number } | null ) => void } @@ -169,7 +171,7 @@ export function useArcadeSession( // Track other players' cursor positions (ephemeral, real-time) const [otherPlayerCursors, setOtherPlayerCursors] = useState< - Record + Record >({}) // WebSocket connection @@ -273,7 +275,9 @@ export function useArcadeSession( onCursorUpdate: (data) => { setOtherPlayerCursors((prev) => ({ ...prev, - [data.playerId]: data.cursorPosition, + [data.playerId]: data.cursorPosition + ? { ...data.cursorPosition, userId: data.userId } + : null, })) }, }) @@ -321,9 +325,9 @@ export function useArcadeSession( // Send cursor position update to other players (ephemeral, real-time) const sendCursorUpdate = useCallback( - (playerId: string, cursorPosition: { x: number; y: number } | null) => { + (playerId: string, sessionUserId: string, cursorPosition: { x: number; y: number } | null) => { if (!roomId) return // Only works in room-based games - socketSendCursorUpdate(roomId, playerId, cursorPosition) + socketSendCursorUpdate(roomId, playerId, sessionUserId, cursorPosition) }, [roomId, socketSendCursorUpdate] ) diff --git a/apps/web/src/hooks/useArcadeSocket.ts b/apps/web/src/hooks/useArcadeSocket.ts index 94a098af..e3495e70 100644 --- a/apps/web/src/hooks/useArcadeSocket.ts +++ b/apps/web/src/hooks/useArcadeSocket.ts @@ -19,6 +19,7 @@ export interface ArcadeSocketEvents { /** Cursor position update from another player (ephemeral, real-time) */ onCursorUpdate?: (data: { playerId: string + userId: string // Session ID that owns this cursor cursorPosition: { x: number; y: number } | null }) => void /** If true, errors will NOT show toasts (for cases where game handles errors directly) */ @@ -36,6 +37,7 @@ export interface UseArcadeSocketReturn { sendCursorUpdate: ( roomId: string, playerId: string, + userId: string, cursorPosition: { x: number; y: number } | null ) => void } @@ -215,9 +217,14 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke ) const sendCursorUpdate = useCallback( - (roomId: string, playerId: string, cursorPosition: { x: number; y: number } | null) => { + ( + roomId: string, + playerId: string, + userId: string, + cursorPosition: { x: number; y: number } | null + ) => { if (!socket) return - socket.emit('cursor-update', { roomId, playerId, cursorPosition }) + socket.emit('cursor-update', { roomId, playerId, userId, cursorPosition }) }, [socket] ) diff --git a/apps/web/src/socket-server.ts b/apps/web/src/socket-server.ts index 7122e8e9..0f0cbae8 100644 --- a/apps/web/src/socket-server.ts +++ b/apps/web/src/socket-server.ts @@ -700,15 +700,18 @@ export function initializeSocketServer(httpServer: HTTPServer) { ({ roomId, playerId, + userId, cursorPosition, }: { roomId: string playerId: string + userId: string // Session ID that owns this cursor cursorPosition: { x: number; y: number } | null // SVG coordinates, null when cursor leaves }) => { // Broadcast to all other sockets in the game room (exclude sender) socket.to(`game:${roomId}`).emit('cursor-update', { playerId, + userId, cursorPosition, }) }