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
+
+
+
+
+
+ )
+}
+
/**
* 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'}
+
+ )}