Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions __tests__/components/game-results-modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }))

Expand Down
1 change: 0 additions & 1 deletion __tests__/components/player-stats-dashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' })

Expand Down
45 changes: 42 additions & 3 deletions app/api/lobby/[code]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
? {
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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 })
Expand Down
2 changes: 2 additions & 0 deletions app/lobby/[code]/LobbyPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -825,7 +825,7 @@
}

setGameInterruptedInfo({ reason: 'abandoned' })
}, [triggerLifecycleRedirect])

Check warning on line 828 in app/lobby/[code]/LobbyPageClient.tsx

View workflow job for this annotation

GitHub Actions / Fast Checks (windows-latest)

React Hook useCallback has an unnecessary dependency: 'triggerLifecycleRedirect'. Either exclude it or remove the dependency array

Check warning on line 828 in app/lobby/[code]/LobbyPageClient.tsx

View workflow job for this annotation

GitHub Actions / Fast Checks (ubuntu-latest)

React Hook useCallback has an unnecessary dependency: 'triggerLifecycleRedirect'. Either exclude it or remove the dependency array

const minPlayersRequired = React.useMemo(() => {
return getLobbyPlayerRequirements(lobby?.gameType as string | undefined).minPlayersRequired
Expand Down Expand Up @@ -1323,7 +1323,7 @@
if (timerActive && isMyTurn() && timeLeft > 0 && timeLeft <= 3) {
playAmbientSound('countdown')
}
}, [timeLeft, timerActive, playAmbientSound])

Check warning on line 1326 in app/lobby/[code]/LobbyPageClient.tsx

View workflow job for this annotation

GitHub Actions / Fast Checks (windows-latest)

React Hook React.useEffect has a missing dependency: 'isMyTurn'. Either include it or remove the dependency array

Check warning on line 1326 in app/lobby/[code]/LobbyPageClient.tsx

View workflow job for this annotation

GitHub Actions / Fast Checks (ubuntu-latest)

React Hook React.useEffect has a missing dependency: 'isMyTurn'. Either include it or remove the dependency array

// Load lobby on mount
useEffect(() => {
Expand Down Expand Up @@ -1596,6 +1596,7 @@

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
Expand Down Expand Up @@ -1981,6 +1982,7 @@
game={game}
soundEnabled={soundEnabled}
canEditSettings={isCreator && !startingGame}
isPremium={isCurrentUserPremium}
onUpdateSettings={updateLobbySettings}
onSoundToggle={() => {
sounds.toggle()
Expand Down
114 changes: 110 additions & 4 deletions app/lobby/[code]/components/LobbyInfo.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,40 @@
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 {
lobby: Lobby
game: Game | null
soundEnabled: boolean
canEditSettings?: boolean
isPremium?: boolean
onUpdateSettings?: (updates: {
maxPlayers?: number
turnTimer?: number
allowSpectators?: boolean
maxSpectators?: number
theme?: string
gameType?: string
}) => Promise<unknown>
onSoundToggle: () => void
onLeave: () => void
/** 'standalone' = sticky card (default). 'header' = flat, rendered inside a parent card. */
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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -226,7 +233,7 @@ export default function LobbyInfo({
</div>
</div>

<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-3">
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3">
<div
className={`rounded-xl border border-bd-line bg-bd-bg2 px-3 py-2 ${
canEditLobbySettings
Expand Down Expand Up @@ -278,6 +285,36 @@ export default function LobbyInfo({
<p className="text-[11px] font-bold uppercase tracking-wider text-bd-ink-muted">{t('game.ui.spectatorsLabel')}</p>
<p className="mt-1 break-words text-sm font-semibold text-bd-ink">{spectatorsLabel}</p>
</div>
{canEditLobbySettings && (
<div
className="cursor-pointer rounded-xl border border-bd-line bg-bd-bg2 px-3 py-2 transition-colors hover:border-bd-ink hover:bg-bd-bg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bd-ink/30"
onClick={() => openEditor('gameType')}
onKeyDown={(event) => handleCardKeyDown(event, 'gameType')}
role="button"
tabIndex={0}
aria-label={t('lobby.changeGame')}
>
<p className="text-[11px] font-bold uppercase tracking-wider text-bd-ink-muted">{t('lobby.changeGame')}</p>
<p className="mt-1 break-words text-sm font-semibold text-bd-ink">
{gameMeta?.icon ?? '🎮'} {gameMeta?.name ?? 'Game'}
</p>
</div>
)}
{canEditLobbySettings && (
<div
className="cursor-pointer rounded-xl border border-bd-line bg-bd-bg2 px-3 py-2 transition-colors hover:border-bd-ink hover:bg-bd-bg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bd-ink/30"
onClick={() => openEditor('theme')}
onKeyDown={(event) => handleCardKeyDown(event, 'theme')}
role="button"
tabIndex={0}
aria-label={t('lobby.changeTheme')}
>
<p className="text-[11px] font-bold uppercase tracking-wider text-bd-ink-muted">{t('lobby.changeTheme')}</p>
<p className="mt-1 break-words text-sm font-semibold text-bd-ink">
{LOBBY_THEMES[((lobby?.theme as LobbyTheme) in LOBBY_THEMES ? lobby?.theme : 'default') as LobbyTheme].name}
</p>
</div>
)}
</div>


Expand Down Expand Up @@ -394,6 +431,75 @@ export default function LobbyInfo({
)}
</>
)}

{activeSettingEditor === 'gameType' && (
<>
<p className="mb-2 text-xs font-semibold text-bd-mint-deep">
{t('lobby.changeGame')}
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{availableGames.map((g) => {
const meta = g.gameType ? getGameMetadata(g.gameType) : null
const isActive = g.gameType === lobby?.gameType
return (
<button
key={g.id}
type="button"
disabled={updatingSetting === 'gameType' || isActive}
onClick={() => g.gameType && void applySettingUpdate('gameType', { gameType: g.gameType })}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-semibold border transition-all ${
isActive
? 'border-bd-ink bg-bd-ink text-bd-bg'
: 'border-bd-line bg-bd-card-warm text-bd-ink hover:border-bd-ink'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
<span>{meta?.icon ?? '🎮'}</span>
<span className="truncate">{meta?.name ?? g.id}</span>
</button>
)
})}
</div>
</>
)}

{activeSettingEditor === 'theme' && (
<>
<p className="mb-2 text-xs font-semibold text-bd-mint-deep">
{t('lobby.changeTheme')}
</p>
<div className="flex flex-wrap gap-2">
{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 (
<button
key={themeId}
type="button"
disabled={updatingSetting === 'theme' || isActive || isLocked}
onClick={() => !isLocked && void applySettingUpdate('theme', { theme: themeId })}
title={isLocked ? '👑 Premium' : theme.name}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold border transition-all ${
isActive
? 'border-bd-ink bg-bd-ink text-bd-bg'
: isLocked
? 'border-bd-line bg-bd-bg2 text-bd-ink-muted cursor-not-allowed opacity-60'
: 'border-bd-line bg-bd-card-warm text-bd-ink hover:border-bd-ink'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
<span
className="inline-block h-3 w-3 rounded-full border border-bd-line/50 shrink-0"
style={{ background: theme.accent }}
/>
<span>{theme.name}</span>
{isLocked && <span className="shrink-0">👑</span>}
</button>
)
})}
</div>
</>
)}
</div>
)}
</div>
Expand Down
2 changes: 2 additions & 0 deletions app/lobby/[code]/hooks/useLobbyActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ interface LobbySettingsUpdatePayload {
turnTimer?: number
allowSpectators?: boolean
maxSpectators?: number
theme?: string
gameType?: string
}

interface LobbySnapshotResult {
Expand Down
22 changes: 0 additions & 22 deletions components/GameResultsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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,
},
]
: []

Expand Down
8 changes: 0 additions & 8 deletions components/PlayerStatsDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Loading
Loading