Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb56bc8b88 | ||
|
|
9f90678151 | ||
|
|
22f0be4d04 | ||
|
|
19bc0b65d9 | ||
|
|
d3ff89a0ee | ||
|
|
8473b6d670 | ||
|
|
53797dbb2d | ||
|
|
6b890b30f4 | ||
|
|
0596ef6587 | ||
|
|
debf786ed9 | ||
|
|
a2aada2e69 |
33
CHANGELOG.md
33
CHANGELOG.md
@@ -1,3 +1,36 @@
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct playerMetadata userId assignment for room-based multiplayer ([53797db](https://github.com/antialias/soroban-abacus-flashcards/commit/53797dbb2d5ccb80e61cbc186ca0a344fe1fbd96))
|
||||
|
||||
## [2.17.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.0...v2.17.1) (2025-10-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct hover avatar and turn indicator to show only current player ([0596ef6](https://github.com/antialias/soroban-abacus-flashcards/commit/0596ef65879a303f1f71863ef307af69bf270c70))
|
||||
|
||||
## [2.17.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.7...v2.17.0) (2025-10-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* hide hover avatar when card is flipped to reveal value ([a2aada2](https://github.com/antialias/soroban-abacus-flashcards/commit/a2aada2e6922fb3af363e0d191275e06b8f8f040))
|
||||
|
||||
## [2.16.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.16.6...v2.16.7) (2025-10-10)
|
||||
|
||||
|
||||
|
||||
176
apps/web/.claude/PLAYER_OWNERSHIP_CENTRALIZATION_PLAN.md
Normal file
176
apps/web/.claude/PLAYER_OWNERSHIP_CENTRALIZATION_PLAN.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Player Ownership Centralization Plan
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Player ownership logic is currently scattered across multiple locations in the codebase, leading to bugs and inconsistencies. The same pattern appears in:
|
||||
|
||||
1. **Server-side** (`session-manager.ts:232-239`): Builds `playerOwnership` map from database
|
||||
2. **Client-side** (`RoomMemoryPairsProvider.tsx:370-403`): Builds `playerOwnership` from `roomData.memberPlayers`
|
||||
3. **UI Components** (`PlayerStatusBar.tsx:31`, `MemoryGrid.tsx:388`): Check `player.userId === viewerId`
|
||||
4. **Validation** (`MatchingGameValidator.ts:88-102`): Validates player ownership
|
||||
|
||||
## Current Implementations
|
||||
|
||||
### Server-Side (session-manager.ts)
|
||||
```typescript
|
||||
// Lines 232-238
|
||||
const players = await db.query.players.findMany({
|
||||
columns: { id: true, userId: true }
|
||||
})
|
||||
playerOwnership = Object.fromEntries(players.map(p => [p.id, p.userId]))
|
||||
```
|
||||
|
||||
### Client-Side (RoomMemoryPairsProvider.tsx)
|
||||
```typescript
|
||||
// Lines 370-403
|
||||
const buildPlayerMetadata = useCallback((playerIds: string[]) => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
// ... use playerOwnership to assign correct userId
|
||||
}, [players, roomData, viewerId])
|
||||
```
|
||||
|
||||
## Centralization Strategy
|
||||
|
||||
### Phase 1: Create Shared Utilities Module
|
||||
**File**: `src/lib/arcade/player-ownership.ts`
|
||||
|
||||
Create a module with:
|
||||
1. **Server-side utility**: `buildPlayerOwnershipMap(roomId?: string)`
|
||||
- Fetches from database
|
||||
- Returns `Record<playerId, userId>`
|
||||
- Used by `session-manager.ts`
|
||||
|
||||
2. **Client-side utility**: `buildPlayerOwnershipFromRoomData(roomData)`
|
||||
- Builds from `RoomData.memberPlayers`
|
||||
- Returns `Map<playerId, userId>` or `Record<playerId, userId>`
|
||||
- Used by React components
|
||||
|
||||
3. **Shared types**: `PlayerOwnershipMap = Record<string, string>`
|
||||
|
||||
4. **Helper functions**:
|
||||
- `isPlayerOwnedByUser(playerId, userId, ownershipMap)`
|
||||
- `getPlayerOwner(playerId, ownershipMap)`
|
||||
- `buildPlayerMetadata(playerIds, ownershipMap, playersMap)` - combines ownership + player data
|
||||
|
||||
### Phase 2: Update Server-Side Code
|
||||
**Files**:
|
||||
- `src/lib/arcade/session-manager.ts`
|
||||
- `src/lib/arcade/player-manager.ts`
|
||||
|
||||
Changes:
|
||||
1. Import utilities from `player-ownership.ts`
|
||||
2. Replace inline ownership building with `buildPlayerOwnershipMap()`
|
||||
3. Add new function to `player-manager.ts`: `getPlayerOwnershipMap(roomId)`
|
||||
|
||||
### Phase 3: Update Client-Side Providers
|
||||
**Files**:
|
||||
- `src/app/arcade/matching/context/RoomMemoryPairsProvider.tsx`
|
||||
- Any other game providers that need this logic
|
||||
|
||||
Changes:
|
||||
1. Import utilities from `player-ownership.ts`
|
||||
2. Replace `buildPlayerMetadata` with centralized version
|
||||
3. Use shared helper functions for ownership checks
|
||||
|
||||
### Phase 4: Update UI Components
|
||||
**Files**:
|
||||
- `src/app/arcade/matching/components/PlayerStatusBar.tsx`
|
||||
- `src/app/arcade/matching/components/MemoryGrid.tsx`
|
||||
- `src/components/PageWithNav.tsx`
|
||||
- `src/contexts/GameModeContext.tsx`
|
||||
|
||||
Changes:
|
||||
1. Use centralized `isPlayerOwnedByUser()` helper
|
||||
2. Consistent API across all components
|
||||
|
||||
### Phase 5: Add to API Endpoints (if needed)
|
||||
**Files**:
|
||||
- `src/app/api/arcade/rooms/[roomId]/route.ts`
|
||||
- Any endpoints that return player data
|
||||
|
||||
Changes:
|
||||
1. Include `playerOwnership` map in API responses where practical
|
||||
2. Document in API response types
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Single Source of Truth**: All ownership logic in one place
|
||||
2. **Consistency**: Same algorithm server-side and client-side
|
||||
3. **Testability**: Can unit test ownership logic in isolation
|
||||
4. **Type Safety**: Shared types across client/server boundary
|
||||
5. **Maintainability**: Bug fixes only need to be made once
|
||||
6. **Documentation**: Central location for ownership algorithm docs
|
||||
|
||||
## Implementation Order (One Commit Per Phase)
|
||||
|
||||
1. ✅ **Commit 1**: Create `src/lib/arcade/player-ownership.ts` with all utilities and tests
|
||||
2. ✅ **Commit 2**: Update `session-manager.ts` to use new utilities
|
||||
3. ✅ **Commit 3**: Update `player-manager.ts` to export ownership helper
|
||||
4. ✅ **Commit 4**: Update `RoomMemoryPairsProvider.tsx` to use utilities
|
||||
5. ✅ **Commit 5**: Update UI components to use helper functions
|
||||
6. ✅ **Commit 6**: Update API endpoints to include ownership data (if needed)
|
||||
7. ✅ **Commit 7**: Add comprehensive integration tests
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### Server vs Client Implementations
|
||||
|
||||
**Why separate implementations?**
|
||||
- Server uses database queries (async)
|
||||
- Client uses in-memory `RoomData` (sync)
|
||||
- Different data sources, same logic
|
||||
|
||||
**Shared interface:**
|
||||
```typescript
|
||||
type PlayerOwnershipMap = Record<string, string> // playerId -> userId
|
||||
|
||||
// Server-side (async)
|
||||
async function buildPlayerOwnershipMap(roomId: string): Promise<PlayerOwnershipMap>
|
||||
|
||||
// Client-side (sync)
|
||||
function buildPlayerOwnershipFromRoomData(
|
||||
roomData: RoomData
|
||||
): PlayerOwnershipMap
|
||||
```
|
||||
|
||||
### Type Consistency
|
||||
|
||||
Both return the same structure:
|
||||
```typescript
|
||||
{
|
||||
"player-id-1": "user-id-1",
|
||||
"player-id-2": "user-id-1",
|
||||
"player-id-3": "user-id-2"
|
||||
}
|
||||
```
|
||||
|
||||
This allows validators, helpers, and checks to work identically regardless of source.
|
||||
|
||||
## Migration Path
|
||||
|
||||
1. Create new module alongside existing code
|
||||
2. Add tests for new utilities
|
||||
3. Gradually migrate files one at a time
|
||||
4. Remove old implementations after migration complete
|
||||
5. Deprecate old patterns in documentation
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit tests** for utilities in isolation
|
||||
2. **Integration tests** for server-side flow
|
||||
3. **Component tests** for client-side usage
|
||||
4. **E2E tests** for full multiplayer scenarios
|
||||
|
||||
## Documentation
|
||||
|
||||
Add to existing docs:
|
||||
- Update `ARCADE_ARCHITECTURE.md` with new utilities section
|
||||
- Update `MULTIPLAYER_SYNC_ARCHITECTURE.md` with ownership flow
|
||||
- Add JSDoc comments to all exported functions
|
||||
@@ -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": []
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useSpring, animated } from '@react-spring/web'
|
||||
import { animated, useSpring } from '@react-spring/web'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { getGridConfiguration } from '../utils/cardGeneration'
|
||||
import { GameCard } from './GameCard'
|
||||
@@ -88,11 +88,13 @@ function HoverAvatar({
|
||||
playerInfo,
|
||||
cardElement,
|
||||
isPlayersTurn,
|
||||
isCardFlipped,
|
||||
}: {
|
||||
playerId: string
|
||||
playerInfo: { emoji: string; name: string; color?: string }
|
||||
cardElement: HTMLElement | null
|
||||
isPlayersTurn: boolean
|
||||
isCardFlipped: boolean
|
||||
}) {
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
const isFirstRender = useRef(true)
|
||||
@@ -116,7 +118,8 @@ function HoverAvatar({
|
||||
const springProps = useSpring({
|
||||
x: position?.x ?? 0,
|
||||
y: position?.y ?? 0,
|
||||
opacity: position && isPlayersTurn && cardElement ? 1 : 0,
|
||||
// Hide avatar if: no position, not player's turn, no card element, OR card is flipped
|
||||
opacity: position && isPlayersTurn && cardElement && !isCardFlipped ? 1 : 0,
|
||||
config: {
|
||||
tension: 280,
|
||||
friction: 60,
|
||||
@@ -376,15 +379,13 @@ export function MemoryGrid() {
|
||||
)}
|
||||
|
||||
{/* Animated Hover Avatars - Rendered as fixed positioned elements that smoothly transition */}
|
||||
{/* Render one avatar per remote player - key by playerId to keep component alive */}
|
||||
{/* Render one avatar per player - key by playerId to keep component alive */}
|
||||
{state.playerHovers &&
|
||||
Object.entries(state.playerHovers)
|
||||
.filter(([playerId]) => {
|
||||
// Don't show your own hover avatar (only show remote players)
|
||||
// In local games, all players belong to this user
|
||||
// In room games, check if player belongs to different user
|
||||
const player = state.playerMetadata?.[playerId]
|
||||
return player?.userId !== viewerId
|
||||
// Only show avatar for the CURRENT player whose turn it is
|
||||
// Don't show for other players (they're waiting for their turn)
|
||||
return playerId === state.currentPlayer
|
||||
})
|
||||
.map(([playerId, cardId]) => {
|
||||
const playerInfo = getPlayerHoverInfo(playerId)
|
||||
@@ -392,6 +393,11 @@ export function MemoryGrid() {
|
||||
const cardElement = cardId ? cardRefs.current.get(cardId) : null
|
||||
// Check if it's this player's turn
|
||||
const isPlayersTurn = state.currentPlayer === playerId
|
||||
// Check if the card being hovered is flipped
|
||||
const hoveredCard = cardId ? state.gameCards.find((c) => c.id === cardId) : null
|
||||
const isCardFlipped = hoveredCard
|
||||
? state.flippedCards.some((c) => c.id === hoveredCard.id) || hoveredCard.matched
|
||||
: false
|
||||
|
||||
if (!playerInfo) return null
|
||||
|
||||
@@ -403,6 +409,7 @@ export function MemoryGrid() {
|
||||
playerInfo={playerInfo}
|
||||
cardElement={cardElement}
|
||||
isPlayersTurn={isPlayersTurn}
|
||||
isCardFlipped={isCardFlipped}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { gamePlurals } from '../../../../utils/pluralization'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
@@ -10,6 +11,7 @@ interface PlayerStatusBarProps {
|
||||
|
||||
export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
|
||||
const { state } = useMemoryPairs()
|
||||
const { data: viewerId } = useViewerId()
|
||||
|
||||
// Get active players from game state (not GameModeContext)
|
||||
// This ensures we only show players actually in this game
|
||||
@@ -25,8 +27,8 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
|
||||
displayEmoji: player.emoji,
|
||||
score: state.scores[player.id] || 0,
|
||||
consecutiveMatches: state.consecutiveMatches?.[player.id] || 0,
|
||||
// In local games all players are local, in room games check metadata
|
||||
isLocalPlayer: state.gameMode === 'single' || state.gameMode === 'multiplayer',
|
||||
// Check if this player belongs to the current viewer
|
||||
isLocalPlayer: player.userId === viewerId,
|
||||
}))
|
||||
|
||||
// Check if current player is local (your turn) or remote (waiting)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -365,6 +365,43 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
|
||||
}, [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(
|
||||
(playerIds: string[]) => {
|
||||
const playerMetadata: { [playerId: string]: any } = {}
|
||||
|
||||
// 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
|
||||
},
|
||||
[players, roomData, viewerId]
|
||||
)
|
||||
|
||||
// Action creators - send moves to arcade session
|
||||
const startGame = useCallback(() => {
|
||||
// Must have at least one active player
|
||||
@@ -375,19 +412,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: { [playerId: string]: any } = {}
|
||||
for (const playerId of activePlayers) {
|
||||
const playerData = players.get(playerId)
|
||||
if (playerData) {
|
||||
playerMetadata[playerId] = {
|
||||
id: playerId,
|
||||
name: playerData.name,
|
||||
emoji: playerData.emoji,
|
||||
userId: viewerId || '',
|
||||
color: playerData.color,
|
||||
}
|
||||
}
|
||||
}
|
||||
const playerMetadata = buildPlayerMetadata(activePlayers)
|
||||
|
||||
// Use current session state configuration (no local state!)
|
||||
const cards = generateGameCards(state.gameType, state.difficulty)
|
||||
@@ -402,7 +427,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
playerMetadata,
|
||||
},
|
||||
})
|
||||
}, [state.gameType, state.difficulty, activePlayers, players, viewerId, sendMove])
|
||||
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove])
|
||||
|
||||
const flipCard = useCallback(
|
||||
(cardId: string) => {
|
||||
@@ -438,20 +463,8 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
return
|
||||
}
|
||||
|
||||
// Capture player metadata from local players map
|
||||
const playerMetadata: { [playerId: string]: any } = {}
|
||||
for (const playerId of activePlayers) {
|
||||
const playerData = players.get(playerId)
|
||||
if (playerData) {
|
||||
playerMetadata[playerId] = {
|
||||
id: playerId,
|
||||
name: playerData.name,
|
||||
emoji: playerData.emoji,
|
||||
userId: viewerId || '',
|
||||
color: playerData.color,
|
||||
}
|
||||
}
|
||||
}
|
||||
// Capture player metadata with correct userId ownership
|
||||
const playerMetadata = buildPlayerMetadata(activePlayers)
|
||||
|
||||
// Use current session state configuration (no local state!)
|
||||
const cards = generateGameCards(state.gameType, state.difficulty)
|
||||
@@ -466,7 +479,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
playerMetadata,
|
||||
},
|
||||
})
|
||||
}, [state.gameType, state.difficulty, activePlayers, players, viewerId, sendMove])
|
||||
}, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove])
|
||||
|
||||
const setGameType = useCallback(
|
||||
(gameType: typeof state.gameType) => {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Unit test for player ownership bug in RoomMemoryPairsProvider
|
||||
*
|
||||
* Bug: playerMetadata[playerId].userId is set to the LOCAL viewerId for ALL players,
|
||||
* including remote players from other room members. This causes "Your turn" to show
|
||||
* even when it's a remote player's turn.
|
||||
*
|
||||
* Fix: Use player.isLocal from GameModeContext to determine correct userId ownership.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('Player Metadata userId Assignment', () => {
|
||||
it('should assign local userId to local players only', () => {
|
||||
const viewerId = 'local-user-id'
|
||||
const players = new Map([
|
||||
[
|
||||
'local-player-1',
|
||||
{
|
||||
id: 'local-player-1',
|
||||
name: 'Local Player',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
isLocal: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'remote-player-1',
|
||||
{
|
||||
id: 'remote-player-1',
|
||||
name: 'Remote Player',
|
||||
emoji: '🤠',
|
||||
color: '#10b981',
|
||||
isLocal: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const activePlayers = ['local-player-1', 'remote-player-1']
|
||||
|
||||
// CURRENT BUGGY IMPLEMENTATION (from RoomMemoryPairsProvider.tsx:378-390)
|
||||
const buggyPlayerMetadata: Record<string, any> = {}
|
||||
for (const playerId of activePlayers) {
|
||||
const playerData = players.get(playerId)
|
||||
if (playerData) {
|
||||
buggyPlayerMetadata[playerId] = {
|
||||
id: playerId,
|
||||
name: playerData.name,
|
||||
emoji: playerData.emoji,
|
||||
userId: viewerId, // BUG: Always uses local viewerId!
|
||||
color: playerData.color,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BUG MANIFESTATION: Both players have local userId
|
||||
expect(buggyPlayerMetadata['local-player-1'].userId).toBe('local-user-id')
|
||||
expect(buggyPlayerMetadata['remote-player-1'].userId).toBe('local-user-id') // WRONG!
|
||||
|
||||
// CORRECT IMPLEMENTATION
|
||||
const correctPlayerMetadata: Record<string, any> = {}
|
||||
for (const playerId of activePlayers) {
|
||||
const playerData = players.get(playerId)
|
||||
if (playerData) {
|
||||
correctPlayerMetadata[playerId] = {
|
||||
id: playerId,
|
||||
name: playerData.name,
|
||||
emoji: playerData.emoji,
|
||||
// FIX: Only use local viewerId for local players
|
||||
// For remote players, we don't know their userId from this context,
|
||||
// but we can mark them as NOT belonging to local user
|
||||
userId: playerData.isLocal ? viewerId : `remote-user-${playerId}`,
|
||||
color: playerData.color,
|
||||
isLocal: playerData.isLocal, // Also include isLocal for clarity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT BEHAVIOR: Each player has correct userId
|
||||
expect(correctPlayerMetadata['local-player-1'].userId).toBe('local-user-id')
|
||||
expect(correctPlayerMetadata['remote-player-1'].userId).not.toBe('local-user-id')
|
||||
})
|
||||
|
||||
it('reproduces "Your turn" bug when checking current player', () => {
|
||||
const viewerId = 'local-user-id'
|
||||
const currentPlayer = 'remote-player-1' // Remote player's turn
|
||||
|
||||
// Buggy playerMetadata (all players have local userId)
|
||||
const buggyPlayerMetadata = {
|
||||
'local-player-1': {
|
||||
id: 'local-player-1',
|
||||
userId: 'local-user-id',
|
||||
},
|
||||
'remote-player-1': {
|
||||
id: 'remote-player-1',
|
||||
userId: 'local-user-id', // BUG!
|
||||
},
|
||||
}
|
||||
|
||||
// PlayerStatusBar logic (line 31 in PlayerStatusBar.tsx)
|
||||
const buggyIsLocalPlayer = buggyPlayerMetadata[currentPlayer]?.userId === viewerId
|
||||
|
||||
// BUG: Shows "Your turn" even though it's remote player's turn!
|
||||
expect(buggyIsLocalPlayer).toBe(true) // WRONG!
|
||||
expect(buggyIsLocalPlayer ? 'Your turn' : 'Their turn').toBe('Your turn') // WRONG!
|
||||
|
||||
// Correct playerMetadata (each player has correct userId)
|
||||
const correctPlayerMetadata = {
|
||||
'local-player-1': {
|
||||
id: 'local-player-1',
|
||||
userId: 'local-user-id',
|
||||
},
|
||||
'remote-player-1': {
|
||||
id: 'remote-player-1',
|
||||
userId: 'remote-user-id', // CORRECT!
|
||||
},
|
||||
}
|
||||
|
||||
// PlayerStatusBar logic with correct data
|
||||
const correctIsLocalPlayer = correctPlayerMetadata[currentPlayer]?.userId === viewerId
|
||||
|
||||
// CORRECT: Shows "Their turn" because it's remote player's turn
|
||||
expect(correctIsLocalPlayer).toBe(false) // CORRECT!
|
||||
expect(correctIsLocalPlayer ? 'Your turn' : 'Their turn').toBe('Their turn') // CORRECT!
|
||||
})
|
||||
|
||||
it('reproduces hover avatar bug when filtering by current player', () => {
|
||||
const viewerId = 'local-user-id'
|
||||
const currentPlayer = 'remote-player-1' // Remote player's turn
|
||||
|
||||
// Buggy playerMetadata
|
||||
const buggyPlayerMetadata = {
|
||||
'remote-player-1': {
|
||||
id: 'remote-player-1',
|
||||
userId: 'local-user-id', // BUG!
|
||||
},
|
||||
}
|
||||
|
||||
// OLD WRONG logic from MemoryGrid.tsx (showed remote players)
|
||||
const oldWrongFilter = buggyPlayerMetadata[currentPlayer]?.userId !== viewerId
|
||||
expect(oldWrongFilter).toBe(false) // Would hide avatar incorrectly
|
||||
|
||||
// CURRENT logic in MemoryGrid.tsx (shows only current player)
|
||||
// This is actually correct - show avatar for whoever's turn it is
|
||||
const currentLogic = currentPlayer === 'remote-player-1'
|
||||
expect(currentLogic).toBe(true) // Shows avatar for current player
|
||||
|
||||
// The REAL issue is in PlayerStatusBar showing "Your turn"
|
||||
// when it should show "Their turn"
|
||||
})
|
||||
})
|
||||
2
apps/web/src/types/build-info.d.ts
vendored
2
apps/web/src/types/build-info.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
declare module '@/generated/build-info.json' {
|
||||
declare module '../generated/build-info.json' {
|
||||
interface BuildInfo {
|
||||
version: string
|
||||
buildTime: string
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "2.16.7",
|
||||
"version": "2.17.3",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user