feat(arcade): add ability to deactivate remote players without kicking user
Room hosts can now deactivate individual remote players without removing the entire user from the room. **Backend:** - Add `setPlayerActiveStatus()` to player-manager for updating player active status - Create `/api/arcade/rooms/:roomId/deactivate-player` endpoint (host only) - Validates host permission, prevents self-deactivation - Broadcasts `player-deactivated` event via socket **Frontend:** - Add `useDeactivatePlayer()` hook in useRoomData - Handle `player-deactivated` socket event to update player list - Update roster warning to show both "Deactivate" and "Kick" for remote players - Deactivate: Soft action, only deactivates the specific player - Kick: Hard action, removes entire user and all their players **UX:** - Host sees two options per remote player in roster warning: - "Deactivate [PlayerName]" (amber button) - "Kick [PlayerName]'s user" (red danger button) - Gives fine-grained control over player participation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
5fac19ef46
commit
3628426a56
|
|
@ -0,0 +1,101 @@
|
|||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getPlayer, getRoomActivePlayers, setPlayerActiveStatus } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/arcade/rooms/:roomId/deactivate-player
|
||||
* Deactivate a specific player in the room (host only)
|
||||
* Body:
|
||||
* - playerId: string - The player to deactivate
|
||||
*/
|
||||
export async function POST(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { roomId } = await context.params
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.playerId) {
|
||||
return NextResponse.json({ error: 'Missing required field: playerId' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if user is the host
|
||||
const members = await getRoomMembers(roomId)
|
||||
const currentMember = members.find((m) => m.userId === viewerId)
|
||||
|
||||
if (!currentMember) {
|
||||
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!currentMember.isCreator) {
|
||||
return NextResponse.json({ error: 'Only the host can deactivate players' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get the player
|
||||
const player = await getPlayer(body.playerId)
|
||||
if (!player) {
|
||||
return NextResponse.json({ error: 'Player not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Find which user owns this player
|
||||
const playerOwnerMember = members.find((m) => m.userId === player.userId)
|
||||
if (!playerOwnerMember) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Player does not belong to a room member' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Can't deactivate your own players (use the regular player controls for that)
|
||||
if (player.userId === viewerId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot deactivate your own players. Use the player controls in the nav.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Deactivate the player
|
||||
await setPlayerActiveStatus(body.playerId, false)
|
||||
|
||||
// Broadcast updates via socket
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
// Get updated player list
|
||||
const memberPlayers = await getRoomActivePlayers(roomId)
|
||||
|
||||
// Convert memberPlayers Map to object for JSON serialization
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
// Notify everyone in the room about the player update
|
||||
io.to(`room:${roomId}`).emit('player-deactivated', {
|
||||
roomId,
|
||||
playerId: body.playerId,
|
||||
playerName: player.name,
|
||||
deactivatedBy: currentMember.displayName,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[Deactivate Player API] Player ${body.playerId} (${player.name}) deactivated by host in room ${roomId}`
|
||||
)
|
||||
} catch (socketError) {
|
||||
console.error('[Deactivate Player API] Failed to broadcast deactivation:', socketError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to deactivate player:', error)
|
||||
return NextResponse.json({ error: 'Failed to deactivate player' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import { StandardGameLayout } from '@/components/StandardGameLayout'
|
|||
import { Z_INDEX } from '@/constants/zIndex'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useFullscreen } from '@/contexts/FullscreenContext'
|
||||
import { useRoomData, useKickUser } from '@/hooks/useRoomData'
|
||||
import { useRoomData, useKickUser, useDeactivatePlayer } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import type { RosterWarning } from '@/components/nav/GameContextNav'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
|
|
@ -143,6 +143,7 @@ function useRosterWarning(phase: 'setup' | 'playing'): RosterWarning | undefined
|
|||
const { roomData } = useRoomData()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { mutate: kickUser } = useKickUser()
|
||||
const { mutate: deactivatePlayer } = useDeactivatePlayer()
|
||||
|
||||
return useMemo(() => {
|
||||
// Don't show notice for 'ok' or 'noLocalControl' (observers are allowed)
|
||||
|
|
@ -239,10 +240,20 @@ function useRosterWarning(phase: 'setup' | 'playing'): RosterWarning | undefined
|
|||
})
|
||||
}
|
||||
|
||||
// Add kick actions for remote players (if host)
|
||||
// Add deactivate and kick actions for remote players (if host)
|
||||
for (const player of kickablePlayers) {
|
||||
// Add deactivate button (softer action)
|
||||
actions.push({
|
||||
label: `Kick ${player.name}`,
|
||||
label: `Deactivate ${player.name}`,
|
||||
onClick: () => {
|
||||
if (roomData) {
|
||||
deactivatePlayer({ roomId: roomData.id, playerId: player.id })
|
||||
}
|
||||
},
|
||||
})
|
||||
// Add kick button (removes entire user)
|
||||
actions.push({
|
||||
label: `Kick ${player.name}'s user`,
|
||||
onClick: () => handleKick(player),
|
||||
variant: 'danger' as const,
|
||||
})
|
||||
|
|
@ -268,6 +279,7 @@ function useRosterWarning(phase: 'setup' | 'playing'): RosterWarning | undefined
|
|||
addPlayer,
|
||||
setActive,
|
||||
kickUser,
|
||||
deactivatePlayer,
|
||||
])
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -351,6 +351,24 @@ export function useRoomData() {
|
|||
}
|
||||
}
|
||||
|
||||
const handlePlayerDeactivated = (data: {
|
||||
roomId: string
|
||||
playerId: string
|
||||
playerName: string
|
||||
deactivatedBy: string
|
||||
memberPlayers: Record<string, RoomPlayer[]>
|
||||
}) => {
|
||||
if (data.roomId === roomData?.id) {
|
||||
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
memberPlayers: data.memberPlayers,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Moderation event handlers
|
||||
const handleKickedFromRoom = (data: { roomId: string; kickedBy: string; reason?: string }) => {
|
||||
setModerationEvent({
|
||||
|
|
@ -486,6 +504,7 @@ export function useRoomData() {
|
|||
socket.on('member-joined', handleMemberJoined)
|
||||
socket.on('member-left', handleMemberLeft)
|
||||
socket.on('room-players-updated', handleRoomPlayersUpdated)
|
||||
socket.on('player-deactivated', handlePlayerDeactivated)
|
||||
socket.on('kicked-from-room', handleKickedFromRoom)
|
||||
socket.on('banned-from-room', handleBannedFromRoom)
|
||||
socket.on('report-submitted', handleReportSubmitted)
|
||||
|
|
@ -499,6 +518,7 @@ export function useRoomData() {
|
|||
socket.off('member-joined', handleMemberJoined)
|
||||
socket.off('member-left', handleMemberLeft)
|
||||
socket.off('room-players-updated', handleRoomPlayersUpdated)
|
||||
socket.off('player-deactivated', handlePlayerDeactivated)
|
||||
socket.off('kicked-from-room', handleKickedFromRoom)
|
||||
socket.off('banned-from-room', handleBannedFromRoom)
|
||||
socket.off('report-submitted', handleReportSubmitted)
|
||||
|
|
@ -768,3 +788,37 @@ export function useKickUser() {
|
|||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate a specific player in the room (host only)
|
||||
*/
|
||||
async function deactivatePlayerInRoomApi(params: {
|
||||
roomId: string
|
||||
playerId: string
|
||||
}): Promise<void> {
|
||||
const response = await fetch(`/api/arcade/rooms/${params.roomId}/deactivate-player`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ playerId: params.playerId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to deactivate player')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Deactivate a specific player in the room (host only)
|
||||
*/
|
||||
export function useDeactivatePlayer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: deactivatePlayerInRoomApi,
|
||||
onSuccess: () => {
|
||||
// The socket will handle updating players, but invalidate just in case
|
||||
queryClient.invalidateQueries({ queryKey: roomKeys.current() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,3 +134,18 @@ export async function getPlayers(playerIds: string[]): Promise<Player[]> {
|
|||
|
||||
return players
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a player's active status
|
||||
* @param playerId - The player ID
|
||||
* @param isActive - The new active status
|
||||
* @returns The updated player
|
||||
*/
|
||||
export async function setPlayerActiveStatus(
|
||||
playerId: string,
|
||||
isActive: boolean
|
||||
): Promise<Player | undefined> {
|
||||
await db.update(schema.players).set({ isActive }).where(eq(schema.players.id, playerId))
|
||||
|
||||
return await getPlayer(playerId)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue