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:
Thomas Hallock
2025-10-30 04:45:00 -05:00
parent 8a5e3f349a
commit 8a11594203
3 changed files with 233 additions and 259 deletions

View File

@@ -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',

View File

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

View File

@@ -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',