From 8a11594203fb91faee6cbc4cb74367164ecd6d85 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Thu, 30 Oct 2025 04:45:00 -0500 Subject: [PATCH] feat(rithmomachia): integrate roster warning into game nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/RithmomachiaGame.tsx | 398 ++++++------------ apps/web/src/components/PageWithNav.tsx | 6 +- .../web/src/components/nav/GameContextNav.tsx | 88 ++++ 3 files changed, 233 insertions(+), 259 deletions(-) diff --git a/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx b/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx index 843cd702..076843c4 100644 --- a/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx +++ b/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx @@ -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(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} >
{ - 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 ( -
-
-

{heading}

-

{description}

-
- - {/* Actions for tooMany status */} - {rosterStatus.status === 'tooMany' && ( -
- {removableLocalPlayers.length > 0 && ( -
-

- Your players: -

-
- {removableLocalPlayers.map((player) => ( - - ))} -
-
- )} - - {isHost && kickablePlayers.length > 0 && ( -
-

- Remote players (host can kick): -

-
- {kickablePlayers.map((player) => ( - - ))} -
-
- )} -
- )} - - {/* Actions for tooFew status */} - {rosterStatus.status === 'tooFew' && ( -
- {inactiveLocalPlayer ? ( - - ) : ( - - )} -
- )} -
- ) -} - /** * Setup phase: game configuration and start button. */ @@ -561,8 +447,6 @@ function SetupPhase() {

- - {/* Game Settings */}
)} - -
playerStreaks?: Record playerBadges?: Record + // 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 diff --git a/apps/web/src/components/nav/GameContextNav.tsx b/apps/web/src/components/nav/GameContextNav.tsx index 0ba3ad66..d37dcac5 100644 --- a/apps/web/src/components/nav/GameContextNav.tsx +++ b/apps/web/src/components/nav/GameContextNav.tsx @@ -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 && ( +
+
+

+ {rosterWarning.heading} +

+

+ {rosterWarning.description} +

+
+ {rosterWarning.actions && rosterWarning.actions.length > 0 && ( +
+ {rosterWarning.actions.map((action, index) => ( + + ))} +
+ )} +
+ )} +