diff --git a/__tests__/components/game-results-modal.test.tsx b/__tests__/components/game-results-modal.test.tsx index f92a42cd..cb8ebd8b 100644 --- a/__tests__/components/game-results-modal.test.tsx +++ b/__tests__/components/game-results-modal.test.tsx @@ -127,8 +127,6 @@ describe('GameResultsModal', () => { expect(screen.getByText('profile.gameResults.replayReady')).toBeTruthy() expect(screen.getByText('profile.gameResults.summaryWinner')).toBeTruthy() expect(screen.getByText('profile.gameResults.duration')).toBeTruthy() - expect(screen.getByText('profile.gameResults.endedOn')).toBeTruthy() - expect(screen.getAllByText('ABCD1').length).toBeGreaterThan(0) fireEvent.click(screen.getByRole('button', { name: 'profile.gameReplay.watch' })) diff --git a/__tests__/components/player-stats-dashboard.test.tsx b/__tests__/components/player-stats-dashboard.test.tsx index c6327973..d3e62fc4 100644 --- a/__tests__/components/player-stats-dashboard.test.tsx +++ b/__tests__/components/player-stats-dashboard.test.tsx @@ -98,7 +98,6 @@ describe('PlayerStatsDashboard', () => { expect(await screen.findByText('profile.stats.dashboard.title')).toBeTruthy() expect(screen.getByText('24')).toBeTruthy() expect(screen.getByText('75%')).toBeTruthy() - expect(screen.getByText('1080profile.stats.dashboard.summary.secondsSuffix')).toBeTruthy() expect(mockFetch).toHaveBeenCalledWith('/api/user/user-1/stats', { cache: 'no-store' }) diff --git a/app/api/lobby/[code]/route.ts b/app/api/lobby/[code]/route.ts index b9ff9e85..385c050e 100644 --- a/app/api/lobby/[code]/route.ts +++ b/app/api/lobby/[code]/route.ts @@ -7,7 +7,8 @@ import { apiLogger } from '@/lib/logger' import { rateLimit, rateLimitPresets } from '@/lib/rate-limit' import { getRequestAuthUser } from '@/lib/request-auth' import { createGameEngine, DEFAULT_GAME_TYPE, isSupportedGameType } from '@/lib/game-registry' -import { getGameMetadata as getCatalogGameMetadata } from '@/lib/game-catalog' +import { getGameMetadata as getCatalogGameMetadata, isAvailableGameType } from '@/lib/game-catalog' +import { LOBBY_THEME_IDS } from '@/lib/lobby-themes' import { pickRelevantLobbyGame } from '@/lib/lobby-snapshot' import { sanitizeLobbyCreatorIdentity, sanitizeLobbyUserIdentity } from '@/lib/lobby-response' import { type RestorableGameState } from '@/lib/game-engine' @@ -144,6 +145,8 @@ const updateLobbySettingsSchema = z turnTimer: z.number().int().min(30).max(180).optional(), allowSpectators: z.boolean().optional(), maxSpectators: z.number().int().min(0).max(100).optional(), + theme: z.string().optional(), + gameType: z.string().optional(), }) .refine((value) => Object.keys(value).length > 0, { message: 'At least one setting must be provided', @@ -692,7 +695,19 @@ export async function PATCH( } const updates = parsedBody.data - const gameMetadata = getCatalogGameMetadata(lobby.gameType || DEFAULT_GAME_TYPE) + + // Validate theme if provided + if (typeof updates.theme === 'string' && !LOBBY_THEME_IDS.includes(updates.theme as typeof LOBBY_THEME_IDS[number])) { + return NextResponse.json({ error: 'Invalid theme' }, { status: 400 }) + } + + // Validate gameType if provided + if (typeof updates.gameType === 'string' && !isAvailableGameType(updates.gameType)) { + return NextResponse.json({ error: 'Game type is not available' }, { status: 400 }) + } + + const effectiveGameType = updates.gameType ?? lobby.gameType ?? DEFAULT_GAME_TYPE + const gameMetadata = getCatalogGameMetadata(effectiveGameType) const minAllowedPlayers = Math.max(2, gameMetadata?.minPlayers ?? 2) const maxAllowedPlayers = Math.min(10, gameMetadata?.maxPlayers ?? 10) const activePlayerCount = Array.isArray(activeGame?.players) ? activeGame.players.length : 0 @@ -726,10 +741,20 @@ export async function PATCH( } } + // When switching game type, clamp maxPlayers to new game's bounds + let clampedMaxPlayers: number | undefined + if (typeof updates.gameType === 'string' && updates.gameType !== lobby.gameType) { + const currentMax = typeof updates.maxPlayers === 'number' ? updates.maxPlayers : lobby.maxPlayers + const clamped = Math.min(maxAllowedPlayers, Math.max(minAllowedPlayers, currentMax)) + if (clamped !== lobby.maxPlayers || typeof updates.maxPlayers === 'number') { + clampedMaxPlayers = clamped + } + } + const updatedLobby = await prisma.lobbies.update({ where: { id: lobby.id }, data: { - ...(typeof updates.maxPlayers === 'number' ? { maxPlayers: updates.maxPlayers } : {}), + ...(typeof updates.maxPlayers === 'number' ? { maxPlayers: updates.maxPlayers } : clampedMaxPlayers !== undefined ? { maxPlayers: clampedMaxPlayers } : {}), ...(typeof updates.turnTimer === 'number' ? { turnTimer: updates.turnTimer } : {}), ...(typeof updates.allowSpectators === 'boolean' ? { @@ -740,6 +765,8 @@ export async function PATCH( ...(typeof updates.maxSpectators === 'number' && updates.allowSpectators !== false ? { maxSpectators: updates.maxSpectators } : {}), + ...(typeof updates.theme === 'string' ? { theme: updates.theme } : {}), + ...(typeof updates.gameType === 'string' ? { gameType: updates.gameType } : {}), }, select: { id: true, @@ -748,9 +775,19 @@ export async function PATCH( allowSpectators: true, maxSpectators: true, turnTimer: true, + theme: true, + gameType: true, }, }) + // If game type changed, also update the waiting game record + if (typeof updates.gameType === 'string' && updates.gameType !== lobby.gameType && activeGame) { + await prisma.games.update({ + where: { id: activeGame.id }, + data: { gameType: toPersistedGameType(updates.gameType) }, + }) + } + // Postgres Changes on Lobbies table broadcasts the settings update automatically log.info('Lobby settings updated', { code, @@ -759,6 +796,8 @@ export async function PATCH( maxPlayers: updatedLobby.maxPlayers, turnTimer: updatedLobby.turnTimer, allowSpectators: updatedLobby.allowSpectators, + theme: updatedLobby.theme, + gameType: updatedLobby.gameType, }) return NextResponse.json({ success: true, lobby: updatedLobby }) diff --git a/app/lobby/[code]/LobbyPageClient.tsx b/app/lobby/[code]/LobbyPageClient.tsx index 16dd620f..9ee087d3 100644 --- a/app/lobby/[code]/LobbyPageClient.tsx +++ b/app/lobby/[code]/LobbyPageClient.tsx @@ -1596,6 +1596,7 @@ function LobbyPageContent({ onSwitchToDedicatedPage }: { onSwitchToDedicatedPage const isCreator = lobby?.creatorId === session?.user?.id || (isGuest && lobby?.creatorId === guestId) + const isCurrentUserPremium = !!(game?.players?.find(p => p.userId === getCurrentUserId())?.user as { isPremium?: boolean } | undefined)?.isPremium const playerCount = game?.players?.length || 0 // Can start game if user is creator (single player games are allowed - bot will be auto-added) const canStartGame = isCreator @@ -1981,6 +1982,7 @@ function LobbyPageContent({ onSwitchToDedicatedPage }: { onSwitchToDedicatedPage game={game} soundEnabled={soundEnabled} canEditSettings={isCreator && !startingGame} + isPremium={isCurrentUserPremium} onUpdateSettings={updateLobbySettings} onSoundToggle={() => { sounds.toggle() diff --git a/app/lobby/[code]/components/LobbyInfo.tsx b/app/lobby/[code]/components/LobbyInfo.tsx index a718bc03..68449338 100644 --- a/app/lobby/[code]/components/LobbyInfo.tsx +++ b/app/lobby/[code]/components/LobbyInfo.tsx @@ -1,9 +1,10 @@ import { useMemo, useState, type KeyboardEvent } from 'react' import { useRouter } from 'next/navigation' import { showToast } from '@/lib/i18n-toast' -import { getGameMetadata } from '@/lib/game-catalog' +import { getGameMetadata, getCatalogAvailableGames } from '@/lib/game-catalog' import { useTranslation } from '@/lib/i18n-helpers' import { getGameLobbiesRoute } from '@/lib/public-game-access' +import { LOBBY_THEMES, LOBBY_THEME_IDS, FREE_LOBBY_THEME, type LobbyTheme } from '@/lib/lobby-themes' import type { Game, Lobby } from '@/types/game' interface LobbyInfoProps { @@ -11,11 +12,14 @@ interface LobbyInfoProps { game: Game | null soundEnabled: boolean canEditSettings?: boolean + isPremium?: boolean onUpdateSettings?: (updates: { maxPlayers?: number turnTimer?: number allowSpectators?: boolean maxSpectators?: number + theme?: string + gameType?: string }) => Promise onSoundToggle: () => void onLeave: () => void @@ -23,13 +27,14 @@ interface LobbyInfoProps { variant?: 'standalone' | 'header' } -type EditableSettingKey = 'maxPlayers' | 'turnTimer' | 'allowSpectators' +type EditableSettingKey = 'maxPlayers' | 'turnTimer' | 'allowSpectators' | 'theme' | 'gameType' export default function LobbyInfo({ lobby, game, soundEnabled, canEditSettings = false, + isPremium = false, onUpdateSettings, onSoundToggle, onLeave, @@ -69,6 +74,8 @@ export default function LobbyInfo({ return Array.from({ length: maxValue - minValue + 1 }, (_, index) => minValue + index) }, [currentPlayers, gameMeta?.maxPlayers, gameMeta?.minPlayers]) + const availableGames = useMemo(() => getCatalogAvailableGames(), []) + const handleCopyInvite = () => { if (typeof window !== 'undefined') { navigator.clipboard @@ -119,7 +126,7 @@ export default function LobbyInfo({ const applySettingUpdate = async ( key: EditableSettingKey, - updates: { maxPlayers?: number; turnTimer?: number; allowSpectators?: boolean; maxSpectators?: number }, + updates: { maxPlayers?: number; turnTimer?: number; allowSpectators?: boolean; maxSpectators?: number; theme?: string; gameType?: string }, ) => { if (!onUpdateSettings) return @@ -226,7 +233,7 @@ export default function LobbyInfo({ -
+
{t('game.ui.spectatorsLabel')}

{spectatorsLabel}

+ {canEditLobbySettings && ( +
openEditor('gameType')} + onKeyDown={(event) => handleCardKeyDown(event, 'gameType')} + role="button" + tabIndex={0} + aria-label={t('lobby.changeGame')} + > +

{t('lobby.changeGame')}

+

+ {gameMeta?.icon ?? '🎮'} {gameMeta?.name ?? 'Game'} +

+
+ )} + {canEditLobbySettings && ( +
openEditor('theme')} + onKeyDown={(event) => handleCardKeyDown(event, 'theme')} + role="button" + tabIndex={0} + aria-label={t('lobby.changeTheme')} + > +

{t('lobby.changeTheme')}

+

+ {LOBBY_THEMES[((lobby?.theme as LobbyTheme) in LOBBY_THEMES ? lobby?.theme : 'default') as LobbyTheme].name} +

+
+ )}
@@ -394,6 +431,75 @@ export default function LobbyInfo({ )} )} + + {activeSettingEditor === 'gameType' && ( + <> +

+ {t('lobby.changeGame')} +

+
+ {availableGames.map((g) => { + const meta = g.gameType ? getGameMetadata(g.gameType) : null + const isActive = g.gameType === lobby?.gameType + return ( + + ) + })} +
+ + )} + + {activeSettingEditor === 'theme' && ( + <> +

+ {t('lobby.changeTheme')} +

+
+ {LOBBY_THEME_IDS.map((themeId) => { + const theme = LOBBY_THEMES[themeId] + const isActive = (lobby?.theme ?? 'default') === themeId + const isPremiumTheme = themeId !== FREE_LOBBY_THEME + const isLocked = isPremiumTheme && !isPremium + return ( + + ) + })} +
+ + )}
)} diff --git a/app/lobby/[code]/hooks/useLobbyActions.ts b/app/lobby/[code]/hooks/useLobbyActions.ts index c1ed43f0..d7df4544 100644 --- a/app/lobby/[code]/hooks/useLobbyActions.ts +++ b/app/lobby/[code]/hooks/useLobbyActions.ts @@ -81,6 +81,8 @@ interface LobbySettingsUpdatePayload { turnTimer?: number allowSpectators?: boolean maxSpectators?: number + theme?: string + gameType?: string } interface LobbySnapshotResult { diff --git a/components/GameResultsModal.tsx b/components/GameResultsModal.tsx index 2c1512f1..e1d68fa4 100644 --- a/components/GameResultsModal.tsx +++ b/components/GameResultsModal.tsx @@ -299,14 +299,6 @@ export default function GameResultsModal({ : t('profile.gameResults.winnerPending') const locale = typeof navigator === 'undefined' ? 'en' : navigator.language - const usesEndedAtLabel = - game?.status === 'finished' || game?.status === 'cancelled' || game?.status === 'abandoned' - const secondaryTimestampLabel = usesEndedAtLabel - ? t('profile.gameResults.endedOn') - : t('profile.gameResults.lastUpdated') - const secondaryTimestampValue = game - ? formatShortDate((usesEndedAtLabel ? game.endedAt : game.updatedAt) || game.updatedAt) - : '-' const summaryText = game ? winner ? t('profile.gameResults.summaryWinner', { player: winnerLabel }) @@ -333,24 +325,10 @@ export default function GameResultsModal({ label: t('profile.gameResults.duration'), value: formatCompactDuration(game.durationMs, locale), }, - { - label: secondaryTimestampLabel, - value: secondaryTimestampValue, - }, { label: t('profile.gameReplay.players'), value: String(game.players.length), }, - { - label: t('profile.gameResults.replayStatus'), - value: game.hasReplay - ? t('profile.gameResults.replayAvailable') - : t('profile.gameResults.replayUnavailable'), - }, - { - label: t('profile.gameResults.roomCode'), - value: game.lobbyCode, - }, ] : [] diff --git a/components/PlayerStatsDashboard.tsx b/components/PlayerStatsDashboard.tsx index a9f79549..b4204331 100644 --- a/components/PlayerStatsDashboard.tsx +++ b/components/PlayerStatsDashboard.tsx @@ -387,14 +387,6 @@ export default function PlayerStatsDashboard({ userId }: PlayerStatsDashboardPro value: formatPercent(stats.overall.winRate), accentClassName: 'bg-bd-lav text-[#6758d8]', }, - { - id: 'avgDuration', - label: t('profile.stats.dashboard.summary.avgDuration'), - value: `${Math.round(stats.overall.avgGameDurationMinutes * 60)}${t( - 'profile.stats.dashboard.summary.secondsSuffix' - )}`, - accentClassName: 'bg-bd-sun text-[#9b6b00]', - }, { id: 'bestStreak', label: t('profile.stats.dashboard.summary.bestStreak'), diff --git a/components/replay/ConnectFourReplayRenderer.tsx b/components/replay/ConnectFourReplayRenderer.tsx new file mode 100644 index 00000000..b48ff8a3 --- /dev/null +++ b/components/replay/ConnectFourReplayRenderer.tsx @@ -0,0 +1,133 @@ +import { useTranslation } from '@/lib/i18n-helpers' +import type { ReplayRendererProps } from './types' + +type CellValue = 1 | 2 | null + +function isCFBoard(value: unknown): value is CellValue[][] { + return ( + Array.isArray(value) && + value.length === 6 && + value.every( + (row) => + Array.isArray(row) && + row.length === 7 && + row.every((cell) => cell === 1 || cell === 2 || cell === null), + ) + ) +} + +function isWinningLine(value: unknown): value is [number, number][] { + return Array.isArray(value) && value.every((pair) => Array.isArray(pair) && pair.length === 2) +} + +export default function ConnectFourReplayRenderer({ + snapshotState, + players, + playerNameById, +}: ReplayRendererProps) { + const { t } = useTranslation() + + const state = snapshotState as Record | null + if (!state || typeof state !== 'object') return null + + const data = state.data as Record | null + if (!data || typeof data !== 'object') return null + + const board = isCFBoard(data.board) ? data.board : null + if (!board) return null + + const winningLine = isWinningLine(data.winningLine) ? data.winningLine : null + const currentDisc = data.currentDisc === 1 || data.currentDisc === 2 ? data.currentDisc : null + const winner = data.winner === 1 || data.winner === 2 || data.winner === 'draw' ? data.winner : null + const lastDroppedRow = typeof data.lastDroppedRow === 'number' ? data.lastDroppedRow : null + const lastDroppedCol = typeof data.lastDroppedCol === 'number' ? data.lastDroppedCol : null + + const winningCells = new Set() + if (winningLine) { + for (const [r, c] of winningLine) winningCells.add(`${r}-${c}`) + } + + const p1Name = players[0] ? (playerNameById.get(players[0].userId) ?? t('profile.gameReplay.board.redDisc')) : t('profile.gameReplay.board.redDisc') + const p2Name = players[1] ? (playerNameById.get(players[1].userId) ?? t('profile.gameReplay.board.yellowDisc')) : t('profile.gameReplay.board.yellowDisc') + + function cellClass(cell: CellValue, isWin: boolean, isLast: boolean): string { + if (cell === 1) { + return isWin + ? 'bg-red-400 ring-2 ring-white shadow-md' + : isLast + ? 'bg-red-500 animate-pulse' + : 'bg-red-500' + } + if (cell === 2) { + return isWin + ? 'bg-amber-300 ring-2 ring-white shadow-md' + : isLast + ? 'bg-amber-400 animate-pulse' + : 'bg-amber-400' + } + return 'bg-slate-200 dark:bg-slate-700' + } + + return ( +
+

+ {t('profile.gameReplay.board.redDisc')} vs {t('profile.gameReplay.board.yellowDisc')} +

+ +
+ {/* Board */} +
+ {board.map((row, rowIndex) => + row.map((cell, colIndex) => { + const key = `${rowIndex}-${colIndex}` + const isWin = winningCells.has(key) + const isLast = rowIndex === lastDroppedRow && colIndex === lastDroppedCol + return ( +
+ ) + }), + )} +
+ + {/* Status */} +
+
+
+ + {p1Name} + +
+
+
+ + {p2Name} + +
+ +
+ {winner === 'draw' ? ( +

+ {t('profile.gameReplay.draw')} +

+ ) : winner === 1 || winner === 2 ? ( +

+ {winner === 1 ? p1Name : p2Name} +

+ ) : currentDisc ? ( +

+ {currentDisc === 1 ? p1Name : p2Name} + {' '}{t('profile.gameReplay.board.toMove')} +

+ ) : null} +
+
+
+
+ ) +} diff --git a/components/replay/registry.ts b/components/replay/registry.ts index 5ff72b1d..f7eb6b32 100644 --- a/components/replay/registry.ts +++ b/components/replay/registry.ts @@ -4,6 +4,7 @@ import type { ReplayRendererProps } from './types' const registry: Record Promise<{ default: ComponentType }>> = { tic_tac_toe: () => import('./TTTReplayRenderer'), yahtzee: () => import('./YahtzeeReplayRenderer'), + connect_four: () => import('./ConnectFourReplayRenderer'), } export function hasReplayRenderer(gameType: string): boolean { diff --git a/locales/en.ts b/locales/en.ts index 11ed76b5..ef3e3ee7 100644 --- a/locales/en.ts +++ b/locales/en.ts @@ -77,6 +77,8 @@ const en = { spectatorWatching: '👁 Someone is watching', maxSpectatorsLabel: 'Max spectators', maxSpectatorsUnlimited: 'Unlimited', + changeGame: 'Game', + changeTheme: 'Theme', spectatingBanner: 'You are spectating this game', backToGames: 'Back to Games', leftLobby: 'You left the lobby', @@ -1416,9 +1418,6 @@ const en = { summary: { totalGames: 'Total games', winRate: 'Win rate', - avgDuration: 'Avg duration', - minutesSuffix: 'm', - secondsSuffix: 's', favoriteGame: 'Favorite game', wld: 'Wins / Losses / Draws', wins: 'Wins', @@ -1691,6 +1690,11 @@ const en = { claimSubmitted: 'Claim submitted', challengeSubmitted: 'Challenge submitted', roundAdvanced: 'Round advanced', + board: { + redDisc: 'Red', + yellowDisc: 'Yellow', + toMove: 'to move', + }, }, gameResults: { title: 'Game Results', @@ -1708,12 +1712,6 @@ const en = { winnerLabel: 'Winner', playedOn: 'Played on', duration: 'Duration', - endedOn: 'Ended', - lastUpdated: 'Last update', - replayStatus: 'Replay', - replayAvailable: 'Ready', - replayUnavailable: 'Not available', - roomCode: 'Room code', noWinner: 'No winner', summaryWinner: '{{player}} won this match.', summaryDraw: 'This match ended in a draw.', diff --git a/locales/no.ts b/locales/no.ts index cd22c585..626ce84c 100644 --- a/locales/no.ts +++ b/locales/no.ts @@ -77,6 +77,8 @@ const no = { spectatorWatching: '👁 Noen ser på', maxSpectatorsLabel: 'Maks tilskuere', maxSpectatorsUnlimited: 'Ubegrenset', + changeGame: 'Spill', + changeTheme: 'Tema', spectatingBanner: 'Du ser på dette spillet', backToGames: 'Tilbake til spill', leftLobby: 'Du forlot lobbyen', @@ -1416,9 +1418,6 @@ const no = { summary: { totalGames: 'Totalt antall spill', winRate: 'Vinnrate', - avgDuration: 'Snittvarighet', - minutesSuffix: 'm', - secondsSuffix: 's', favoriteGame: 'Favorittspill', wld: 'Seire / Tap / Uavgjort', wins: 'Seire', @@ -1679,7 +1678,12 @@ const no = { guessSubmitted: 'Gjetning sendt', claimSubmitted: 'Påstand sendt', challengeSubmitted: 'Svar på utfordring sendt', - roundAdvanced: 'Runden gikk videre' + roundAdvanced: 'Runden gikk videre', + board: { + redDisc: 'Rød', + yellowDisc: 'Gul', + toMove: 'skal flytte', + }, }, gameResults: { title: 'Spillresultater', @@ -1697,12 +1701,6 @@ const no = { winnerLabel: 'Vinner', playedOn: 'Spilt', duration: 'Varighet', - endedOn: 'Avsluttet', - lastUpdated: 'Sist oppdatert', - replayStatus: 'Replay', - replayAvailable: 'Klar', - replayUnavailable: 'Ikke tilgjengelig', - roomCode: 'Romkode', noWinner: 'Ingen vinner', summaryWinner: '{{player}} vant denne kampen.', summaryDraw: 'Denne kampen endte uavgjort.', diff --git a/locales/ru.ts b/locales/ru.ts index 888faba6..b0d95a6d 100644 --- a/locales/ru.ts +++ b/locales/ru.ts @@ -77,6 +77,8 @@ const ru = { spectatorWatching: '👁 Кто-то наблюдает', maxSpectatorsLabel: 'Макс. зрителей', maxSpectatorsUnlimited: 'Без ограничений', + changeGame: 'Игра', + changeTheme: 'Тема', spectatingBanner: 'Вы наблюдаете за этой игрой', backToGames: 'Назад к играм', leftLobby: 'Вы покинули лобби', @@ -1416,9 +1418,6 @@ const ru = { summary: { totalGames: 'Всего игр', winRate: 'Процент побед', - avgDuration: 'Средняя длительность', - minutesSuffix: 'м', - secondsSuffix: 'с', favoriteGame: 'Любимая игра', wld: 'Победы / Поражения / Ничьи', wins: 'Победы', @@ -1679,7 +1678,12 @@ const ru = { guessSubmitted: 'Ответ отправлен', claimSubmitted: 'Заявление отправлено', challengeSubmitted: 'Ответ на вызов отправлен', - roundAdvanced: 'Раунд продолжен' + roundAdvanced: 'Раунд продолжен', + board: { + redDisc: 'Красный', + yellowDisc: 'Жёлтый', + toMove: 'ходит', + }, }, gameResults: { title: 'Результаты игры', @@ -1697,12 +1701,6 @@ const ru = { winnerLabel: 'Победитель', playedOn: 'Сыграно', duration: 'Длительность', - endedOn: 'Завершён', - lastUpdated: 'Последнее обновление', - replayStatus: 'Повтор', - replayAvailable: 'Готов', - replayUnavailable: 'Недоступен', - roomCode: 'Код комнаты', noWinner: 'Победителя нет', summaryWinner: '{{player}} выиграл этот матч.', summaryDraw: 'Этот матч завершился вничью.', diff --git a/locales/uk.ts b/locales/uk.ts index 18cb0fb2..808d7825 100644 --- a/locales/uk.ts +++ b/locales/uk.ts @@ -86,6 +86,8 @@ const uk: Translation = { spectatorWatching: '👁 Хтось спостерігає', maxSpectatorsLabel: 'Макс. глядачів', maxSpectatorsUnlimited: 'Без обмежень', + changeGame: 'Гра', + changeTheme: 'Тема', spectatingBanner: 'Ви спостерігаєте за цією грою', backToGames: 'Назад до ігор', leftLobby: 'Ви покинули лобі', @@ -1418,9 +1420,6 @@ const uk: Translation = { summary: { totalGames: 'Всього ігор', winRate: 'Відсоток перемог', - avgDuration: 'Сер. тривалість', - minutesSuffix: 'хв', - secondsSuffix: 'с', favoriteGame: 'Улюблена гра', wld: 'Перемоги / Поразки / Нічиї', wins: 'Перемоги', @@ -1693,6 +1692,11 @@ const uk: Translation = { claimSubmitted: 'Твердження надіслано', challengeSubmitted: 'Відповідь на виклик надіслано', roundAdvanced: 'Раунд продовжено', + board: { + redDisc: 'Червоний', + yellowDisc: 'Жовтий', + toMove: 'ходить', + }, }, gameResults: { title: 'Результати гри', @@ -1710,12 +1714,6 @@ const uk: Translation = { winnerLabel: 'Переможець', playedOn: 'Зіграно', duration: 'Тривалість', - endedOn: 'Завершено', - lastUpdated: 'Останнє оновлення', - replayStatus: 'Повтор', - replayAvailable: 'Готовий', - replayUnavailable: 'Недоступний', - roomCode: 'Код кімнати', noWinner: 'Переможця немає', summaryWinner: '{{player}} виграв цей матч.', summaryDraw: 'Цей матч завершився нічиєю.',