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:
Thomas Hallock 2025-10-30 07:14:41 -05:00
parent 5fac19ef46
commit 3628426a56
4 changed files with 185 additions and 3 deletions

View File

@ -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 })
}
}

View File

@ -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,
])
}

View File

@ -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() })
},
})
}

View File

@ -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)
}