diff --git a/apps/front/src/components/App.tsx b/apps/front/src/components/App.tsx index c87cbc11..44435c8b 100644 --- a/apps/front/src/components/App.tsx +++ b/apps/front/src/components/App.tsx @@ -1,8 +1,9 @@ import * as React from 'react' -import { BrowserRouter } from 'react-router-dom' -import { getPathLanguage } from '../translations' +import { useTranslation } from 'react-i18next' +import { BrowserRouter, useLocation } from 'react-router-dom' +import { DEFAULT_LANGUAGE, getPathLanguage } from '../translations' +import { ensurePlayerIdentity } from '../utils/playerIdentity' import { Language } from './Language' -import { PlayerIdentityGate } from './PlayerIdentityGate' import { PlayerIdentityTransfer } from './PlayerIdentityTransfer' import { Router } from './Router' import { @@ -13,11 +14,31 @@ import { } from './SideBar' import { Theme } from './Theme' +function LanguageSync() { + const { pathname } = useLocation() + const { i18n } = useTranslation() + + React.useEffect(() => { + const language = getPathLanguage(pathname) ?? DEFAULT_LANGUAGE + document.documentElement.lang = language + void i18n.changeLanguage(language) + }, [i18n, pathname]) + + return null +} + export function App() { const mainContentRef = React.useRef>(null) + React.useEffect(() => { + void ensurePlayerIdentity().catch((error) => { + console.error('Failed to initialize player identity.', error) + }) + }, []) + return ( - + +
- - <> - - - - - - + + + +
diff --git a/apps/front/src/components/Button.tsx b/apps/front/src/components/Button.tsx index 084d9b10..8328f1ab 100644 --- a/apps/front/src/components/Button.tsx +++ b/apps/front/src/components/Button.tsx @@ -30,7 +30,7 @@ export function Button({ diff --git a/apps/front/src/components/Game.tsx b/apps/front/src/components/Game.tsx index 9fc7de1d..4c0d4421 100644 --- a/apps/front/src/components/Game.tsx +++ b/apps/front/src/components/Game.tsx @@ -1,6 +1,8 @@ import * as React from 'react' +import { useTranslation } from 'react-i18next' import { useIsOnMobile } from '../hooks/detectDevice' import { useNoIndex } from '../hooks/useNoIndex' +import { Button } from './Button' import { useGameWhileLoading } from './GameContext' import { GameOutcome } from './GameOutcome' import { HowToPlayModal } from './HowToPlay' @@ -8,12 +10,15 @@ import { Loading } from './Loading' import { OutcomeHistory } from './OutcomeHistory' import { PlayerOneBoard, PlayerTwoBoard } from './PlayerBoard' import { QRCodeModal } from './QRCode' +import { RankedMatchInfo } from './RankedMatchInfo' import { ReconnectNotice } from './ReconnectNotice' +import { ResignGame } from './ResignGame' import { SideBarActions } from './SideBar' import { WarningToast } from './WarningToast' export function Game() { const gameStore = useGameWhileLoading() + const { t } = useTranslation() const isOnMobile = useIsOnMobile() const gameRef = React.useRef>(null) useNoIndex() @@ -22,6 +27,17 @@ export function Game() { return } + if (gameStore.status === 'identity-error') { + return ( +
+

{gameStore.errorMessage}

+ +
+ ) + } + const { errorMessage, clearErrorMessage } = gameStore // Pas tip top je trouve, mais virtuellement ça marche @@ -33,6 +49,8 @@ export function Game() { + + {isOnMobile && gameOutcome}
diff --git a/apps/front/src/components/GameContext/GameContext.tsx b/apps/front/src/components/GameContext/GameContext.tsx index b16eb47f..a471abe9 100644 --- a/apps/front/src/components/GameContext/GameContext.tsx +++ b/apps/front/src/components/GameContext/GameContext.tsx @@ -25,9 +25,9 @@ export function useGameWhileLoading() { export function useGame() { const context = useGameWhileLoading() - if (context === null) { + if (context === null || context.status === 'identity-error') { throw new Error( - '`GameContext` should not be used while the game is still loading' + '`GameContext` should not be used while the game is loading or its identity is unresolved' ) } diff --git a/apps/front/src/components/GameContext/useGameSetup.test.tsx b/apps/front/src/components/GameContext/useGameSetup.test.tsx index 04e66e57..5ce51a30 100644 --- a/apps/front/src/components/GameContext/useGameSetup.test.tsx +++ b/apps/front/src/components/GameContext/useGameSetup.test.tsx @@ -7,30 +7,73 @@ import { toGameStateMessage, toGamePresenceMessage, toGameReconnectDeadlineMessage, - type IGameState + type IGameState, + type PlayerCredentials } from '@knucklebones/common' import { act, renderHook, waitFor } from '@testing-library/react' import { + ApiRequestError, createWebSocketTicket, initGame, play, reportClientProtocolDiagnostic } from '../../utils/api' +import { ensurePlayerIdentity } from '../../utils/playerIdentity' +import type { GameSetup } from './GameContext' import { useGameSetup } from './useGameSetup' +function readyState( + current: GameSetup +): Exclude | undefined { + if (current === null || current.status === 'identity-error') { + return undefined + } + return current +} + +interface CapturedWebSocketOptions { + shouldReconnect?(): boolean + retryOnError?: boolean + reconnectInterval?(attempt: number): number +} + const socket = vi.hoisted(() => ({ lastJsonMessage: null as unknown, readyState: 0 })) +const navigate = vi.hoisted(() => vi.fn()) +const useWebSocketCalls = vi.hoisted( + () => [] as Array<[unknown, CapturedWebSocketOptions | undefined]> +) +const useWebSocketMock = vi.hoisted(() => + vi.fn((url: unknown, options?: CapturedWebSocketOptions) => { + useWebSocketCalls.push([url, options]) + return socket + }) +) vi.mock('react-use-websocket', () => ({ - default: () => socket, + default: useWebSocketMock, ReadyState: { CLOSED: 3, OPEN: 1 } })) +vi.mock('react-router-dom', async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => navigate +})) vi.mock('../../hooks/useRoomKey', () => ({ useRoomKey: () => '11111111-1111-4111-8111-111111111111' })) vi.mock('../../utils/api', () => ({ + ApiRequestError: class extends Error { + status: number + code?: string + + constructor(status: number, message: string, code?: string) { + super(message) + this.status = status + this.code = code + } + }, createWebSocketTicket: vi.fn(), deleteDisplayName: vi.fn(), initGame: vi.fn(), @@ -39,6 +82,10 @@ vi.mock('../../utils/api', () => ({ updateDisplayName: vi.fn(), voteRematch: vi.fn() })) +vi.mock('../../utils/playerIdentity', async (importOriginal) => ({ + ...(await importOriginal()), + ensurePlayerIdentity: vi.fn() +})) const playerId = '22222222-2222-4222-8222-222222222222' const roomKey = '11111111-1111-4111-8111-111111111111' @@ -76,6 +123,8 @@ describe('useGameSetup', () => { beforeEach(() => { socket.lastJsonMessage = null socket.readyState = 0 + navigate.mockReset() + useWebSocketMock.mockClear() localStorage.setItem('playerId', playerId) vi.mocked(createWebSocketTicket).mockReset() vi.mocked(initGame).mockReset().mockResolvedValue(undefined) @@ -83,6 +132,7 @@ describe('useGameSetup', () => { vi.mocked(reportClientProtocolDiagnostic) .mockReset() .mockResolvedValue(undefined) + vi.mocked(ensurePlayerIdentity).mockReset() }) it('ignores stale, foreign-room, and malformed state messages', async () => { @@ -90,23 +140,25 @@ describe('useGameSetup', () => { const { rerender, result } = renderHook(() => useGameSetup(), { wrapper }) emitMessage(toGameStateMessage(createGameState(3), roomKey), rerender) - await waitFor(() => expect(result.current?.revision).toBe(3)) + await waitFor(() => expect(readyState(result.current)?.revision).toBe(3)) emitMessage( toGameStateMessage(createGameState(2, 'Stale Name'), roomKey), rerender ) - expect(result.current?.revision).toBe(3) - expect(result.current?.playerOne.displayName).toBe('Current Name') + expect(readyState(result.current)?.revision).toBe(3) + expect(readyState(result.current)?.playerOne.displayName).toBe( + 'Current Name' + ) emitMessage( toGameStateMessage(createGameState(4, 'Foreign Name'), foreignRoomKey), rerender ) - expect(result.current?.revision).toBe(3) + expect(readyState(result.current)?.revision).toBe(3) emitMessage({ type: 'game.state', version: 1 }, rerender) - expect(result.current?.revision).toBe(3) + expect(readyState(result.current)?.revision).toBe(3) expect(consoleError).toHaveBeenCalledWith( 'Ignored an invalid game-state message.' ) @@ -121,7 +173,7 @@ describe('useGameSetup', () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) const { rerender, result } = renderHook(() => useGameSetup(), { wrapper }) emitMessage(toGameStateMessage(createGameState(3), roomKey), rerender) - await waitFor(() => expect(result.current?.revision).toBe(3)) + await waitFor(() => expect(readyState(result.current)?.revision).toBe(3)) emitMessage( { @@ -131,8 +183,10 @@ describe('useGameSetup', () => { rerender ) - expect(result.current?.revision).toBe(3) - expect(result.current?.errorMessage).toBe('errors.unsupported-protocol') + expect(readyState(result.current)?.revision).toBe(3) + expect(readyState(result.current)?.errorMessage).toBe( + 'errors.unsupported-protocol' + ) expect(consoleError).toHaveBeenCalledWith( 'Ignored a message using an unsupported protocol version.' ) @@ -143,6 +197,57 @@ describe('useGameSetup', () => { ) }) + it('keeps ranked timeout counts attached to players after changing perspective', async () => { + const serverPlayerOne = new Player('player-one', 'Player One') + const serverPlayerTwo = new Player(playerId, 'Player Two', undefined, 4) + const gameState = new GameState({ + revision: 1, + playerOne: serverPlayerOne, + playerTwo: serverPlayerTwo, + nextPlayer: serverPlayerTwo, + outcome: 'ongoing', + boType: 1, + rankedTurn: { + expiresAt: Date.now() + 30_000, + playerOneTimeouts: 0, + playerTwoTimeouts: 2 + } + }).toJson() + const { rerender, result } = renderHook(() => useGameSetup(), { wrapper }) + + emitMessage(toGameStateMessage(gameState, roomKey), rerender) + + await waitFor(() => expect(readyState(result.current)?.revision).toBe(1)) + expect(readyState(result.current)?.playerOne.id).toBe(playerId) + expect(readyState(result.current)?.rankedTurn).toMatchObject({ + playerOneTimeouts: 2, + playerTwoTimeouts: 0 + }) + }) + + it('returns a player home after their third ranked timeout', async () => { + const timedOutPlayer = new Player(playerId, 'Timed Out Player') + const winner = new Player('winner', 'Winner') + const gameState = new GameState({ + revision: 1, + playerOne: timedOutPlayer, + playerTwo: winner, + nextPlayer: timedOutPlayer, + outcome: 'game-ended', + finishReason: 'forfeit', + forfeitReason: 'timeout', + winnerId: winner.id, + boType: 1 + }).toJson() + const { rerender } = renderHook(() => useGameSetup(), { wrapper }) + + emitMessage(toGameStateMessage(gameState, roomKey), rerender) + + await waitFor(() => + expect(navigate).toHaveBeenCalledWith('/en/', { replace: true }) + ) + }) + it('reinitializes the room whenever the socket reconnects', async () => { const { rerender } = renderHook(() => useGameSetup(), { wrapper }) expect(initGame).not.toHaveBeenCalled() @@ -164,42 +269,75 @@ describe('useGameSetup', () => { await waitFor(() => expect(initGame).toHaveBeenCalledTimes(2)) }) + it('configures the websocket to reconnect automatically', () => { + renderHook(() => useGameSetup(), { wrapper }) + + const options = useWebSocketMock.mock.calls[0]?.[1] + expect(options?.shouldReconnect?.()).toBe(true) + expect(options?.retryOnError).toBe(true) + expect(options?.reconnectInterval).toBeTypeOf('function') + }) + + it('returns to matchmaking when the ranked assignment has expired', async () => { + vi.mocked(initGame).mockRejectedValueOnce( + new ApiRequestError( + 409, + 'The ranked match assignment has expired.', + 'RANKED_ASSIGNMENT_EXPIRED' + ) + ) + const { rerender } = renderHook(() => useGameSetup(), { wrapper }) + + act(() => { + socket.readyState = 1 + rerender() + }) + + await waitFor(() => + expect(navigate).toHaveBeenCalledWith('/en/ranked', { replace: true }) + ) + }) + it('tracks valid room presence without replacing game state', async () => { const { rerender, result } = renderHook(() => useGameSetup(), { wrapper }) emitMessage(toGameStateMessage(createGameState(1), roomKey), rerender) - await waitFor(() => expect(result.current?.revision).toBe(1)) + await waitFor(() => expect(readyState(result.current)?.revision).toBe(1)) emitMessage(toGamePresenceMessage(roomKey, playerId, true), rerender) await waitFor(() => - expect(result.current?.presenceByPlayerId[playerId]).toBe(true) + expect(readyState(result.current)?.presenceByPlayerId[playerId]).toBe( + true + ) ) - expect(result.current?.revision).toBe(1) + expect(readyState(result.current)?.revision).toBe(1) emitMessage(toGamePresenceMessage(roomKey, playerId, false), rerender) await waitFor(() => - expect(result.current?.presenceByPlayerId[playerId]).toBe(false) + expect(readyState(result.current)?.presenceByPlayerId[playerId]).toBe( + false + ) ) }) it('tracks and clears reconnect deadlines', async () => { const { rerender, result } = renderHook(() => useGameSetup(), { wrapper }) emitMessage(toGameStateMessage(createGameState(1), roomKey), rerender) - await waitFor(() => expect(result.current?.revision).toBe(1)) + await waitFor(() => expect(readyState(result.current)?.revision).toBe(1)) emitMessage( toGameReconnectDeadlineMessage(roomKey, playerId, 123_456), rerender ) await waitFor(() => - expect(result.current?.reconnectDeadlineByPlayerId[playerId]).toBe( - 123_456 - ) + expect( + readyState(result.current)?.reconnectDeadlineByPlayerId[playerId] + ).toBe(123_456) ) emitMessage(toGameReconnectDeadlineMessage(roomKey, playerId, 0), rerender) await waitFor(() => expect( - result.current?.reconnectDeadlineByPlayerId[playerId] + readyState(result.current)?.reconnectDeadlineByPlayerId[playerId] ).toBeUndefined() ) }) @@ -212,9 +350,9 @@ describe('useGameSetup', () => { rerender ) await waitFor(() => - expect(result.current?.reconnectDeadlineByPlayerId[playerId]).toBe( - 123_456 - ) + expect( + readyState(result.current)?.reconnectDeadlineByPlayerId[playerId] + ).toBe(123_456) ) emitMessage( @@ -229,7 +367,9 @@ describe('useGameSetup', () => { rerender ) await waitFor(() => - expect(result.current?.reconnectDeadlineByPlayerId).toEqual({}) + expect(readyState(result.current)?.reconnectDeadlineByPlayerId).toEqual( + {} + ) ) }) @@ -240,11 +380,68 @@ describe('useGameSetup', () => { await waitFor(() => expect(result.current).not.toBeNull()) await act(async () => { - await result.current?.sendPlay(0) + await readyState(result.current)?.sendPlay(0) + }) + + expect(readyState(result.current)?.playerOne.columns).toEqual([[], [], []]) + expect(readyState(result.current)?.errorMessage).toBe('network unavailable') + expect(readyState(result.current)?.isLoading).toBe(false) + }) + + it('keeps a newer authoritative state instead of rolling back', async () => { + let rejectPlay: (error: Error) => void = () => {} + vi.mocked(play).mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectPlay = reject + }) + ) + const { rerender, result } = renderHook(() => useGameSetup(), { wrapper }) + emitMessage(toGameStateMessage(createGameState(1), roomKey), rerender) + await waitFor(() => expect(readyState(result.current)?.revision).toBe(1)) + + let pendingSend: Promise = Promise.resolve() + await act(async () => { + pendingSend = readyState(result.current)?.sendPlay(0) ?? Promise.resolve() + }) + + emitMessage(toGameStateMessage(createGameState(2), roomKey), rerender) + await waitFor(() => expect(readyState(result.current)?.revision).toBe(2)) + + await act(async () => { + rejectPlay(new Error('network unavailable')) + await pendingSend + }) + + expect(readyState(result.current)?.revision).toBe(2) + expect(readyState(result.current)?.errorMessage).toBe('network unavailable') + expect(readyState(result.current)?.isLoading).toBe(false) + }) + + it('surfaces an identity failure and retries after a reset', async () => { + localStorage.removeItem('playerId') + vi.mocked(ensurePlayerIdentity) + .mockReset() + .mockRejectedValueOnce(new Error('identity unavailable')) + + const { result } = renderHook(() => useGameSetup(), { wrapper }) + + await waitFor(() => expect(result.current).not.toBeNull()) + if (result.current === null || result.current.status !== 'identity-error') { + throw new Error('Expected the hook to surface the identity error.') + } + const errorState = result.current + expect(errorState.errorMessage).toBe('identity unavailable') + + vi.mocked(ensurePlayerIdentity).mockResolvedValueOnce({ + playerId, + credential: 'new-credential', + recoveryPhrase: 'a-phrase' + } as PlayerCredentials) + await act(async () => { + errorState.retryIdentity() }) - expect(result.current?.playerOne.columns).toEqual([[], [], []]) - expect(result.current?.errorMessage).toBe('network unavailable') - expect(result.current?.isLoading).toBe(false) + await waitFor(() => expect(result.current).toBeNull()) }) }) diff --git a/apps/front/src/components/GameContext/useGameSetup.ts b/apps/front/src/components/GameContext/useGameSetup.ts index 8b2a44c2..bc533f32 100644 --- a/apps/front/src/components/GameContext/useGameSetup.ts +++ b/apps/front/src/components/GameContext/useGameSetup.ts @@ -1,6 +1,6 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' -import { useLocation } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import useWebSocketImport, { ReadyState } from 'react-use-websocket' import { AI_PLAYER_ID, @@ -13,19 +13,23 @@ import { PROTOCOL_VERSION, type GameSettings } from '@knucklebones/common' +import { useLocalizedPath } from '../../hooks/useLocalizedPath' import { useRoomKey } from '../../hooks/useRoomKey' import { + ApiRequestError, createWebSocketTicket, deleteDisplayName, updateDisplayName, initGame, play, reportClientProtocolDiagnostic, + resignGame, voteRematch } from '../../utils/api' import { getStoredPlayerId } from '../../utils/identityStorage' import { getPlayerFromId, getPlayerSide } from '../../utils/player' -import { getWebSocketUrl, preparePlayers } from './utils' +import { ensurePlayerIdentity } from '../../utils/playerIdentity' +import { getWebSocketUrl, preparePlayers, prepareRankedTurn } from './utils' // react-use-websocket 4.13 publishes a CommonJS object containing its default // export. Vite 8 exposes that object directly when the importer is ESM. @@ -38,9 +42,13 @@ const useWebSocket = export function useGameSetup() { const { t } = useTranslation() + const navigate = useNavigate() + const localizedPath = useLocalizedPath() const [gameState, setGameState] = React.useState(null) const [isLoading, setIsLoading] = React.useState(true) const [errorMessage, setErrorMessage] = React.useState(null) + const [identityError, setIdentityError] = React.useState(null) + const [identityRetryAttempt, setIdentityRetryAttempt] = React.useState(0) const [presenceByPlayerId, setPresenceByPlayerId] = React.useState< Record >({}) @@ -49,23 +57,62 @@ export function useGameSetup() { const roomKey = useRoomKey() const latestRevision = React.useRef({ roomKey, value: -1 }) const state = useLocation().state as GameSettings | undefined - const playerId = getStoredPlayerId()! + const [playerId, setPlayerId] = React.useState( + () => getStoredPlayerId() ?? undefined + ) + React.useEffect(() => { + if (playerId !== undefined) { + return + } + + let disposed = false + void ensurePlayerIdentity() + .then(({ playerId: nextPlayerId }) => { + if (!disposed) { + setPlayerId(nextPlayerId) + } + }) + .catch((error) => { + if (!disposed) { + const message = + error instanceof Error ? error.message : t('identity.error') + setIdentityError(message) + setErrorMessage(message) + } + }) + + return () => { + disposed = true + } + }, [playerId, t, identityRetryAttempt]) const getAuthenticatedWebSocketUrl = React.useCallback(async () => { - const { ticket } = await createWebSocketTicket({ roomKey, playerId }) + const { ticket } = await createWebSocketTicket({ + roomKey, + playerId: playerId! + }) return getWebSocketUrl(roomKey, ticket) }, [playerId, roomKey]) const { lastJsonMessage, readyState } = useWebSocket( - getAuthenticatedWebSocketUrl + playerId === undefined ? null : getAuthenticatedWebSocketUrl, + { + shouldReconnect: () => true, + retryOnError: true, + reconnectInterval: (attempt) => Math.min(1_000 * 2 ** attempt, 15_000) + } ) const isGameStateReady = gameState !== null - const playerSide = isGameStateReady - ? getPlayerSide(playerId, gameState) - : 'spectator' + const playerSide = + isGameStateReady && playerId !== undefined + ? getPlayerSide(playerId, gameState) + : 'spectator' const [playerOne, playerTwo] = isGameStateReady ? preparePlayers(playerSide, gameState) : [] + const rankedTurn = isGameStateReady + ? prepareRankedTurn(playerSide, gameState.rankedTurn) + : undefined const winner = gameState?.winnerId !== undefined @@ -117,6 +164,10 @@ export function useGameSetup() { return nextDeadlines }) } else if (serverEvent.data.type === 'game.error') { + if (serverEvent.data.payload.code === 'RANKED_ASSIGNMENT_EXPIRED') { + navigate(localizedPath('/ranked'), { replace: true }) + return + } setErrorMessage(serverEvent.data.payload.message) } return @@ -161,6 +212,18 @@ export function useGameSetup() { } } + const isTimedOutPlayer = + nextGameState.outcome === 'game-ended' && + nextGameState.finishReason === 'forfeit' && + nextGameState.forfeitReason === 'timeout' && + nextGameState.winnerId !== playerId && + (nextGameState.playerOne.id === playerId || + nextGameState.playerTwo.id === playerId) + if (isTimedOutPlayer) { + navigate(localizedPath('/'), { replace: true }) + return + } + setGameState(nextGameState) if (nextGameState.outcome !== 'ongoing') { setReconnectDeadlineByPlayerId({}) @@ -168,7 +231,7 @@ export function useGameSetup() { setIsLoading(false) setErrorMessage(null) } - }, [lastJsonMessage, roomKey, t]) + }, [lastJsonMessage, localizedPath, navigate, playerId, roomKey, t]) React.useEffect(() => { setPresenceByPlayerId({}) @@ -176,7 +239,7 @@ export function useGameSetup() { }, [roomKey]) React.useEffect(() => { - if (readyState === ReadyState.OPEN) { + if (readyState === ReadyState.OPEN && playerId !== undefined) { initGame( { roomKey, playerId }, { playerType: 'human', boType: state?.boType } @@ -195,10 +258,18 @@ export function useGameSetup() { } }) .catch((error) => { + if ( + error instanceof ApiRequestError && + error.status === 409 && + error.code === 'RANKED_ASSIGNMENT_EXPIRED' + ) { + navigate(localizedPath('/ranked'), { replace: true }) + return + } setErrorMessage(error.message) }) } - }, [roomKey, playerId, readyState, state]) + }, [roomKey, playerId, readyState, state, navigate, localizedPath]) async function sendPlay(column: number) { const dice = playerOne?.dice @@ -208,10 +279,11 @@ export function useGameSetup() { const body = { column, dice, - author: playerId + author: playerId! } const previousGameState = gameState + const previousRevision = previousGameState?.revision const realGameState = GameState.fromJson(gameState!) realGameState.applyPlay(body, false) @@ -219,11 +291,21 @@ export function useGameSetup() { setGameState(mutatedGameState) - await play({ roomKey, playerId }, { column }).catch((error) => { - setErrorMessage(error.message) - setGameState(previousGameState) - setIsLoading(false) - }) + await play({ roomKey, playerId: playerId! }, { column }).catch( + (error) => { + setErrorMessage(error.message) + // Only roll the optimistic move back if no newer authoritative state + // was applied while the request was in flight. Otherwise the server + // state (or the next broadcast) wins. + const isLatest = + latestRevision.current.roomKey === roomKey && + latestRevision.current.value === (previousRevision ?? -1) + if (isLatest) { + setGameState(previousGameState) + } + setIsLoading(false) + } + ) } } @@ -231,36 +313,57 @@ export function useGameSetup() { setErrorMessage(null) } + function retryIdentity() { + setIdentityError(null) + setErrorMessage(null) + setIdentityRetryAttempt((attempt) => attempt + 1) + } + // Mouais à voir comment on peut repenser les options ici async function _voteRematch() { - await voteRematch({ roomKey, playerId }).catch((error) => { + await voteRematch({ roomKey, playerId: playerId! }).catch((error) => { setErrorMessage(error.message) }) } + async function resign(): Promise { + try { + await resignGame({ roomKey }) + return true + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : t('ranked.resign.error') + ) + return false + } + } + async function voteContinueBo() { - await voteRematch({ roomKey, playerId }).catch((error) => { + await voteRematch({ roomKey, playerId: playerId! }).catch((error) => { setErrorMessage(error.message) }) } async function voteContinueIndefinitely() { - await voteRematch({ roomKey, playerId }, { boType: 'indefinite' }).catch( - (error) => { - setErrorMessage(error.message) - } - ) + await voteRematch( + { roomKey, playerId: playerId! }, + { boType: 'indefinite' } + ).catch((error) => { + setErrorMessage(error.message) + }) } async function _updateDisplayName(newDisplayName: string) { if (isEmptyOrBlank(newDisplayName)) { - await deleteDisplayName({ roomKey, playerId }).catch((error) => { - setErrorMessage(error.message) - }) + await deleteDisplayName({ roomKey, playerId: playerId! }).catch( + (error) => { + setErrorMessage(error.message) + } + ) } else { await updateDisplayName( - { roomKey, playerId }, + { roomKey, playerId: playerId! }, { displayName: newDisplayName } ).catch((error) => { setErrorMessage(error.message) @@ -269,15 +372,24 @@ export function useGameSetup() { } // Easy way to do a type guard - if (!isGameStateReady) { + if (identityError !== null) { + return { + status: 'identity-error' as const, + errorMessage: identityError, + retryIdentity + } + } + if (!isGameStateReady || playerId === undefined) { return null } return { + status: 'ready' as const, ...gameState, isLoading, playerOne, playerTwo, + rankedTurn, playerId, playerSide, winner, @@ -289,6 +401,7 @@ export function useGameSetup() { voteContinueBo, voteContinueIndefinitely, voteRematch: _voteRematch, + resign, updateDisplayName: _updateDisplayName } } diff --git a/apps/front/src/components/GameContext/utils.ts b/apps/front/src/components/GameContext/utils.ts index bfe6255a..08ef2189 100644 --- a/apps/front/src/components/GameContext/utils.ts +++ b/apps/front/src/components/GameContext/utils.ts @@ -14,6 +14,21 @@ export function preparePlayers( ] } +export function prepareRankedTurn( + playerSide: PlayerSide, + rankedTurn: IGameState['rankedTurn'] +): IGameState['rankedTurn'] { + if (rankedTurn === undefined || playerSide !== 'player-two') { + return rankedTurn + } + + return { + ...rankedTurn, + playerOneTimeouts: rankedTurn.playerTwoTimeouts, + playerTwoTimeouts: rankedTurn.playerOneTimeouts + } +} + export function getWebSocketUrl(roomKey: string, ticket: string) { let hostname = import.meta.env.VITE_WORKER_URL diff --git a/apps/front/src/components/GameOutcome.test.tsx b/apps/front/src/components/GameOutcome.test.tsx new file mode 100644 index 00000000..2eefa0f0 --- /dev/null +++ b/apps/front/src/components/GameOutcome.test.tsx @@ -0,0 +1,147 @@ +import * as React from 'react' +import { MemoryRouter } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { getRankedProfile } from '../utils/api' +import { useGame } from './GameContext' +import { GameOutcome } from './GameOutcome' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, values?: Record) => + values === undefined ? key : `${key} ${JSON.stringify(values)}` + }) +})) + +vi.mock('../hooks/detectDevice', () => ({ + useIsOnDesktop: () => true +})) + +vi.mock('../hooks/useLocalizedPath', () => ({ + useLocalizedPath: () => (path: string) => path +})) + +vi.mock('../hooks/useRoomKey', () => ({ + useRoomKey: () => '33333333-3333-4333-8333-333333333333' +})) + +vi.mock('../utils/api', () => ({ + getRankedProfile: vi.fn(), + getRankedRematchStatus: vi.fn(), + requestRankedRematch: vi.fn() +})) + +vi.mock('../utils/identityStorage', () => ({ + getStoredPlayerId: () => '11111111-1111-4111-8111-111111111111' +})) + +vi.mock('../utils/rankedMatchStorage', () => ({ + getStoredRankedMatchAssignment: () => ({ + matchId: '44444444-4444-4444-8444-444444444444', + roomKey: '33333333-3333-4333-8333-333333333333', + queueKey: 'classic:bo1', + ratingPool: 'classic', + format: 'bo1', + playerOneId: '11111111-1111-4111-8111-111111111111', + playerTwoId: '22222222-2222-4222-8222-222222222222', + playerOneRating: 1200, + playerTwoRating: 1200, + createdAt: 1, + expiresAt: 2 + }), + storeRankedMatchAssignment: vi.fn() +})) + +vi.mock('./GameContext', () => ({ + useGame: vi.fn() +})) + +function rankedGame(isLoading: boolean) { + const playerOne = { + id: '11111111-1111-4111-8111-111111111111', + inGameName: 'Player One', + isPlayerOne: true, + score: 10 + } + const playerTwo = { + id: '22222222-2222-4222-8222-222222222222', + inGameName: 'Player Two', + isPlayerOne: false, + score: 20 + } + + return { + outcome: 'game-ended', + winner: playerTwo, + finishReason: 'completed', + forfeitReason: undefined, + isLoading, + playerSide: 'player-one', + playerOne, + playerTwo, + rematchVote: undefined, + presenceByPlayerId: {}, + boType: 1, + voteRematch: vi.fn(), + voteContinueBo: vi.fn(), + voteContinueIndefinitely: vi.fn() + } +} + +describe('GameOutcome ranked rating', () => { + beforeEach(() => { + vi.mocked(getRankedProfile).mockReset() + vi.mocked(getRankedProfile).mockResolvedValue({ + playerId: '11111111-1111-4111-8111-111111111111', + ratingPool: 'classic', + rating: 1184, + gamesPlayed: 1, + wins: 0, + draws: 0, + losses: 1 + }) + }) + + it('waits for the authoritative final state before loading the rating', async () => { + vi.mocked(useGame).mockReturnValue(rankedGame(true) as never) + const view = render( + + + + ) + + expect(getRankedProfile).not.toHaveBeenCalled() + + vi.mocked(useGame).mockReturnValue(rankedGame(false) as never) + view.rerender( + + + + ) + + await waitFor(() => expect(getRankedProfile).toHaveBeenCalledOnce()) + expect( + await screen.findByText(/ranked.result.rating-change/) + ).toHaveTextContent('1184') + }) + + it('disables ranked rematches after a timeout forfeit', () => { + const game = rankedGame(false) + vi.mocked(useGame).mockReturnValue({ + ...game, + winner: game.playerOne, + finishReason: 'forfeit', + forfeitReason: 'timeout' + } as never) + + render( + + + + ) + + expect( + screen.getByRole('button', { name: 'ranked.result.rematch' }) + ).toBeDisabled() + }) +}) diff --git a/apps/front/src/components/GameOutcome.tsx b/apps/front/src/components/GameOutcome.tsx index fe1a5f21..60888e0d 100644 --- a/apps/front/src/components/GameOutcome.tsx +++ b/apps/front/src/components/GameOutcome.tsx @@ -1,16 +1,66 @@ +import * as React from 'react' import { useTranslation } from 'react-i18next' +import { Link, useNavigate } from 'react-router-dom' import { PlayIcon } from '@heroicons/react/24/outline' import { t } from 'i18next' import { useIsOnDesktop } from '../hooks/detectDevice' +import { useLocalizedPath } from '../hooks/useLocalizedPath' +import { useRoomKey } from '../hooks/useRoomKey' +import { + getRankedProfile, + getRankedRematchStatus, + requestRankedRematch +} from '../utils/api' +import { getStoredPlayerId } from '../utils/identityStorage' +import { + getStoredRankedMatchAssignment, + storeRankedMatchAssignment +} from '../utils/rankedMatchStorage' import { Button } from './Button' import { useGame, type InGameContext } from './GameContext' import { ShortcutModal } from './ShortcutModal' -type GetWinMessageArgs = Pick +type GetWinMessageArgs = Pick< + InGameContext, + | 'finishReason' + | 'forfeitReason' + | 'outcome' + | 'playerOne' + | 'playerSide' + | 'playerTwo' + | 'winner' +> -function getWinMessage({ outcome, winner }: GetWinMessageArgs) { +function getWinMessage({ + finishReason, + forfeitReason, + outcome, + playerOne, + playerSide, + playerTwo, + winner +}: GetWinMessageArgs) { if (outcome !== 'ongoing') { if (winner !== undefined) { + if ( + outcome === 'game-ended' && + finishReason === 'forfeit' && + forfeitReason !== undefined + ) { + if (playerSide === 'spectator') { + const loser = winner.id === playerOne.id ? playerTwo : playerOne + return t(`game.forfeit.${forfeitReason}.spectator` as const, { + loser: loser.inGameName, + winner: winner.inGameName + }) + } + return t( + `game.forfeit.${forfeitReason}.${ + winner.isPlayerOne ? 'you-win' : 'opponent-win' + }` as const, + { player: winner.inGameName } + ) + } const gameScope = outcome === 'round-ended' ? 'round' : 'game' const playerWin = winner.isPlayerOne ? 'you-win' : 'opponent-win' return t(`game.${gameScope}.${playerWin}` as const, { @@ -71,10 +121,14 @@ export function GameOutcome() { const { outcome, winner, + finishReason, + forfeitReason, + isLoading, playerSide, playerOne, playerTwo, rematchVote, + presenceByPlayerId, boType, voteRematch, voteContinueBo, @@ -84,8 +138,41 @@ export function GameOutcome() { const hasVoted = rematchVote === playerOne.id const isOnDesktop = useIsOnDesktop() const { t } = useTranslation() + const roomKey = useRoomKey() + const rankedAssignment = React.useMemo( + () => getStoredRankedMatchAssignment(roomKey), + [roomKey] + ) + const [rankedRating, setRankedRating] = React.useState() + const [rankedRatingError, setRankedRatingError] = React.useState(false) + + React.useEffect(() => { + let cancelled = false + if ( + outcome === 'game-ended' && + rankedAssignment !== undefined && + !isLoading + ) { + void getRankedProfile() + .then((profile) => { + if (!cancelled) { + setRankedRating(profile.rating) + setRankedRatingError(false) + } + }) + .catch(() => { + if (!cancelled) { + setRankedRatingError(true) + } + }) + } + return () => { + cancelled = true + } + }, [isLoading, outcome, rankedAssignment]) if (outcome === 'ongoing') { + if (rankedAssignment !== undefined) return null // On peut mettre un VS semi-transparent dans le fond de la partie // pour rappeler cet élément sans pour autant que ça prenne de l'espace dans // le layout. @@ -94,8 +181,29 @@ export function GameOutcome() { const content = (
-

{getWinMessage({ outcome, winner })}

- {!isSpectator && ( +

+ {getWinMessage({ + finishReason, + forfeitReason, + outcome, + playerOne, + playerSide, + playerTwo, + winner + })} +

+ {!isSpectator && rankedAssignment !== undefined ? ( + + ) : !isSpectator ? ( - )} + ) : null} {!isSpectator && + rankedAssignment === undefined && (hasVoted ? (

{t('game.waiting-rematch', { player: playerTwo.inGameName })}

) : ( @@ -125,7 +234,7 @@ export function GameOutcome() {
) - if (isOnDesktop) { + if (isOnDesktop || isSpectator) { return content } @@ -139,3 +248,138 @@ export function GameOutcome() { ) } + +function RankedResultRating({ + assignment, + hasError, + opponentAvailable, + opponentRequested, + rating +}: { + assignment: NonNullable> + hasError: boolean + opponentAvailable: boolean + opponentRequested: boolean + rating?: number +}) { + const { t } = useTranslation() + const localizedPath = useLocalizedPath() + const navigate = useNavigate() + const roomKey = useRoomKey() + const playerId = getStoredPlayerId() + const [rematchStatus, setRematchStatus] = React.useState< + 'idle' | 'requesting' | 'waiting' | 'unavailable' + >('idle') + const [rematchError, setRematchError] = React.useState(false) + const previousRating = + playerId === assignment.playerOneId + ? assignment.playerOneRating + : assignment.playerTwoRating + const change = rating === undefined ? undefined : rating - previousRating + + const handleRematchStatus = React.useCallback( + (status: Awaited>): boolean => { + if (status.status === 'matched') { + storeRankedMatchAssignment(status.match) + navigate(localizedPath(`/room/${status.match.roomKey}`), { + state: { playerType: 'human', boType: 1 } + }) + return true + } + setRematchStatus( + status.status === 'opponent-unavailable' ? 'unavailable' : 'waiting' + ) + return false + }, + [localizedPath, navigate] + ) + + React.useEffect(() => { + if (rematchStatus !== 'waiting') { + return + } + + let disposed = false + let timeout: ReturnType | undefined + const poll = async () => { + try { + const status = await getRankedRematchStatus(roomKey) + if (!disposed && !handleRematchStatus(status)) { + timeout = setTimeout(() => void poll(), 500) + } + } catch { + if (!disposed) { + setRematchError(true) + timeout = setTimeout(() => void poll(), 1_000) + } + } + } + timeout = setTimeout(() => void poll(), 500) + return () => { + disposed = true + clearTimeout(timeout) + } + }, [handleRematchStatus, rematchStatus, roomKey]) + + async function requestRematch() { + setRematchStatus('requesting') + setRematchError(false) + try { + handleRematchStatus(await requestRankedRematch(roomKey)) + } catch { + setRematchStatus('idle') + setRematchError(true) + } + } + + const isOpponentUnavailable = + rematchStatus === 'unavailable' || !opponentAvailable + + return ( +
+

+ {hasError + ? t('ranked.result.rating-error') + : rating === undefined || change === undefined + ? t('ranked.rating-loading') + : t('ranked.result.rating-change', { + before: previousRating, + after: rating, + change: change > 0 ? `+${change}` : String(change) + })} +

+
+ + +
+ {isOpponentUnavailable ? ( +

{t('ranked.result.opponent-left')}

+ ) : opponentRequested && rematchStatus === 'idle' ? ( +

{t('ranked.result.opponent-rematch')}

+ ) : rematchStatus === 'waiting' ? ( +

{t('ranked.result.rematch-pending')}

+ ) : rematchError ? ( +

+ {t('ranked.result.rematch-error')} +

+ ) : null} +
+ ) +} diff --git a/apps/front/src/components/GameSettings/GameSettingsModal.tsx b/apps/front/src/components/GameSettings/GameSettingsModal.tsx index 5bde88e1..d2fd7e89 100644 --- a/apps/front/src/components/GameSettings/GameSettingsModal.tsx +++ b/apps/front/src/components/GameSettings/GameSettingsModal.tsx @@ -7,6 +7,7 @@ import { type Difficulty, type PlayerType } from '@knucklebones/common' +import { useLocalizedPath } from '../../hooks/useLocalizedPath' import { Button } from '../Button' import { Modal, type ModalProps } from '../Modal' import { type Option, ToggleGroup } from '../ToggleGroup' @@ -59,6 +60,7 @@ export function GameSettingsModal({ const [difficulty, setDifficulty] = React.useState('medium') const [boType, setBoType] = React.useState('indefinite') const { t } = useTranslation() + const localizedPath = useLocalizedPath() return ( @@ -81,7 +83,7 @@ export function GameSettingsModal({
+ -
diff --git a/apps/front/src/components/IconButton.tsx b/apps/front/src/components/IconButton.tsx index 8d4df541..63489c13 100644 --- a/apps/front/src/components/IconButton.tsx +++ b/apps/front/src/components/IconButton.tsx @@ -19,7 +19,7 @@ export function IconButton< diff --git a/apps/front/src/components/Language.tsx b/apps/front/src/components/Language.tsx index fc28c153..2ff28747 100644 --- a/apps/front/src/components/Language.tsx +++ b/apps/front/src/components/Language.tsx @@ -1,25 +1,48 @@ +import type * as React from 'react' import { useTranslation } from 'react-i18next' +import { useLocation, useNavigate } from 'react-router-dom' import { GlobeAltIcon } from '@heroicons/react/24/outline' import { getPathWithoutLanguage, supportedLanguages } from '../translations' import { Button } from './Button' -function getNextLanguagePath(currentLanguage: string) { +function getNextLanguage(currentLanguage: string) { const currentIndex = supportedLanguages.findIndex(({ value }) => currentLanguage.startsWith(value) ) - const nextLang = supportedLanguages[currentIndex === 0 ? 1 : 0].value - return `/${nextLang}${getPathWithoutLanguage()}` + return supportedLanguages[(currentIndex + 1) % supportedLanguages.length] + .value } // https://ui.shadcn.com/docs/components/select ? export function Language() { const { t, i18n } = useTranslation() - const nextLanguagePath = getNextLanguagePath(i18n.language) + const { pathname } = useLocation() + const navigate = useNavigate() + const nextLanguage = getNextLanguage(i18n.language) + const nextLanguagePath = `/${nextLanguage}${getPathWithoutLanguage(pathname)}` + + function changeLanguage(event: React.MouseEvent) { + if ( + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return + } + + event.preventDefault() + document.documentElement.lang = nextLanguage + void i18n.changeLanguage(nextLanguage) + void navigate(nextLanguagePath) + } return ( - - ) - } - - return children -} diff --git a/apps/front/src/components/PlayerIdentityTransfer.tsx b/apps/front/src/components/PlayerIdentityTransfer.tsx index 12dcb01d..7a9dbb7f 100644 --- a/apps/front/src/components/PlayerIdentityTransfer.tsx +++ b/apps/front/src/components/PlayerIdentityTransfer.tsx @@ -6,6 +6,7 @@ import { EyeSlashIcon, IdentificationIcon } from '@heroicons/react/24/outline' +import { QRCodeSVG } from 'qrcode.react' import { createIdentityTransferCode, parseIdentityTransferCode, @@ -18,7 +19,6 @@ import { rotateIdentityRecovery } from '../utils/api' import { - confirmRecoveryPhrase, getPendingRecoveryPhrase, storePendingRecoveryPhrase, storePlayerCredentials @@ -150,12 +150,6 @@ export function PlayerIdentityTransfer() { } } - function acknowledgeRecoveryPhrase() { - confirmRecoveryPhrase() - setRecoveryPhrase(undefined) - setIsRecoveryVisible(false) - } - async function recoverPlayerIdentity() { const normalizedPhrase = recoveryInput.trim().toLowerCase() if (!recoveryPhraseSchema.safeParse(normalizedPhrase).success) { @@ -184,7 +178,9 @@ export function PlayerIdentityTransfer() { } label={t('identity.transfer.label')} - isInitiallyOpen={recoveryPhrase !== undefined} + onOpen={() => { + if (transferCode === '') void issueTransferCode() + }} >
{t('identity.transfer.title')} @@ -207,6 +203,20 @@ export function PlayerIdentityTransfer() { aria-label={t('identity.transfer.code-label')} className='rounded-md border-2 border-slate-300 bg-white px-3 py-2 font-mono text-sm dark:border-slate-600 dark:bg-slate-800' /> + {isCodeVisible && transferCode !== '' && ( +
+
+ +
+

+ {t('identity.transfer.qr-description')} +

+
+ )}
-
)} - + {recoveryPhrase === undefined && ( + + )}

{t('identity.recovery.use-title')}