feat(rithmomachia): integrate roster warning into game nav
Move roster status notice from fixed overlay into GameContextNav: **Architecture Changes:** - Add RosterWarning interface to GameContextNav (heading, description, actions) - Create useRosterWarning hook in RithmomachiaGame to compute warning data - Pass warning through PageWithNav → GameContextNav - Remove old RosterStatusNotice component (252 lines deleted) **UX Improvements:** - Warning now appears in nav above player list - Compact, non-intrusive design with left border accent - All actions (deactivate local, kick remote) in one place - Observers allowed (removed noLocalControl restraint) **Benefits:** - No z-index conflicts or overlay positioning issues - Warning integrated with existing nav UI - Flexible system reusable by other games - Cleaner separation of concerns (hook for logic, nav for UI) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useFullscreen } from '@/contexts/FullscreenContext'
|
||||
import { useRoomData, useKickUser } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import type { RosterWarning } from '@/components/nav/GameContextNav'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useRithmomachia } from '../Provider'
|
||||
import type { Piece, RelationKind, RithmomachiaConfig } from '../types'
|
||||
@@ -133,6 +134,143 @@ function CaptureErrorDialog({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to compute roster warning based on game state
|
||||
*/
|
||||
function useRosterWarning(phase: 'setup' | 'playing'): RosterWarning | undefined {
|
||||
const { rosterStatus, whitePlayerId, blackPlayerId } = useRithmomachia()
|
||||
const { players: playerMap, activePlayers: activePlayerIds, addPlayer, setActive } = useGameMode()
|
||||
const { roomData } = useRoomData()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { mutate: kickUser } = useKickUser()
|
||||
|
||||
return useMemo(() => {
|
||||
// Don't show notice for 'ok' or 'noLocalControl' (observers are allowed)
|
||||
if (rosterStatus.status === 'ok' || rosterStatus.status === 'noLocalControl') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const playersArray = Array.from(playerMap.values()).sort((a, b) => {
|
||||
const aTime =
|
||||
typeof a.createdAt === 'number'
|
||||
? a.createdAt
|
||||
: a.createdAt instanceof Date
|
||||
? a.createdAt.getTime()
|
||||
: 0
|
||||
const bTime =
|
||||
typeof b.createdAt === 'number'
|
||||
? b.createdAt
|
||||
: b.createdAt instanceof Date
|
||||
? b.createdAt.getTime()
|
||||
: 0
|
||||
return aTime - bTime
|
||||
})
|
||||
|
||||
const isHost =
|
||||
roomData && viewerId
|
||||
? roomData.members.find((m) => m.userId === viewerId)?.isCreator === true
|
||||
: false
|
||||
|
||||
const removableLocalPlayers = playersArray.filter(
|
||||
(player) =>
|
||||
player.isLocal !== false &&
|
||||
activePlayerIds.has(player.id) &&
|
||||
player.id !== whitePlayerId &&
|
||||
player.id !== blackPlayerId
|
||||
)
|
||||
|
||||
const kickablePlayers =
|
||||
isHost && roomData
|
||||
? playersArray.filter(
|
||||
(player) =>
|
||||
player.isLocal === false &&
|
||||
activePlayerIds.has(player.id) &&
|
||||
player.id !== whitePlayerId &&
|
||||
player.id !== blackPlayerId
|
||||
)
|
||||
: []
|
||||
|
||||
const inactiveLocalPlayer = playersArray.find(
|
||||
(player) => player.isLocal !== false && !activePlayerIds.has(player.id)
|
||||
)
|
||||
|
||||
const handleKick = (player: any) => {
|
||||
if (!roomData) return
|
||||
for (const [userId, players] of Object.entries(roomData.memberPlayers)) {
|
||||
if (players.some((p) => p.id === player.id)) {
|
||||
kickUser({ roomId: roomData.id, userId })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rosterStatus.status === 'tooFew') {
|
||||
const actions = []
|
||||
if (inactiveLocalPlayer) {
|
||||
actions.push({
|
||||
label: `Activate ${inactiveLocalPlayer.name}`,
|
||||
onClick: () => setActive(inactiveLocalPlayer.id, true),
|
||||
})
|
||||
} else {
|
||||
actions.push({
|
||||
label: 'Create local player',
|
||||
onClick: () => addPlayer({ isActive: true }),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
heading: 'Need two active players',
|
||||
description:
|
||||
phase === 'setup'
|
||||
? 'Rithmomachia needs exactly two active players before the match can begin.'
|
||||
: 'Gameplay is paused until two players are active.',
|
||||
actions,
|
||||
}
|
||||
}
|
||||
|
||||
if (rosterStatus.status === 'tooMany') {
|
||||
const actions = []
|
||||
|
||||
// Add deactivate actions for local players
|
||||
for (const player of removableLocalPlayers) {
|
||||
actions.push({
|
||||
label: `Deactivate ${player.name}`,
|
||||
onClick: () => setActive(player.id, false),
|
||||
})
|
||||
}
|
||||
|
||||
// Add kick actions for remote players (if host)
|
||||
for (const player of kickablePlayers) {
|
||||
actions.push({
|
||||
label: `Kick ${player.name}`,
|
||||
onClick: () => handleKick(player),
|
||||
variant: 'danger' as const,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
heading: 'Too many active players',
|
||||
description: 'Rithmomachia supports only two active players. Deactivate or kick extras:',
|
||||
actions,
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}, [
|
||||
rosterStatus.status,
|
||||
phase,
|
||||
playerMap,
|
||||
activePlayerIds,
|
||||
whitePlayerId,
|
||||
blackPlayerId,
|
||||
roomData,
|
||||
viewerId,
|
||||
addPlayer,
|
||||
setActive,
|
||||
kickUser,
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Main Rithmomachia game component.
|
||||
* Orchestrates the game phases and UI.
|
||||
@@ -142,6 +280,7 @@ export function RithmomachiaGame() {
|
||||
const { state, resetGame, goToSetup, whitePlayerId, blackPlayerId } = useRithmomachia()
|
||||
const { setFullscreenElement } = useFullscreen()
|
||||
const gameRef = useRef<HTMLDivElement>(null)
|
||||
const rosterWarning = useRosterWarning(state.gamePhase === 'setup' ? 'setup' : 'playing')
|
||||
|
||||
useEffect(() => {
|
||||
// Register this component's main div as the fullscreen element
|
||||
@@ -197,6 +336,7 @@ export function RithmomachiaGame() {
|
||||
onSetup={goToSetup}
|
||||
currentPlayerId={currentPlayerId}
|
||||
playerBadges={playerBadges}
|
||||
rosterWarning={rosterWarning}
|
||||
>
|
||||
<StandardGameLayout>
|
||||
<div
|
||||
@@ -235,260 +375,6 @@ export function RithmomachiaGame() {
|
||||
)
|
||||
}
|
||||
|
||||
function RosterStatusNotice({ phase }: { phase: 'setup' | 'playing' }) {
|
||||
const { rosterStatus, whitePlayerId, blackPlayerId } = useRithmomachia()
|
||||
const { players: playerMap, activePlayers: activePlayerIds, addPlayer, setActive } = useGameMode()
|
||||
const { roomData } = useRoomData()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { mutate: kickUser } = useKickUser()
|
||||
|
||||
const playersArray = useMemo(() => {
|
||||
const list = Array.from(playerMap.values())
|
||||
return list.sort((a, b) => {
|
||||
const aTime =
|
||||
typeof a.createdAt === 'number'
|
||||
? a.createdAt
|
||||
: a.createdAt instanceof Date
|
||||
? a.createdAt.getTime()
|
||||
: 0
|
||||
const bTime =
|
||||
typeof b.createdAt === 'number'
|
||||
? b.createdAt
|
||||
: b.createdAt instanceof Date
|
||||
? b.createdAt.getTime()
|
||||
: 0
|
||||
return aTime - bTime
|
||||
})
|
||||
}, [playerMap])
|
||||
|
||||
// Check if current user is room host
|
||||
const isHost = useMemo(() => {
|
||||
if (!roomData || !viewerId) return false
|
||||
const currentMember = roomData.members.find((m) => m.userId === viewerId)
|
||||
return currentMember?.isCreator === true
|
||||
}, [roomData, viewerId])
|
||||
|
||||
// Find all removable local players (active but not assigned to white/black)
|
||||
const removableLocalPlayers = useMemo(
|
||||
() =>
|
||||
playersArray.filter(
|
||||
(player) =>
|
||||
player.isLocal !== false &&
|
||||
activePlayerIds.has(player.id) &&
|
||||
player.id !== whitePlayerId &&
|
||||
player.id !== blackPlayerId
|
||||
),
|
||||
[playersArray, activePlayerIds, whitePlayerId, blackPlayerId]
|
||||
)
|
||||
|
||||
// Find all kickable remote players (active remote players, if we're host)
|
||||
const kickablePlayers = useMemo(() => {
|
||||
if (!isHost || !roomData) return []
|
||||
|
||||
return playersArray.filter(
|
||||
(player) =>
|
||||
player.isLocal === false && // Remote player
|
||||
activePlayerIds.has(player.id) && // Active
|
||||
player.id !== whitePlayerId && // Not assigned to white
|
||||
player.id !== blackPlayerId // Not assigned to black
|
||||
)
|
||||
}, [isHost, roomData, playersArray, activePlayerIds, whitePlayerId, blackPlayerId])
|
||||
|
||||
const inactiveLocalPlayer = useMemo(
|
||||
() =>
|
||||
playersArray.find((player) => player.isLocal !== false && !activePlayerIds.has(player.id)) ||
|
||||
null,
|
||||
[playersArray, activePlayerIds]
|
||||
)
|
||||
|
||||
const heading = useMemo(() => {
|
||||
if (rosterStatus.status === 'tooFew') return 'Need two active players'
|
||||
if (rosterStatus.status === 'tooMany') return 'Too many active players'
|
||||
return ''
|
||||
}, [rosterStatus.status])
|
||||
|
||||
const description = useMemo(() => {
|
||||
if (rosterStatus.status === 'tooFew') {
|
||||
return phase === 'setup'
|
||||
? 'Rithmomachia needs exactly two active players before the match can begin.'
|
||||
: 'Gameplay is paused until two players are active.'
|
||||
}
|
||||
if (rosterStatus.status === 'tooMany') {
|
||||
return 'Rithmomachia supports only two active players. Deactivate or kick extras below:'
|
||||
}
|
||||
return ''
|
||||
}, [phase, rosterStatus.status])
|
||||
|
||||
// Don't show notice for 'ok' or 'noLocalControl' (observers are allowed)
|
||||
if (rosterStatus.status === 'ok' || rosterStatus.status === 'noLocalControl') {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleKick = (player: any) => {
|
||||
if (!roomData) return
|
||||
|
||||
// Find the user ID for this player
|
||||
for (const [userId, players] of Object.entries(roomData.memberPlayers)) {
|
||||
if (players.some((p) => p.id === player.id)) {
|
||||
kickUser({ roomId: roomData.id, userId })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
top: '180px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '90%',
|
||||
maxWidth: '800px',
|
||||
borderWidth: '2px',
|
||||
borderColor: 'amber.400',
|
||||
backgroundColor: 'amber.50',
|
||||
color: 'amber.900',
|
||||
p: '4',
|
||||
borderRadius: 'md',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
zIndex: Z_INDEX.GAME.OVERLAY,
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<h3 className={css({ fontWeight: 'bold', fontSize: 'lg' })}>{heading}</h3>
|
||||
<p className={css({ fontSize: 'sm', lineHeight: '1.5', mt: '1' })}>{description}</p>
|
||||
</div>
|
||||
|
||||
{/* Actions for tooMany status */}
|
||||
{rosterStatus.status === 'tooMany' && (
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '2', mt: '3' })}>
|
||||
{removableLocalPlayers.length > 0 && (
|
||||
<div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
fontWeight: 'semibold',
|
||||
mb: '1',
|
||||
color: 'amber.700',
|
||||
})}
|
||||
>
|
||||
Your players:
|
||||
</p>
|
||||
<div className={css({ display: 'flex', flexWrap: 'wrap', gap: '2' })}>
|
||||
{removableLocalPlayers.map((player) => (
|
||||
<button
|
||||
key={player.id}
|
||||
type="button"
|
||||
onClick={() => setActive(player.id, false)}
|
||||
className={css({
|
||||
px: '3',
|
||||
py: '1.5',
|
||||
bg: 'amber.500',
|
||||
color: 'white',
|
||||
borderRadius: 'md',
|
||||
fontWeight: 'semibold',
|
||||
fontSize: 'sm',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
_hover: { bg: 'amber.600' },
|
||||
})}
|
||||
>
|
||||
Deactivate {player.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isHost && kickablePlayers.length > 0 && (
|
||||
<div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
fontWeight: 'semibold',
|
||||
mb: '1',
|
||||
color: 'red.700',
|
||||
})}
|
||||
>
|
||||
Remote players (host can kick):
|
||||
</p>
|
||||
<div className={css({ display: 'flex', flexWrap: 'wrap', gap: '2' })}>
|
||||
{kickablePlayers.map((player) => (
|
||||
<button
|
||||
key={player.id}
|
||||
type="button"
|
||||
onClick={() => handleKick(player)}
|
||||
className={css({
|
||||
px: '3',
|
||||
py: '1.5',
|
||||
bg: 'red.500',
|
||||
color: 'white',
|
||||
borderRadius: 'md',
|
||||
fontWeight: 'semibold',
|
||||
fontSize: 'sm',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
_hover: { bg: 'red.600' },
|
||||
})}
|
||||
>
|
||||
Kick {player.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions for tooFew status */}
|
||||
{rosterStatus.status === 'tooFew' && (
|
||||
<div className={css({ display: 'flex', gap: '2', mt: '3', flexWrap: 'wrap' })}>
|
||||
{inactiveLocalPlayer ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActive(inactiveLocalPlayer.id, true)}
|
||||
className={css({
|
||||
px: '3',
|
||||
py: '2',
|
||||
bg: 'amber.500',
|
||||
color: 'white',
|
||||
borderRadius: 'md',
|
||||
fontWeight: 'semibold',
|
||||
fontSize: 'sm',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
_hover: { bg: 'amber.600' },
|
||||
})}
|
||||
>
|
||||
Activate {inactiveLocalPlayer.name}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addPlayer({ isActive: true })}
|
||||
className={css({
|
||||
px: '3',
|
||||
py: '2',
|
||||
bg: 'amber.500',
|
||||
color: 'white',
|
||||
borderRadius: 'md',
|
||||
fontWeight: 'semibold',
|
||||
fontSize: 'sm',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
_hover: { bg: 'amber.600' },
|
||||
})}
|
||||
>
|
||||
Create local player
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup phase: game configuration and start button.
|
||||
*/
|
||||
@@ -561,8 +447,6 @@ function SetupPhase() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RosterStatusNotice phase="setup" />
|
||||
|
||||
{/* Game Settings */}
|
||||
<div
|
||||
className={css({
|
||||
@@ -800,8 +684,6 @@ function PlayingPhase() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RosterStatusNotice phase="playing" />
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useGameMode } from '../contexts/GameModeContext'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { useViewerId } from '../hooks/useViewerId'
|
||||
import { AppNavBar } from './AppNavBar'
|
||||
import { GameContextNav } from './nav/GameContextNav'
|
||||
import { GameContextNav, type RosterWarning } from './nav/GameContextNav'
|
||||
import type { PlayerBadge } from './nav/types'
|
||||
import { PlayerConfigDialog } from './nav/PlayerConfigDialog'
|
||||
import { ModerationNotifications } from './nav/ModerationNotifications'
|
||||
@@ -24,6 +24,8 @@ interface PageWithNavProps {
|
||||
playerScores?: Record<string, number>
|
||||
playerStreaks?: Record<string, number>
|
||||
playerBadges?: Record<string, PlayerBadge>
|
||||
// Game-specific roster warnings
|
||||
rosterWarning?: RosterWarning
|
||||
}
|
||||
|
||||
export function PageWithNav({
|
||||
@@ -39,6 +41,7 @@ export function PageWithNav({
|
||||
playerScores,
|
||||
playerStreaks,
|
||||
playerBadges,
|
||||
rosterWarning,
|
||||
}: PageWithNavProps) {
|
||||
const { players, activePlayers, setActive, activePlayerCount } = useGameMode()
|
||||
const { roomData, isInRoom, moderationEvent, clearModerationEvent } = useRoomData()
|
||||
@@ -176,6 +179,7 @@ export function PageWithNav({
|
||||
setShowPopover={setShowPopover}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
rosterWarning={rosterWarning}
|
||||
/>
|
||||
) : null
|
||||
|
||||
|
||||
@@ -34,6 +34,18 @@ interface ArcadeRoomInfo {
|
||||
joinCode?: string
|
||||
}
|
||||
|
||||
export interface RosterWarningAction {
|
||||
label: string
|
||||
onClick: () => void
|
||||
variant?: 'primary' | 'danger'
|
||||
}
|
||||
|
||||
export interface RosterWarning {
|
||||
heading: string
|
||||
description: string
|
||||
actions?: RosterWarningAction[]
|
||||
}
|
||||
|
||||
interface GameContextNavProps {
|
||||
navTitle: string
|
||||
navEmoji?: string
|
||||
@@ -62,6 +74,8 @@ interface GameContextNavProps {
|
||||
setShowPopover?: (show: boolean) => void
|
||||
activeTab?: 'add' | 'invite'
|
||||
setActiveTab?: (tab: 'add' | 'invite') => void
|
||||
// Game-specific roster warnings
|
||||
rosterWarning?: RosterWarning
|
||||
}
|
||||
|
||||
export function GameContextNav({
|
||||
@@ -89,6 +103,7 @@ export function GameContextNav({
|
||||
setShowPopover,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
rosterWarning,
|
||||
}: GameContextNavProps) {
|
||||
// Get current user info for moderation
|
||||
const { data: currentUserId } = useViewerId()
|
||||
@@ -180,6 +195,79 @@ export function GameContextNav({
|
||||
onInvitationChange={() => refetchRoomData()}
|
||||
/>
|
||||
|
||||
{/* Roster Warning Banner - Game-specific warnings (e.g., too many players) */}
|
||||
{rosterWarning && (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(251, 191, 36, 0.15), rgba(245, 158, 11, 0.1))',
|
||||
borderLeft: '4px solid #f59e0b',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h4
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#92400e',
|
||||
}}
|
||||
>
|
||||
{rosterWarning.heading}
|
||||
</h4>
|
||||
<p
|
||||
style={{
|
||||
margin: '4px 0 0 0',
|
||||
fontSize: '13px',
|
||||
color: '#78350f',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
>
|
||||
{rosterWarning.description}
|
||||
</p>
|
||||
</div>
|
||||
{rosterWarning.actions && rosterWarning.actions.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{rosterWarning.actions.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: action.variant === 'danger' ? '#dc2626' : '#f59e0b',
|
||||
color: 'white',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.background =
|
||||
action.variant === 'danger' ? '#b91c1c' : '#d97706'
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.background =
|
||||
action.variant === 'danger' ? '#dc2626' : '#f59e0b'
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
||||
Reference in New Issue
Block a user