diff --git a/apps/web/src/arcade-games/rithmomachia/Provider.tsx b/apps/web/src/arcade-games/rithmomachia/Provider.tsx index 2bdbf2e0..92f08350 100644 --- a/apps/web/src/arcade-games/rithmomachia/Provider.tsx +++ b/apps/web/src/arcade-games/rithmomachia/Provider.tsx @@ -21,6 +21,14 @@ import type { /** * Context value for Rithmomachia game. */ +export type RithmomachiaRosterStatus = + | { status: 'ok'; activePlayerCount: number; localPlayerCount: number } + | { + status: 'tooFew' | 'tooMany' | 'noLocalControl' + activePlayerCount: number + localPlayerCount: number + } + interface RithmomachiaContextValue { // State state: RithmomachiaState @@ -30,6 +38,11 @@ interface RithmomachiaContextValue { viewerId: string | null playerColor: Color | null isMyTurn: boolean + rosterStatus: RithmomachiaRosterStatus + localActivePlayerIds: string[] + whitePlayerId: string | null + blackPlayerId: string | null + localTurnPlayerId: string | null // Game actions startGame: () => void @@ -92,13 +105,38 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) { const { activePlayers: activePlayerIds, players } = useGameMode() const { mutate: updateGameConfig } = useUpdateGameConfig() - // Get local player ID - const localPlayerId = useMemo(() => { - return Array.from(activePlayerIds).find((id) => { - const player = players.get(id) - return player?.isLocal !== false - }) - }, [activePlayerIds, players]) + const activePlayerList = useMemo(() => Array.from(activePlayerIds), [activePlayerIds]) + + const whitePlayerId = activePlayerList[0] ?? null + const blackPlayerId = activePlayerList[1] ?? null + + const localActivePlayerIds = useMemo( + () => + activePlayerList.filter((id) => { + const player = players.get(id) + return player?.isLocal !== false + }), + [activePlayerList, players] + ) + + const rosterStatus = useMemo(() => { + const activeCount = activePlayerList.length + const localCount = localActivePlayerIds.length + + if (activeCount < 2) { + return { status: 'tooFew', activePlayerCount: activeCount, localPlayerCount: localCount } + } + + if (activeCount > 2) { + return { status: 'tooMany', activePlayerCount: activeCount, localPlayerCount: localCount } + } + + if (localCount === 0) { + return { status: 'noLocalControl', activePlayerCount: activeCount, localPlayerCount: localCount } + } + + return { status: 'ok', activePlayerCount: activeCount, localPlayerCount: localCount } + }, [activePlayerList, localActivePlayerIds]) // Merge saved config from room data const mergedInitialState = useMemo(() => { @@ -129,33 +167,46 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) { applyMove: (state) => state, // No optimistic updates for v1 - rely on server validation }) - // Determine player color (simplified: first player is White, second is Black) + const localTurnPlayerId = useMemo(() => { + const currentId = state.turn === 'W' ? whitePlayerId : blackPlayerId + if (!currentId) return null + return localActivePlayerIds.includes(currentId) ? currentId : null + }, [state.turn, whitePlayerId, blackPlayerId, localActivePlayerIds]) + const playerColor = useMemo((): Color | null => { - if (!localPlayerId) return null - const playerIndex = Array.from(activePlayerIds).indexOf(localPlayerId) - return playerIndex === 0 ? 'W' : 'B' - }, [localPlayerId, activePlayerIds]) + if (localTurnPlayerId) { + return state.turn + } + + if (localActivePlayerIds.length === 1) { + const soleLocalId = localActivePlayerIds[0] + if (soleLocalId === whitePlayerId) return 'W' + if (soleLocalId === blackPlayerId) return 'B' + } + + return null + }, [localTurnPlayerId, localActivePlayerIds, whitePlayerId, blackPlayerId, state.turn]) // Check if it's my turn const isMyTurn = useMemo(() => { - if (!playerColor) return false - return state.turn === playerColor - }, [state.turn, playerColor]) + if (rosterStatus.status !== 'ok') return false + return localTurnPlayerId !== null + }, [rosterStatus.status, localTurnPlayerId]) // Action: Start game const startGame = useCallback(() => { - if (!viewerId || !localPlayerId) return + if (!viewerId || !localTurnPlayerId) return sendMove({ type: 'START_GAME', - playerId: localPlayerId, + playerId: localTurnPlayerId, userId: viewerId, data: { playerColor: playerColor || 'W', - activePlayers: Array.from(activePlayerIds), + activePlayers: activePlayerList, }, }) - }, [sendMove, viewerId, localPlayerId, playerColor, activePlayerIds]) + }, [sendMove, viewerId, localTurnPlayerId, playerColor, activePlayerList]) // Action: Make a move const makeMove = useCallback( @@ -167,11 +218,11 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) { capture?: CaptureData, ambush?: AmbushContext ) => { - if (!viewerId || !localPlayerId) return + if (!viewerId || !localTurnPlayerId) return sendMove({ type: 'MOVE', - playerId: localPlayerId, + playerId: localTurnPlayerId, userId: viewerId, data: { from, @@ -189,17 +240,17 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) { }, }) }, - [sendMove, viewerId, localPlayerId] + [sendMove, viewerId, localTurnPlayerId] ) // Action: Declare harmony const declareHarmony = useCallback( (pieceIds: string[], harmonyType: HarmonyType, params: Record) => { - if (!viewerId || !localPlayerId) return + if (!viewerId || !localTurnPlayerId) return sendMove({ type: 'DECLARE_HARMONY', - playerId: localPlayerId, + playerId: localTurnPlayerId, userId: viewerId, data: { pieceIds, @@ -208,68 +259,68 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) { }, }) }, - [sendMove, viewerId, localPlayerId] + [sendMove, viewerId, localTurnPlayerId] ) // Action: Resign const resign = useCallback(() => { - if (!viewerId || !localPlayerId) return + if (!viewerId || !localTurnPlayerId) return sendMove({ type: 'RESIGN', - playerId: localPlayerId, + playerId: localTurnPlayerId, userId: viewerId, data: {}, }) - }, [sendMove, viewerId, localPlayerId]) + }, [sendMove, viewerId, localTurnPlayerId]) // Action: Offer draw const offerDraw = useCallback(() => { - if (!viewerId || !localPlayerId) return + if (!viewerId || !localTurnPlayerId) return sendMove({ type: 'OFFER_DRAW', - playerId: localPlayerId, + playerId: localTurnPlayerId, userId: viewerId, data: {}, }) - }, [sendMove, viewerId, localPlayerId]) + }, [sendMove, viewerId, localTurnPlayerId]) // Action: Accept draw const acceptDraw = useCallback(() => { - if (!viewerId || !localPlayerId) return + if (!viewerId || !localTurnPlayerId) return sendMove({ type: 'ACCEPT_DRAW', - playerId: localPlayerId, + playerId: localTurnPlayerId, userId: viewerId, data: {}, }) - }, [sendMove, viewerId, localPlayerId]) + }, [sendMove, viewerId, localTurnPlayerId]) // Action: Claim repetition const claimRepetition = useCallback(() => { - if (!viewerId || !localPlayerId) return + if (!viewerId || !localTurnPlayerId) return sendMove({ type: 'CLAIM_REPETITION', - playerId: localPlayerId, + playerId: localTurnPlayerId, userId: viewerId, data: {}, }) - }, [sendMove, viewerId, localPlayerId]) + }, [sendMove, viewerId, localTurnPlayerId]) // Action: Claim fifty-move rule const claimFiftyMove = useCallback(() => { - if (!viewerId || !localPlayerId) return + if (!viewerId || !localTurnPlayerId) return sendMove({ type: 'CLAIM_FIFTY_MOVE', - playerId: localPlayerId, + playerId: localTurnPlayerId, userId: viewerId, data: {}, }) - }, [sendMove, viewerId, localPlayerId]) + }, [sendMove, viewerId, localTurnPlayerId]) // Action: Set config const setConfig = useCallback( @@ -338,6 +389,11 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) { viewerId: viewerId ?? null, playerColor, isMyTurn, + rosterStatus, + localActivePlayerIds, + whitePlayerId, + blackPlayerId, + localTurnPlayerId, startGame, makeMove, declareHarmony, diff --git a/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx b/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx index 66f7a277..1ab66d23 100644 --- a/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx +++ b/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx @@ -3,10 +3,12 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { animated, to, useSpring } from '@react-spring/web' import { useRouter } from 'next/navigation' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { PageWithNav } from '@/components/PageWithNav' import { StandardGameLayout } from '@/components/StandardGameLayout' import { Z_INDEX } from '@/constants/zIndex' +import { useGameMode } from '@/contexts/GameModeContext' +import type { Player } from '@/contexts/GameModeContext' import { useFullscreen } from '@/contexts/FullscreenContext' import { css } from '../../../../styled-system/css' import { useRithmomachia } from '../Provider' @@ -194,11 +196,346 @@ export function RithmomachiaGame() { ) } +function RosterStatusNotice({ phase }: { phase: 'setup' | 'playing' }) { + const { rosterStatus, whitePlayerId, blackPlayerId } = useRithmomachia() + const { players: playerMap, activePlayers: activePlayerIds, addPlayer, setActive } = useGameMode() + const [showManager, setShowManager] = useState(false) + + 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]) + + const activePlayerNames = useMemo( + () => + playersArray + .filter((player) => activePlayerIds.has(player.id)) + .map((player) => + `${player.emoji} ${player.name}${player.isLocal === false ? ' (remote)' : ''}` + ), + [playersArray, activePlayerIds] + ) + + const inactiveLocalPlayer = useMemo( + () => + playersArray.find( + (player) => player.isLocal !== false && !activePlayerIds.has(player.id) + ) || null, + [playersArray, activePlayerIds] + ) + + const removableLocalPlayer = useMemo( + () => + playersArray.find( + (player) => + player.isLocal !== false && + activePlayerIds.has(player.id) && + player.id !== whitePlayerId && + player.id !== blackPlayerId + ) || null, + [playersArray, activePlayerIds, whitePlayerId, blackPlayerId] + ) + + const quickFix = useMemo(() => { + if (rosterStatus.status === 'tooFew') { + if (inactiveLocalPlayer) { + return { + label: `Activate ${inactiveLocalPlayer.name}`, + action: () => setActive(inactiveLocalPlayer.id, true), + } + } + + return { + label: 'Create local player', + action: () => addPlayer({ isActive: true }), + } + } + + if (rosterStatus.status === 'noLocalControl') { + if (inactiveLocalPlayer) { + return { + label: `Activate ${inactiveLocalPlayer.name}`, + action: () => setActive(inactiveLocalPlayer.id, true), + } + } + + return null + } + + if (rosterStatus.status === 'tooMany' && removableLocalPlayer) { + return { + label: `Deactivate ${removableLocalPlayer.name}`, + action: () => setActive(removableLocalPlayer.id, false), + } + } + + return null + }, [rosterStatus.status, inactiveLocalPlayer, removableLocalPlayer, addPlayer, setActive]) + + const heading = useMemo(() => { + switch (rosterStatus.status) { + case 'tooFew': + return 'Need two active players' + case 'tooMany': + return 'Too many active players' + case 'noLocalControl': + return 'Join the roster from this device' + default: + return '' + } + }, [rosterStatus.status]) + + const description = useMemo(() => { + switch (rosterStatus.status) { + case 'tooFew': + return phase === 'setup' + ? 'Rithmomachia needs exactly two active players before the match can begin. Activate or add another player to continue.' + : 'Gameplay is paused until two players are active. Activate or add another player to resume the match.' + case 'tooMany': + return 'Rithmomachia supports only two active players. Deactivate extra players so each color has exactly one seat.' + case 'noLocalControl': + return phase === 'setup' + ? 'All active seats belong to other devices. Activate a local player to control a side before starting.' + : 'All active seats belong to other devices. Activate a local player if you want to make moves from this computer.' + default: + return '' + } + }, [phase, rosterStatus.status]) + + if (rosterStatus.status === 'ok') { + return null + } + + const details: string[] = [] + if (activePlayerNames.length > 0) { + details.push(`Active seats: ${activePlayerNames.join(', ')}`) + } else { + details.push('No players are currently marked active.') + } + + return ( + <> +
+
+

{heading}

+

{description}

+ {details.map((detail) => ( +

+ {detail} +

+ ))} +
+
+ {quickFix && ( + + )} + +
+
+ {showManager && ( + addPlayer({ isActive: true })} + /> + )} + + ) +} + +function InlinePlayerManager({ + players, + activePlayerIds, + onToggleActive, + onAddPlayer, +}: { + players: Player[] + activePlayerIds: Set + onToggleActive: (playerId: string, active: boolean) => void + onAddPlayer: () => void +}) { + return ( +
+
+

+ Player seats +

+ +
+
    + {players.map((player) => { + const isActive = activePlayerIds.has(player.id) + const isLocal = player.isLocal !== false + + return ( +
  • +
    + + {player.emoji} {player.name} + + + {isLocal ? 'Local player' : 'Remote player'} + +
    + +
  • + ) + })} +
+
+ ) +} + /** * Setup phase: game configuration and start button. */ function SetupPhase() { - const { state, startGame, setConfig, lastError, clearError } = useRithmomachia() + const { state, startGame, setConfig, lastError, clearError, rosterStatus } = useRithmomachia() + const startDisabled = rosterStatus.status !== 'ok' const toggleSetting = (key: keyof typeof state) => { if (typeof state[key] === 'boolean') { @@ -265,6 +602,8 @@ function SetupPhase() {

+ + {/* Game Settings */}
Start Game @@ -453,7 +796,7 @@ function SetupPhase() { * Playing phase: main game board and controls. */ function PlayingPhase() { - const { state, isMyTurn, lastError, clearError } = useRithmomachia() + const { state, isMyTurn, lastError, clearError, rosterStatus } = useRithmomachia() return (
)} + +
)} + {!isMyTurn && rosterStatus.status === 'ok' && ( +
+ Waiting for {state.turn === 'W' ? 'White' : 'Black'} +
+ )}